"""GE-Enforce plugin API.

Two audiences:
- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest.
  Full CRUD + publish lands in P2; this is the P1/first-slice read surface.
- Client (service token, geenforce.fetch scope): GET /manifest serves the
  CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the
  collector's managed-token pattern (X-API-Key or Bearer PAT).
"""

import ipaddress
import os
import time
from datetime import datetime, timedelta
from functools import wraps

from flask import Blueprint, request, Response, send_file, current_app, g
from flask_jwt_extended import jwt_required
from sqlalchemy.exc import IntegrityError

from shopdb.api import (
    db, cache, success_response, error_response, ErrorCodes, require_permission,
    authorized_service_token, Setting, Application,
)

SHAREROOT_SETTING = 'geenforce_share_root'

# Sort floor for revisions with no lastseenat (they sort last regardless).
_EPOCH = datetime.min

from ..models import (
    ManifestScope, ManifestEntry, ManifestPublishedVersion,
    ManifestEnforcementReport, ManifestEnforcementResult, ManifestPayload,
    ManifestBlob, ENTRY_TYPES, PHASES,
)
from ..serializer import scope_to_manifest, entry_to_dict
from ..importer import build_entry, populate_entry
from ..filters import (
    entry_applies, matches_pctype, matches_hostname, matches_machinenumber,
    matches_cmmversion,
)
from .. import service

geenforce_bp = Blueprint('geenforce', __name__)

FETCH_SCOPE = 'geenforce.fetch'
REPORT_SCOPE = 'geenforce.report'
# Comma-separated CIDRs / plain IPs whose callers may reach the client
# endpoints WITHOUT a per-PC token (network-trust for a vaulted fleet).
# Empty/unset = disabled (token stays the only path).
ALLOWED_CIDRS_SETTING = 'geenforce_allowed_cidrs'


def _trusted_client_ip():
    """The trustworthy caller IP for the AUTH allowlist.

    Uses request.remote_addr, NOT the raw X-Forwarded-For header. Proxies APPEND
    to X-Forwarded-For, so its first hop is attacker-controlled: parsing it (as
    _client_ip does for rate-limiting) would let any caller send
    'X-Forwarded-For: <allowlisted-ip>' and bypass the token.

    DEPLOYMENT DEPENDENCY, not a property of this function. remote_addr is only
    trustworthy because of what sits in front:

      - Behind IIS, the URL-Rewrite rule OVERWRITES X-Forwarded-For with the real
        TCP peer, and waitress (--trusted-proxy=127.0.0.1
        --trusted-proxy-headers=x-forwarded-for) derives remote_addr from it.
      - Remove that rule and IIS still forwards whatever X-Forwarded-For the
        CALLER sent. waitress trusts it because it comes from 127.0.0.1, so
        remote_addr becomes attacker-controlled and this allowlist is bypassable
        from anywhere on the network. It does NOT fail closed.
      - A client reaching waitress directly is not a trusted proxy, so there
        remote_addr is its own peer address.

    The rule is the control. See docs/geenforce-api-cutover.md; the Windows
    installer enables it and verifies it is live at the end of an install.
    """
    return request.remote_addr or ''


def _ip_allowlisted():
    """True when the caller IP falls in the configured geenforce allowlist.

    Lets vaulted fleet PCs reach the client endpoints without a per-PC token -
    network trust replaces the shared secret. Fails closed: an unparseable
    caller IP or malformed allowlist entry never matches. Empty setting = off.
    Uses the SPOOF-RESISTANT remote_addr (see _trusted_client_ip), never the raw
    X-Forwarded-For header.
    """
    raw = (Setting.get(ALLOWED_CIDRS_SETTING) or '').strip()
    if not raw:
        return False
    try:
        ip = ipaddress.ip_address(_trusted_client_ip())
    except ValueError:
        return False
    for part in raw.split(','):
        part = part.strip()
        if not part:
            continue
        try:
            if ip in ipaddress.ip_network(part, strict=False):
                return True
        except ValueError:
            continue
    return False


def _require_service_token(scope):
    """Decorator factory: require a managed service token scoped for `scope`,
    OR a caller from the configured IP allowlist.

    Two client-auth paths:
      1. a managed geenforce.fetch/report token (X-API-Key or Bearer PAT), or
      2. a source IP in geenforce_allowed_cidrs (vault network trust).
    No env-key fallback. Fail-closed: neither path -> 401.
    """
    def wrapper(f):
        @wraps(f)
        def decorated(*args, **kwargs):
            token = authorized_service_token(scope)
            if token is not None:
                # Stash for the route so it can honor the token's resource
                # binding (token.resourcescopelist restricts which manifest
                # scopes + blobs this token may pull; None = unrestricted).
                g.geenforce_token = token
                return f(*args, **kwargs)
            # Network-trust path: an allowlisted vault IP reaches the client
            # endpoints with no token. No token = no resource-scope binding
            # (unrestricted), which the perimeter-trust model accepts.
            if _ip_allowlisted():
                g.geenforce_token = None
                return f(*args, **kwargs)
            return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
                                  http_code=401)
        return decorated
    return wrapper


def _token_resource_scopes():
    """Resource-scope allowlist for the authorized token, or None if the token
    may reach any scope. A bound token (e.g. a display's) is pinned to its own
    manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs."""
    token = getattr(g, 'geenforce_token', None)
    return token.resourcescopelist if token is not None else None


require_fetch_token = _require_service_token(FETCH_SCOPE)
require_report_token = _require_service_token(REPORT_SCOPE)


# -- client-facing endpoint ---------------------------------------------------

@geenforce_bp.route('/manifest', methods=['GET'])
@require_fetch_token
def get_manifest():
    """Serve the current published manifest for a scope (fat-client: full scope).

    Query: pctype (=scopename, required), phase (default runtime). The engine
    filters client-side, matching today, so subtype/hostname/machinenumber/
    cmmversion are accepted but not applied here.
    """
    scopename = (request.args.get('pctype') or '').strip()
    phase = (request.args.get('phase') or 'runtime').strip()
    if not scopename:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'pctype is required', http_code=400)

    allowed = _token_resource_scopes()
    if allowed is not None and scopename not in allowed:
        return error_response(ErrorCodes.FORBIDDEN,
                              'token is not allowed this scope', http_code=403)

    scope = ManifestScope.query.filter_by(
        scopename=scopename, phase=phase).first()
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND,
                              f'No scope: {scopename}', http_code=404)

    published = scope.publishedversions.filter_by(iscurrent=True).first()
    if not published:
        return error_response(ErrorCodes.NOT_FOUND,
                              f'{scopename} has no published version',
                              http_code=404)

    etag = f'"{scope.scopeid}-v{published.versionnumber}"'
    if request.headers.get('If-None-Match') == etag:
        return Response(status=304, headers={'ETag': etag})
    return Response(published.manifestjson, mimetype='application/json',
                    headers={'ETag': etag,
                             'X-Manifest-Version': str(published.versionnumber)})


# -- client payload download (share-less installer delivery) ------------------

# GET /payload hardening. This endpoint is reachable with only a read-only
# geenforce.fetch token, so a leaked display token must not be able to pull
# unbounded bytes or hammer it. Two bounds cap the blast radius:
#   - a per-IP fixed-window rate limit (same shape and cache extension as the
#     login limiter in shopdb.core.api.auth, so no new dependency), and
#   - a served-size ceiling: refuse to stream a blob larger than the cap.
# Both are overridable via app config for a site that ships bigger installers.
PAYLOAD_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
PAYLOAD_DOWNLOAD_RATELIMIT_MAX = 120
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60


def _client_ip():
    """Caller IP for rate limiting, honoring the first X-Forwarded-For hop
    (mirrors shopdb.core.api.auth._login_ip)."""
    forwarded = request.headers.get('X-Forwarded-For')
    if forwarded:
        return forwarded.split(',')[0].strip()
    return request.remote_addr or 'unknown'


def _payload_max_bytes():
    return current_app.config.get('GEENFORCE_PAYLOAD_MAX_BYTES',
                                  PAYLOAD_DOWNLOAD_MAX_BYTES)


def _payload_download_ratelimited():
    """Fixed-window per-IP limiter for the payload download endpoint.

    Backed by the existing cache extension (no new dependency), same shape as
    the login limiter. Under the default SimpleCache the counter is per-process,
    so with N gunicorn workers the effective budget is N x the configured max; a
    shared cache backend (Redis/memcached) tightens it to a true global budget.
    Returns True when the caller is over budget for the current window.
    """
    if not current_app.config.get('GEENFORCE_PAYLOAD_RATELIMIT_ENABLED', True):
        return False
    window = current_app.config.get(
        'GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS',
        PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS)
    maxhits = current_app.config.get(
        'GEENFORCE_PAYLOAD_RATELIMIT_MAX', PAYLOAD_DOWNLOAD_RATELIMIT_MAX)
    # Time bucket makes this a fixed window: the key rolls over at each window
    # boundary, so a per-hit set() cannot turn it into a sliding window.
    bucket = int(time.time() // window) if window > 0 else 0
    key = f'geenforcepayloadratelimit:{_client_ip()}:{bucket}'
    count = cache.get(key) or 0
    if count >= maxhits:
        return True
    cache.set(key, count + 1, timeout=window)
    return False


@geenforce_bp.route('/payload/<sha256>', methods=['GET'])
@require_fetch_token
def get_payload(sha256):
    """Serve a payload blob by content hash over HTTPS.

    Lets share-less (Intune/local-account) PCs pull installers the manifest
    references without SMB. Sources: the content-addressed blob store (large
    http payloads) first, then an inline DB payload with this hash. The client
    re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the
    hash (content is immutable).

    Hardened: per-IP rate limited, and a blob over the served-size ceiling is
    refused (413) rather than streamed.
    """
    if _payload_download_ratelimited():
        return error_response('RATE_LIMITED',
                              'Too many payload downloads. Try again later.',
                              http_code=429)

    sha = (sha256 or '').strip().lower()
    if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
        return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
                              http_code=400)

    # A resource-bound token may only pull a blob its own scope(s) reference.
    # Return 404 (not 403) so it cannot probe which hashes exist. Checked before
    # the 304 shortcut so a bound token cannot even confirm a hash via ETag.
    allowed = _token_resource_scopes()
    if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed):
        return error_response(ErrorCodes.NOT_FOUND, 'no such payload',
                              http_code=404)

    etag = f'"{sha}"'
    if request.headers.get('If-None-Match') == etag:
        return Response(status=304, headers={'ETag': etag})

    maxbytes = _payload_max_bytes()

    blob = db.session.get(ManifestBlob, sha)
    if blob and os.path.isfile(service.blob_path(sha)):
        if blob.sizebytes is not None and blob.sizebytes > maxbytes:
            return error_response('PAYLOAD_TOO_LARGE',
                                  'payload exceeds the download size limit',
                                  http_code=413)
        response = send_file(
            service.blob_path(sha),
            mimetype=blob.contenttype or 'application/octet-stream',
            as_attachment=True, download_name=blob.filename)
        response.headers['ETag'] = etag
        return response

    inline = ManifestPayload.query.filter_by(payloadsha256=sha).first()
    if inline:
        if inline.payloadbytes is not None and len(inline.payloadbytes) > maxbytes:
            return error_response('PAYLOAD_TOO_LARGE',
                                  'payload exceeds the download size limit',
                                  http_code=413)
        return Response(
            inline.payloadbytes,
            mimetype=inline.contenttype or 'application/octet-stream',
            headers={'ETag': etag,
                     'Content-Disposition': f'attachment; filename="{inline.filename}"'})

    return error_response(ErrorCodes.NOT_FOUND, 'No payload for that hash',
                          http_code=404)


# -- client reporting (observed state) ----------------------------------------

@geenforce_bp.route('/report', methods=['POST'])
@require_report_token
def post_report():
    """Record one PC's enforcement cycle: applied version (did it receive the
    update?) + per-entry self-heal outcomes (installed/skipped/failed)."""
    payload = request.get_json(silent=True) or {}
    if not (payload.get('hostname') or '').strip():
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'hostname is required', http_code=400)
    try:
        report = service.record_enforcement_report(payload)
        db.session.commit()
    except ValueError as exc:
        db.session.rollback()
        return error_response(ErrorCodes.VALIDATION_ERROR, str(exc),
                              http_code=400)
    return success_response({'reportid': report.reportid,
                             'status': report.status}, message='recorded')


# -- admin read surface (P2 adds full CRUD + publish) -------------------------

@geenforce_bp.route('/scopes', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_scopes():
    """List imaging PC-type scopes with entry + published-version counts."""
    scopes = ManifestScope.query.order_by(
        ManifestScope.phase, ManifestScope.scopename).all()
    data = []
    for scope in scopes:
        current = scope.publishedversions.filter_by(iscurrent=True).first()
        data.append({
            'scopeid': scope.scopeid,
            'scopename': scope.scopename,
            'phase': scope.phase,
            'manifestversion': scope.manifestversion,
        'minlibversion': scope.minlibversion,
            'minlibversion': scope.minlibversion,
            'computertypeid': scope.computertypeid,
            'measuringtooltypeid': scope.measuringtooltypeid,
            'iscommon': scope.iscommon,
            'entrycount': len(scope.entries),
            'publishedversion': current.versionnumber if current else None,
        })
    return success_response(data)


@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def preview_scope(scopeid):
    """Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
                              http_code=404)
    return success_response({'scopename': scope.scopename,
                             'manifest': scope_to_manifest(scope)})


@geenforce_bp.route('/applications', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_applications():
    """The core Applications catalog (id + name) for the curated entry->app link
    picker in the entry editor."""
    apps = Application.query.filter_by(isactive=True).order_by(
        Application.appname).all()
    return success_response([{'appid': a.appid, 'appname': a.appname}
                             for a in apps])


# -- scope CRUD (geenforce.manage) --------------------------------------------

def _scope_summary(scope):
    current = scope.publishedversions.filter_by(iscurrent=True).first()
    return {
        'scopeid': scope.scopeid,
        'scopename': scope.scopename,
        'phase': scope.phase,
        'manifestversion': scope.manifestversion,
        'description': scope.description,
        'computertypeid': scope.computertypeid,
        'measuringtooltypeid': scope.measuringtooltypeid,
        'iscommon': scope.iscommon,
        'entrycount': len(scope.entries),
        'publishedversion': current.versionnumber if current else None,
    }


def _entry_payload(entry):
    """Manifest-entry dict with entryid + sortorder + the curated app link.

    appid/appname are shopdb metadata (not manifest fields), added alongside the
    rendered manifest entry for the editor.
    """
    data = {'entryid': entry.entryid, 'sortorder': entry.sortorder,
            'appid': entry.appid, 'appname': None}
    if entry.appid:
        app = db.session.get(Application, entry.appid)
        data['appname'] = app.appname if app else None
    data.update(entry_to_dict(entry))
    # Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor.
    data['payloadsource'] = entry.payloadsource
    data['payloadref'] = entry.payloadref
    data['payloadsha256'] = entry.payloadsha256
    data['haspayload'] = (entry.payloadsource == 'inline'
                          and entry.payloadsha256 is not None)
    return data


def _apply_app_link(entry, payload):
    """Set the optional curated appid from the payload (shopdb metadata, not a
    manifest field, so handled outside populate_entry). Ignores an unknown id."""
    if 'appid' not in payload:
        return
    appid = payload.get('appid')
    if appid in (None, '', 0):
        entry.appid = None
        return
    # Ignore a non-numeric id rather than 500 (matches the docstring contract).
    try:
        appid = int(appid)
    except (ValueError, TypeError):
        return
    if db.session.get(Application, appid):
        entry.appid = appid


@geenforce_bp.route('/scopes', methods=['POST'])
@jwt_required()
@require_permission('geenforce.manage')
def create_scope():
    payload = request.get_json(silent=True) or {}
    scopename = (payload.get('scopename') or '').strip()
    phase = (payload.get('phase') or 'runtime').strip()
    if not scopename:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'scopename is required', http_code=400)
    if phase not in PHASES:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              f'phase must be one of {PHASES}', http_code=400)
    if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first():
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'scope already exists', http_code=400)
    scope = ManifestScope(
        scopename=scopename, phase=phase,
        manifestversion=str(payload.get('manifestversion', '1.0')),
        minlibversion=(payload.get('minlibversion') or None),
        description=payload.get('description'),
        computertypeid=payload.get('computertypeid'),
        measuringtooltypeid=payload.get('measuringtooltypeid'),
        iscommon=bool(payload.get('iscommon', scopename == 'common')))
    db.session.add(scope)
    db.session.commit()
    return success_response(_scope_summary(scope), http_code=201)


@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_scope(scopeid):
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    data = _scope_summary(scope)
    data['entries'] = [_entry_payload(e) for e in scope.entries]
    return success_response(data)


@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def update_scope(scopeid):
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    payload = request.get_json(silent=True) or {}
    for field in ('description', 'computertypeid', 'measuringtooltypeid'):
        if field in payload:
            setattr(scope, field, payload[field])
    if 'manifestversion' in payload:
        scope.manifestversion = str(payload['manifestversion'])
    if 'minlibversion' in payload:
        # Blank clears it, which returns the scope to a derived floor rather
        # than pinning it at the empty string.
        scope.minlibversion = (payload['minlibversion'] or None)
    if 'iscommon' in payload:
        scope.iscommon = bool(payload['iscommon'])
    db.session.commit()
    return success_response(_scope_summary(scope))


@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
@jwt_required()
@require_permission('geenforce.manage')
def delete_scope(scopeid):
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    db.session.delete(scope)
    db.session.commit()
    return success_response({'deleted': scopeid})


# -- entry CRUD (geenforce.manage) --------------------------------------------

def _validate_entry(payload):
    """Return an error string, or None if the entry payload is valid."""
    if not (payload.get('Name') or '').strip():
        return 'entry Name is required'
    if payload.get('Type') not in ENTRY_TYPES:
        return f'entry Type must be one of {ENTRY_TYPES}'
    return None


@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
@jwt_required()
@require_permission('geenforce.manage')
def create_entry(scopeid):
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    payload = request.get_json(silent=True) or {}
    invalid = _validate_entry(payload)
    if invalid:
        return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
    nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
    entry = build_entry(payload, nextorder)
    _apply_app_link(entry, payload)
    scope.entries.append(entry)
    try:
        db.session.commit()
    except IntegrityError:
        db.session.rollback()
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'an entry with that Name already exists in this scope',
                              http_code=400)
    return success_response(_entry_payload(entry), http_code=201)


@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def update_entry(entryid):
    entry = db.session.get(ManifestEntry, entryid)
    if not entry:
        return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
    payload = request.get_json(silent=True) or {}
    invalid = _validate_entry(payload)
    if invalid:
        return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
    # Free the one-to-one InUseCheck (unique entryid) before populate re-inserts
    # it, so the replacement does not collide with the old row mid-flush.
    if entry.inusecheck is not None:
        db.session.delete(entry.inusecheck)
        entry.inusecheck = None
        db.session.flush()
    populate_entry(entry, payload)
    _apply_app_link(entry, payload)
    try:
        db.session.commit()
    except IntegrityError:
        db.session.rollback()
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'an entry with that Name already exists in this scope',
                              http_code=400)
    return success_response(_entry_payload(entry))


@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
@jwt_required()
@require_permission('geenforce.manage')
def delete_entry(entryid):
    entry = db.session.get(ManifestEntry, entryid)
    if not entry:
        return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
    db.session.delete(entry)
    db.session.commit()
    return success_response({'deleted': entryid})


@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def reorder_entries(scopeid):
    """Set entry order from a list of entryids (the ordering contract)."""
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    order = (request.get_json(silent=True) or {}).get('order') or []
    owned = {e.entryid: e for e in scope.entries}
    if set(order) != set(owned):
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'order must list exactly this scope\'s entry ids',
                              http_code=400)
    for position, entryid in enumerate(order):
        owned[entryid].sortorder = position
    db.session.commit()
    return success_response({'order': order})


# -- simulate: "what would this PC get" ---------------------------------------

@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def simulate_scope(scopeid):
    """Which draft entries apply to a given machine profile, and why the rest
    are filtered out (reuses the engine-mirror filters)."""
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    profile = {
        'pctype': request.args.get('pctype') or scope.scopename,
        'subtype': request.args.get('subtype'),
        'hostname': request.args.get('hostname'),
        'machinenumber': request.args.get('machinenumber'),
        'cmmversion': request.args.get('cmmversion'),
        'phase': scope.phase,
    }
    applied, filtered = [], []
    for entry in scope.entries:
        entry_dict = entry_to_dict(entry)
        if entry_applies(entry_dict, profile):
            applied.append(entry.name)
        else:
            reasons = []
            strict_allowed = profile.get('phase') == 'preinstall'
            if not matches_pctype(entry_dict, profile['pctype'],
                                  profile['subtype'], strict_allowed):
                reasons.append('PCTypes')
            if not matches_hostname(entry_dict, profile['hostname']):
                reasons.append('TargetHostnames')
            if not matches_machinenumber(entry_dict, profile['machinenumber']):
                reasons.append('TargetMachineNumbers')
            if not matches_cmmversion(entry_dict, profile['cmmversion']):
                reasons.append('_CmmVersion')
            filtered.append({'name': entry.name, 'filteredby': reasons})
    return success_response({'profile': profile, 'applied': applied,
                             'filtered': filtered})


# -- compliance: "how much of the fleet has this app" -------------------------

@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def scope_compliance(scopeid):
    """Fleet-install coverage per app-linked entry (from collected PC data).

    One row per entry that carries a curated appid; installed/version-match
    counts come from the computers plugin's ComputerInstalledApp. Degrades to
    null counts (computersplugin: false) when that plugin is absent.
    """
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
                              http_code=404)
    return success_response(service.compliance_for_scope(scope))


# -- inline payload upload / fetch (small scripts + configs) ------------------

PAYLOAD_MAX_BYTES = 1024 * 1024


@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def upload_entry_payload(entryid):
    """Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
    entry = db.session.get(ManifestEntry, entryid)
    if not entry:
        return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
    uploaded = request.files.get('file')
    if uploaded is None:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'a file is required', http_code=400)
    rawbytes = uploaded.read()
    if not rawbytes:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'payload is empty', http_code=400)
    if len(rawbytes) > PAYLOAD_MAX_BYTES:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'payload exceeds 1 MB limit', http_code=400)
    service.store_inline_payload(entry, uploaded.filename,
                                 uploaded.mimetype, rawbytes)
    db.session.commit()
    return success_response(_entry_payload(entry), http_code=201)


@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def download_entry_payload(entryid):
    """Return the stored inline payload bytes for an entry (404 if none)."""
    entry = db.session.get(ManifestEntry, entryid)
    if not entry:
        return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
    payload = ManifestPayload.query.filter_by(entryid=entryid).first()
    if not payload:
        return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload',
                              http_code=404)
    return Response(
        payload.payloadbytes,
        mimetype=payload.contenttype or 'application/octet-stream',
        headers={'Content-Disposition':
                 f'attachment; filename="{payload.filename}"'})


# -- publish lifecycle (geenforce.publish) ------------------------------------

@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def publish_scope_route(scopeid):
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    from flask_jwt_extended import get_jwt_identity
    body = request.get_json(silent=True) or {}
    notes = body.get('notes')
    try:
        publishedby = int(get_jwt_identity())
    except (TypeError, ValueError):
        publishedby = None
    try:
        version = service.publish_scope(
            scope.scopename, scope.phase, notes=notes,
            publishedby=publishedby, force=bool(body.get('force')))
    except service.LibVersionTooOldError as error:
        # 409, not 500: the request is well-formed, the fleet is not ready. The
        # hosts come back so the caller can act without going hunting.
        return error_response(
            ErrorCodes.CONFLICT, str(error), http_code=409,
            details={'required': error.required, 'floor': error.floor,
                     'hosts': error.hosts})
    db.session.commit()
    return success_response({'versionnumber': version}, http_code=201)


@geenforce_bp.route('/scopes/<int:scopeid>/publish-preflight', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def publish_preflight(scopeid):
    """Would publishing this scope outrun the fleet's enforcer lib?

    Lets the UI warn BEFORE someone clicks publish, rather than only refusing
    afterwards.
    """
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    hosts, floor = service.hosts_below_libversion(
        scope.scopename, scope.phase, service.required_libversion(scope))
    return success_response({
        'required': service.required_libversion(scope),
        'floor': floor,
        'hostsbehind': hosts,
        'canpublish': not hosts,
    })


@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_versions(scopeid):
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    versions = ManifestPublishedVersion.query.filter_by(
        scopeid=scopeid).order_by(
        ManifestPublishedVersion.versionnumber.desc()).all()
    return success_response([{
        'versionnumber': v.versionnumber,
        'iscurrent': v.iscurrent,
        'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None,
        'publishedby': v.publishedby,
        'notes': v.notes,
    } for v in versions])


@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
                    methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_version(scopeid, versionnumber):
    version = ManifestPublishedVersion.query.filter_by(
        scopeid=scopeid, versionnumber=versionnumber).first()
    if not version:
        return error_response(ErrorCodes.NOT_FOUND, 'No such version',
                              http_code=404)
    import json as _json
    return success_response({'versionnumber': version.versionnumber,
                             'manifest': _json.loads(version.manifestjson)})


def _normalize_cidrs(raw):
    """Validate + normalize a comma/newline-separated CIDR list. Returns
    (normalized_csv, bad_entries). Bare IPs are accepted (host route)."""
    good, bad = [], []
    for part in (raw or '').replace('\n', ',').split(','):
        part = part.strip()
        if not part:
            continue
        try:
            good.append(str(ipaddress.ip_network(part, strict=False)))
        except ValueError:
            bad.append(part)
    return ','.join(good), bad


@geenforce_bp.route('/config', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_config():
    """Plugin config: the on-share export root + the client IP allowlist."""
    shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
    cidrs = Setting.query.filter_by(key=ALLOWED_CIDRS_SETTING).first()
    return success_response({
        'shareroot': shareroot.value if shareroot else '',
        'allowedcidrs': cidrs.value if cidrs else '',
    })


@geenforce_bp.route('/config', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.publish')
def put_config():
    payload = request.get_json(silent=True) or {}
    result = {}
    if 'shareroot' in payload:
        shareroot = (payload.get('shareroot') or '').strip()
        Setting.set(SHAREROOT_SETTING, shareroot, valuetype='string',
                    category='geenforce',
                    description='On-share export root for GE-Enforce manifests')
        result['shareroot'] = shareroot
    if 'allowedcidrs' in payload:
        normalized, bad = _normalize_cidrs(payload.get('allowedcidrs'))
        if bad:
            return error_response(
                ErrorCodes.VALIDATION_ERROR,
                'Invalid CIDR(s): ' + ', '.join(bad), http_code=400)
        Setting.set(ALLOWED_CIDRS_SETTING, normalized, valuetype='string',
                    category='geenforce',
                    description='Client IP allowlist (CIDRs) that may reach the '
                                'GE-Enforce client endpoints without a token')
        result['allowedcidrs'] = normalized
    db.session.commit()
    return success_response(result)


@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def export_share_route(scopeid):
    """Write the scope's current published JSON to the configured share root
    (backing up the old file to _meta/history first)."""
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
    shareroot = setting.value if setting else ''
    if not shareroot:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'Configure the share root first', http_code=400)
    try:
        path = service.export_scope_to_share(scope.scopename, scope.phase,
                                             shareroot)
    except (ValueError, OSError) as exc:
        return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
    return success_response({'path': path})


@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def rollback_scope_route(scopeid):
    scope = db.session.get(ManifestScope, scopeid)
    if not scope:
        return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
    versionnumber = (request.get_json(silent=True) or {}).get('versionnumber')
    if versionnumber is None:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'versionnumber is required', http_code=400)
    try:
        service.rollback_scope(scope.scopename, scope.phase, int(versionnumber))
        db.session.commit()
    except ValueError as exc:
        db.session.rollback()
        return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
    return success_response({'versionnumber': int(versionnumber)})


def _current_published_version(scopename, phase):
    scope = ManifestScope.query.filter_by(
        scopename=scopename, phase=phase).first()
    if not scope:
        return None
    published = scope.publishedversions.filter_by(iscurrent=True).first()
    return published.versionnumber if published else None


def _asset_facts(hostnames):
    """What ShopDB knows about each reporting host, resolved in bulk.

    A report row only carries a hostname; everything an operator wants next -
    which machine that PC drives, which measuring tool it is, where it sits,
    whether its config is still being backed up - hangs off the asset behind it.

    Bulk queries, never per row: this table shows the whole fleet, so a lookup
    inside the loop would be one query per PC. Every plugin here is optional
    (ADR-013), hence the ImportError guards - a lean build without computers
    still renders the table, just without these columns.

    Returns {lowercased hostname: {...}}.
    """
    facts = {}
    if not hostnames:
        return facts

    try:
        from plugins.computers.models import Computer
    except ImportError:
        return facts

    from shopdb.api import Asset

    rows = (db.session.query(Computer, Asset)
            .join(Asset, Asset.assetid == Computer.assetid)
            .filter(Computer.hostname.in_(hostnames))
            .all())

    assetidtohost = {}
    for computer, asset in rows:
        key = (computer.hostname or '').lower()
        assetidtohost[asset.assetid] = key
        facts[key] = {
            'computerid': computer.computerid,
            'assetid': asset.assetid,
            'assetnumber': asset.assetnumber,
            'location': (asset.location.locationname if asset.location else None),
            'mapx': asset.mapx,
            'levelid': asset.levelid,
            'mapy': asset.mapy,
            'machinenumber': None, 'machineassetid': None,
            'machinepluginid': None,
            'toolassetnumber': None, 'toolassetid': None,
            'toolpluginid': None,
            'displayrole': None,
            'backupkind': None, 'backuplastseen': None,
            'backuplastseenat': None, 'backupcollectedat': None,
        }

    _attach_controlled_assets(facts, assetidtohost)
    _attach_display_roles(facts, hostnames)
    _attach_backup_state(facts, hostnames)
    return facts


def _attach_controlled_assets(facts, assetidtohost):
    """Machine number / measuring-tool asset # for the asset each PC controls.

    The collector records this as a 'controls' relationship, so read that rather
    than re-deriving it - the same resolution living in two places is what put a
    wrong subtype filter on the map.
    """
    if not assetidtohost:
        return
    try:
        from shopdb.api import Asset, AssetRelationship, RelationshipType
    except ImportError:
        return

    controls = RelationshipType.query.filter_by(
        relationshiptype='controls').first()
    if not controls:
        return

    target = db.aliased(Asset)
    rows = (db.session.query(AssetRelationship.sourceassetid, target)
            .join(target, target.assetid == AssetRelationship.targetassetid)
            .filter(AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
                    AssetRelationship.isactive.is_(True),
                    AssetRelationship.sourceassetid.in_(assetidtohost))
            .all())

    # assetid -> the PLUGIN extension id the detail routes key on. /machines/:id
    # and /measuringtools/:id take machineid / measuringtoolid, NOT the core
    # assetid; linking with the assetid lands on whichever record happens to
    # share that number, which looks plausible and is wrong. Frontend
    # getAssetRoute resolves the same way.
    toolids = {}
    try:
        from plugins.measuringtools.models import MeasuringTool
        toolids = {row.assetid: row.measuringtoolid for row in
                   MeasuringTool.query.with_entities(
                       MeasuringTool.assetid, MeasuringTool.measuringtoolid)}
    except ImportError:
        pass
    machineids = {}
    try:
        from plugins.machines.models import Machine
        machineids = {row.assetid: row.machineid for row in
                      Machine.query.with_entities(Machine.assetid, Machine.machineid)}
    except ImportError:
        pass

    for sourceassetid, asset in rows:
        entry = facts.get(assetidtohost.get(sourceassetid))
        if not entry:
            continue
        if asset.assetid in toolids:
            entry['toolassetnumber'] = asset.assetnumber
            entry['toolassetid'] = asset.assetid
            entry['toolpluginid'] = toolids.get(asset.assetid)
        else:
            entry['machinenumber'] = asset.assetnumber
            entry['machineassetid'] = asset.assetid
            # None when the machines plugin is not installed or the asset has no
            # extension row: the frontend then renders the number without a link
            # rather than a link that goes somewhere wrong.
            entry['machinepluginid'] = machineids.get(asset.assetid)


def _attach_display_roles(facts, hostnames):
    """Dashboard / Lobby / parts kiosk, from the display-role mapping.

    ShopDB's mirror of the display-type.txt each kiosk reads at startup.
    """
    from shopdb.api import DashboardDefault
    lowered = {name.lower() for name in hostnames}
    for default in DashboardDefault.query.filter_by(isactive=True).all():
        host = (default.fqdn or '').split('.')[0].lower()
        if host and host in lowered and facts.get(host):
            facts[host]['displayrole'] = default.displayrole


def _attach_backup_state(facts, hostnames):
    """Whether this PC's config is still being backed up, and of what kind.

    Keyed on lastseenat, NOT the newest revision date. Dedup means an unchanged
    config writes no revision, so a machine stable for six months has a
    six-month-old newest revision and is perfectly healthy; the one to worry
    about is the machine whose backup stopped running. sourcehostname is the PC
    the config was read off, which is exactly the host reporting here.
    """
    try:
        from plugins.backups.models import BackupRevision
    except ImportError:
        return
    rows = (BackupRevision.query
            .filter(BackupRevision.sourcehostname.in_(hostnames))
            .all())
    # Newest confirmed first, ordered HERE rather than in SQL: putting nulls
    # last needs ORDER BY ... NULLS LAST, which SQLite accepts and MySQL
    # rejects outright - so the query passed every test and 500'd on the real
    # database. Row counts here are one per host per kind, so sorting in Python
    # costs nothing.
    rows.sort(key=lambda revision: (revision.lastseenat is not None,
                                    revision.lastseenat or _EPOCH),
              reverse=True)
    for revision in rows:
        key = (revision.sourcehostname or '').lower()
        entry = facts.get(key)
        # First row per host wins.
        if entry and entry['backuplastseen'] is None:
            entry['backupkind'] = revision.backupkind
            entry['backuplastseen'] = (
                revision.lastseenat.isoformat() + 'Z'
                if revision.lastseenat else None)
            # Raw value kept beside the wire string so the verdict compares
            # datetimes rather than re-parsing its own output.
            entry['backuplastseenat'] = revision.lastseenat
            # collectedat is when this configuration was CAPTURED, and it only
            # moves when the config actually changed. Pairing it with lastseenat
            # is what lets the hover say "checked X, and what it verified is
            # still the backup taken at Y" instead of one date that has to mean
            # both and ends up meaning neither.
            entry['backupcollectedat'] = (
                revision.collectedat.isoformat() + 'Z'
                if revision.collectedat else None)


def _backup_ok(lastseenat, cutoff):
    """Tri-state verdict for one PC's backup: True good, False stale, None unknown.

    A BOOLEAN rather than a date, because the question is "is this backed up?"
    and every backup kind answers it the same way. Adding a new kind needs no
    change here - it reports a revision like the others and inherits the badge.

    None means there is nothing to judge: no backup revision for this host at
    all, or the staleness check is disabled. The caller shows no badge rather
    than a green one, since "never seen" must not read as healthy.
    """
    if cutoff is None or lastseenat is None:
        return None
    return lastseenat >= cutoff


def _backup_stale_cutoff():
    """(cutoff datetime, threshold days) for 'this PC's backup has gone quiet'.

    A raw date makes the reader do the arithmetic, and the arithmetic is not
    obvious: the collector only ATTEMPTS every backups_intervalhours (24 by
    default), so a confirmation from yesterday is the healthy steady state, not
    a warning. What answers the question is the verdict.

    The threshold is the backups plugin's own backups_staledays, read through
    its service so there is ONE definition of stale rather than a second one
    drifting here. Returns (None, 0) when the plugin is absent or the check is
    disabled, and callers then show the date alone.
    """
    try:
        from plugins.backups.services import staleness
    except ImportError:
        return None, 0
    try:
        days = int(staleness.staledays())
    except (TypeError, ValueError):
        return None, 0
    if days <= 0:
        return None, 0
    return service._utcnow() - timedelta(days=days), days


def _report_stale_cutoff():
    """(cutoff datetime, threshold minutes) for 'this PC has gone quiet'.

    A report is a record of one cycle, not a heartbeat with an expiry: nothing
    ages it. So a PC that stops reporting keeps the status of its last good
    cycle and reads as healthy while it is unplugged. Comparing receivedat - the
    SERVER's clock, not anything the client asserts - against a threshold is
    what turns silence into a visible state.

    Returns (None, 0) when the check is disabled, so callers skip it entirely.
    """
    from shopdb.api import Setting
    try:
        minutes = int(Setting.get('geenforce_reportstaleminutes', 30) or 0)
    except (TypeError, ValueError):
        minutes = 30
    if minutes <= 0:
        return None, 0
    # Same clock helper that WROTE receivedat (naive UTC), so the two cannot
    # drift into comparing an aware datetime against a naive one.
    return service._utcnow() - timedelta(minutes=minutes), minutes


@geenforce_bp.route('/reports', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_reports():
    """Latest enforcement report per PC (fleet compliance view).

    Each row shows the applied vs latest published version (receivedlatest = the
    PC picked up the update) and the install/skip/fail counts + status.
    Optional filters: hostname, scopename.
    """
    query = ManifestEnforcementReport.query.filter_by(iscurrent=True)
    hostname = request.args.get('hostname')
    scopename = request.args.get('scopename')
    if hostname:
        query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname))
    if scopename:
        query = query.filter_by(scopename=scopename)
    reports = query.order_by(
        ManifestEnforcementReport.receivedat.desc()).all()

    facts = _asset_facts({report.hostname for report in reports
                          if report.hostname})

    stalecutoff, stalafter = _report_stale_cutoff()
    backupcutoff, backupafterdays = _backup_stale_cutoff()
    latest_cache = {}
    data = []
    for report in reports:
        key = (report.scopename, report.phase)
        if key not in latest_cache:
            latest_cache[key] = _current_published_version(*key)
        latest = latest_cache[key]
        known = facts.get((report.hostname or '').lower(), {})
        # Two separate ways to have gone quiet, and the table has a column for
        # each. receivedat is the SERVER's record of the last report it got;
        # lastcheckin is the client's own claim about when it last ran. A PC can
        # fail either way round - silent to the server, or still posting while
        # its own cycle has stopped advancing - so both count.
        #
        # They are checked independently rather than by taking the newer of the
        # two: lastcheckin is client-asserted, so a wrong or future value there
        # must not be able to mask real silence on receivedat.
        isstale = stalecutoff is not None and (
            report.receivedat is None
            or report.receivedat < stalecutoff
            or (report.lastcheckin is not None
                and report.lastcheckin < stalecutoff))
        data.append({
            'reportid': report.reportid,
            'hostname': report.hostname,
            # A host ShopDB has no asset for still shows: the enforcement result
            # is real even when the inventory is behind.
            'computerid': known.get('computerid'),
            'assetnumber': known.get('assetnumber'),
            'location': known.get('location'),
            'mapx': known.get('mapx'),
            'mapy': known.get('mapy'),
            # Coordinates are pixels of ONE level (ADR-017), so the level goes
            # with them or the hover preview has nothing to draw on.
            'levelid': known.get('levelid'),
            'machinenumber': known.get('machinenumber'),
            'machineassetid': known.get('machineassetid'),
            'machinepluginid': known.get('machinepluginid'),
            'toolassetnumber': known.get('toolassetnumber'),
            'toolassetid': known.get('toolassetid'),
            'toolpluginid': known.get('toolpluginid'),
            # Reported by the device wins; the DashboardDefault mapping is a
            # fallback for hosts on an older client that does not send it.
            'displayrole': report.subtype or known.get('displayrole'),
            'backupkind': known.get('backupkind'),
            'backuplastseen': known.get('backuplastseen'),
            # VERIFIED, not "last backup taken". Dedup means an unchanged config
            # writes no new revision, so lastseenat is the last time the
            # collector CHECKED and found the backup intact - which is the
            # question. backupok says whether that check is recent enough to
            # trust; the date alone reads as "nothing has happened since", which
            # is the healthy steady state and looks like neglect.
            'backupok': _backup_ok(known.get('backuplastseenat'), backupcutoff),
            'backupcollectedat': known.get('backupcollectedat'),
            'backupstaleafterdays': backupafterdays,
            'scopename': report.scopename,
            'phase': report.phase,
            'appliedversion': report.appliedversion,
            'latestversion': latest,
            'receivedlatest': (latest is not None
                               and report.appliedversion == latest),
            'enforcerversion': report.enforcerversion,
            # 'status' is what the PC said about its LAST cycle and is left
            # exactly as reported. 'isstale' is the server's own judgement that
            # no cycle has been heard from in too long - a PC switched off after
            # a clean run reports 'ok' forever, so the two are different facts
            # and the table shows both.
            'status': report.status,
            'isstale': isstale,
            'staleafterminutes': stalafter,
            'installed': report.installedcount,
            'skipped': report.skippedcount,
            'failed': report.failedcount,
            'filtered': report.filteredcount,
            'lastcheckin': (report.lastcheckin.isoformat() + 'Z'
                            if report.lastcheckin else None),
            'receivedat': (report.receivedat.isoformat() + 'Z'
                           if report.receivedat else None),
        })
    return success_response(data)


@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_report(reportid):
    """One report with per-entry outcomes (self-heal / failure detail)."""
    report = db.session.get(ManifestEnforcementReport, reportid)
    if not report:
        return error_response(ErrorCodes.NOT_FOUND, 'No such report',
                              http_code=404)
    latest = _current_published_version(report.scopename, report.phase)
    return success_response({
        'reportid': report.reportid,
        'hostname': report.hostname,
        'scopename': report.scopename,
        'phase': report.phase,
        'appliedversion': report.appliedversion,
        'latestversion': latest,
        'receivedlatest': (latest is not None
                           and report.appliedversion == latest),
        'status': report.status,
        'results': [{
            'entryname': r.entryname,
            'action': r.action,
            'selfhealed': r.selfhealed,
            'exitcode': r.exitcode,
            'message': r.message,
        } for r in report.results],
    })


@geenforce_bp.route('/dashboard/failures', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def dashboard_failures():
    """Entries that FAILED on their PC's most recent enforcement cycle.

    The dashboard card behind this answers a question nothing else does: what is
    broken on the floor right now. The reports table has held it all along -
    status, the failing entry, its exit code and the engine's message - and the
    only way to see any of it was to open one PC's report modal, one PC at a
    time. A bay returned 500 to every collector report for a day and a half
    before anyone looked.

    Per ENTRY, not per report: "three PCs failed" is a number, while "Install
    OpenText failed with exit 1603 on WJSF1234" is something a person can act
    on. Only current reports (iscurrent) are considered, so a failure that has
    since been fixed disappears on its own rather than needing dismissing.
    """
    rows = (db.session.query(ManifestEnforcementResult, ManifestEnforcementReport)
            .join(ManifestEnforcementReport,
                  ManifestEnforcementResult.reportid ==
                  ManifestEnforcementReport.reportid)
            .filter(ManifestEnforcementReport.iscurrent.is_(True),
                    ManifestEnforcementResult.action == 'failed')
            .order_by(ManifestEnforcementReport.receivedat.desc())
            .limit(50)
            .all())

    # Resolve hostnames to computerids in ONE query so the card can link to the
    # PC rather than just naming it. A row whose PC is unknown to shopdb still
    # shows - the failure is real even when the inventory is behind.
    hostnames = {report.hostname for _result, report in rows}
    idbyhost = {}
    if hostnames:
        try:
            from plugins.computers.models import Computer
            for computer in Computer.query.filter(
                    Computer.hostname.in_(hostnames)).all():
                idbyhost[(computer.hostname or '').lower()] = computer.computerid
        except ImportError:
            pass

    return success_response([{
        'hostname': report.hostname,
        'computerid': idbyhost.get((report.hostname or '').lower()),
        'scopename': report.scopename,
        'entryname': result.entryname,
        'exitcode': result.exitcode,
        'message': (result.message or '').strip()[:200],
        'receivedat': (report.receivedat.isoformat() + 'Z'
                       if report.receivedat else None),
    } for result, report in rows])
