#!/usr/bin/env python3
"""
Donjon - REST API Server
Uses Python stdlib http.server by default; Flask if available.
All endpoints return JSON. Dashboard served at GET /.
"""

import json
import os
import re
import sys
import time
import traceback
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlparse

# ---------------------------------------------------------------------------
# Ensure lib/ is on the Python path
# ---------------------------------------------------------------------------
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_PROJECT_ROOT / 'lib'))
sys.path.insert(0, str(_PROJECT_ROOT))

# ---------------------------------------------------------------------------
# Import lib modules (all have singleton getters)
# ---------------------------------------------------------------------------
try:
    from lib.evidence import get_evidence_manager
except ImportError:
    get_evidence_manager = None

try:
    from lib.asset_inventory import get_asset_inventory
except ImportError:
    get_asset_inventory = None

try:
    from lib.remediation import get_remediation_tracker
except ImportError:
    get_remediation_tracker = None

try:
    from lib.risk_register import get_risk_register
except ImportError:
    get_risk_register = None

try:
    from lib.audit import get_audit_trail
except ImportError:
    get_audit_trail = None

try:
    from lib.exceptions import get_exception_manager
except ImportError:
    get_exception_manager = None

try:
    from lib.export import ExportManager
except ImportError:
    ExportManager = None

try:
    from lib.discovery import get_discovery_engine
except ImportError:
    get_discovery_engine = None

try:
    from lib.executive_report import get_report_generator
except ImportError:
    get_report_generator = None

try:
    from lib.compliance import get_compliance_mapper
except ImportError:
    get_compliance_mapper = None

try:
    from lib.scheduler import get_scheduler
except ImportError:
    get_scheduler = None

try:
    from lib.notifications import get_notification_manager
except ImportError:
    get_notification_manager = None

try:
    from lib.ai_engine import get_ai_engine
except ImportError:
    get_ai_engine = None

try:
    from lib.licensing import get_license_manager
except ImportError:
    get_license_manager = None

try:
    from lib.config import Config
    _config = Config()
except Exception:
    _config = None

from web.auth import get_auth, APIKeyAuth
try:
    from web.dashboard_shell import generate_shell as generate_dashboard_html
except ImportError:
    from web.dashboard import generate_dashboard_html

# ---------------------------------------------------------------------------
# Import API sub-modules (safe imports — app stays up if deps are missing)
# ---------------------------------------------------------------------------
try:
    from web.api_license import handle_license_request
except ImportError:
    handle_license_request = None

try:
    from web.api_settings import handle_settings_request
except ImportError:
    handle_settings_request = None

try:
    from web.api_patch_verify import patch_verify
except ImportError:
    patch_verify = None

try:
    from web.api_rbac import handle_rbac_request
except ImportError:
    handle_rbac_request = None

try:
    from web.api_sso import handle_sso_request
except ImportError:
    handle_sso_request = None

try:
    from web.api_tenants import handle_tenants_request
except ImportError:
    handle_tenants_request = None

try:
    from web.api_audit import handle_audit_request
except ImportError:
    handle_audit_request = None

try:
    from web.api_mssp_clients import handle_mssp_clients
except ImportError:
    handle_mssp_clients = None

try:
    from web.api_mssp_ops import handle_request as handle_mssp_ops_request
except ImportError:
    handle_mssp_ops_request = None

try:
    from web.api_mssp_reporting import handle_request as handle_mssp_reporting_request
except ImportError:
    handle_mssp_reporting_request = None

# Gap-closure v2 API modules
try:
    from web.api_intel import handle as handle_intel_request
except ImportError:
    handle_intel_request = None

try:
    from web.api_compliance_overlap import _build_overlap_matrix as handle_overlap_request
except ImportError:
    handle_overlap_request = None

try:
    from lib.trial_license import handle as handle_trial_request
except ImportError:
    handle_trial_request = None

try:
    from lib.tool_status import get_tool_status as check_scanner_requirements
    check_all_tools = lambda: {"note": "Use /api/v1/tools/<scanner_id> for per-scanner status"}
except ImportError:
    check_all_tools = None
    check_scanner_requirements = None

try:
    from lib.scan_profiles import handle as handle_scan_profile
    ScanProfileManager = True  # flag that module is available
except ImportError:
    handle_scan_profile = None
    ScanProfileManager = None

try:
    from lib.data_retention import run as run_cleanup
    get_data_stats = lambda: {"available": True}
except ImportError:
    run_cleanup = None
    get_data_stats = None

try:
    from lib.usage_reporter import get_usage_reporter
except ImportError:
    get_usage_reporter = None

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _json_serial(obj: Any) -> Any:
    """JSON serialiser for objects not handled by default."""
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, Path):
        return str(obj)
    if isinstance(obj, bytes):
        return obj.decode('utf-8', errors='replace')
    if hasattr(obj, '__dict__'):
        return obj.__dict__
    raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")


def json_response(data: Any, status: int = 200) -> Tuple[bytes, int, str]:
    """Return (body_bytes, status_code, content_type)."""
    body = json.dumps(data, default=_json_serial, indent=2)
    return body.encode('utf-8'), status, 'application/json'


def error_response(message: str, status: int = 400) -> Tuple[bytes, int, str]:
    """Return a JSON error response."""
    return json_response({'error': True, 'message': message}, status)


def html_response(html: str, status: int = 200) -> Tuple[bytes, int, str]:
    """Return an HTML response."""
    return html.encode('utf-8'), status, 'text/html; charset=utf-8'


# ---------------------------------------------------------------------------
# Route registry
# ---------------------------------------------------------------------------

class Route:
    """Represents a single API route with method, pattern, and handler."""

    def __init__(self, method: str, pattern: str, handler: Callable):
        self.method = method.upper()
        self.handler = handler
        # Convert /api/v1/assets/<id> to a regex with named groups
        regex = re.sub(r'<(\w+)>', r'(?P<\1>[^/]+)', pattern)
        self.regex = re.compile(f'^{regex}$')

    def match(self, method: str, path: str) -> Optional[Dict[str, str]]:
        """Return path params dict if this route matches, else None."""
        if method.upper() != self.method:
            return None
        m = self.regex.match(path)
        return m.groupdict() if m else None


# ---------------------------------------------------------------------------
# DonjonAPI  --  core API logic (framework-agnostic)
# ---------------------------------------------------------------------------

class DonjonAPI:
    """
    Core API logic. Registers routes and dispatches requests.
    Independent of transport layer (stdlib or Flask).
    """

    _AUTH_FAIL_WINDOW = 60   # seconds
    _AUTH_FAIL_MAX = 10      # max failures before lockout

    def __init__(self, auth: Optional[APIKeyAuth] = None):
        self.auth = auth or get_auth()
        self.routes: List[Route] = []
        self._start_time = datetime.now(timezone.utc)
        self._agents: Dict[str, Dict] = {}  # agent_id -> last check-in
        self._auth_failures: Dict[str, List[float]] = {}
        self._register_routes()

    # ---- route registration helpers -------------------------------------

    def _get(self, pattern: str, handler: Callable):
        self.routes.append(Route('GET', pattern, handler))

    def _post(self, pattern: str, handler: Callable):
        self.routes.append(Route('POST', pattern, handler))

    def _put(self, pattern: str, handler: Callable):
        self.routes.append(Route('PUT', pattern, handler))

    def _delete(self, pattern: str, handler: Callable):
        self.routes.append(Route('DELETE', pattern, handler))

    # ---- dispatch -------------------------------------------------------

    def dispatch(self, method: str, path: str, query: Dict[str, str],
                 body: Optional[Dict], api_key: Optional[str] = None,
                 source_ip: str = '0.0.0.0',
                 ) -> Tuple[bytes, int, str]:
        """
        Route a request and return (body_bytes, status, content_type).
        """
        # --- Rate limiting on auth failures --------------------------------
        now = time.time()
        if source_ip in self._auth_failures:
            recent = [t for t in self._auth_failures[source_ip]
                      if now - t < self._AUTH_FAIL_WINDOW]
            self._auth_failures[source_ip] = recent
            if len(recent) >= self._AUTH_FAIL_MAX:
                return json_response({
                    'error': 'rate_limited',
                    'message': 'Too many authentication failures. Try again later.',
                }, 429)

        # --- Authentication -----------------------------------------------
        if not self.auth.authenticate(path, api_key):
            self._auth_failures.setdefault(source_ip, []).append(now)
            return json_response(self.auth.get_auth_error_response(), 401)

        # --- Tier enforcement ---------------------------------------------
        tier_block = self._check_tier_limit(method, path, body)
        if tier_block:
            return tier_block

        # --- Dashboard ----------------------------------------------------
        if method == 'GET' and path == '/':
            return html_response(generate_dashboard_html())

        # --- Route matching -----------------------------------------------
        for route in self.routes:
            params = route.match(method, path)
            if params is not None:
                try:
                    return route.handler(params=params, query=query, body=body,
                                         api_key=api_key)
                except Exception as exc:
                    tb = traceback.format_exc()
                    sys.stderr.write(f"API error on {method} {path}: {tb}\n")
                    return error_response("Internal server error", 500)

        return error_response(f"Not found: {method} {path}", 404)

    # ---- tier enforcement helper ----------------------------------------

    def _check_tier_limit(self, method: str, path: str,
                          body: Optional[Dict]) -> Optional[Tuple[bytes, int, str]]:
        """Return an error response if the request violates a tier limit,
        or None if allowed.  Mirrors Burp Suite's approach: core workflow
        is always available, but automation / depth / reporting are gated."""
        if not get_license_manager:
            return None  # module not loaded, no enforcement

        lm = get_license_manager()
        tier = lm.get_tier()

        # --- Defense-in-depth: route-level tier enforcement ------------------
        # This runs BEFORE individual handler checks, providing a second layer.
        # Even if a handler forgets to call require_tier(), this gate blocks.
        _ROUTE_TIER_MAP = {
            '/api/v1/rbac': 'enterprise',
            '/api/v1/sso': 'enterprise',
            '/api/v1/tenants': 'enterprise',
            '/api/v1/audit': 'enterprise',
            '/api/v1/mssp': 'managed',
        }
        from lib.license_guard import TIER_ORDER
        current_idx = TIER_ORDER.index(tier) if tier in TIER_ORDER else 0
        for prefix, required_tier in _ROUTE_TIER_MAP.items():
            if path.startswith(prefix):
                required_idx = TIER_ORDER.index(required_tier)
                if current_idx < required_idx:
                    return json_response({
                        'error': True,
                        'message': f'This feature requires a {required_tier} license',
                        'upgrade_required': required_tier,
                    }, 403)
                break  # matched a prefix, no need to check more

        if tier in ("pro", "enterprise", "managed"):
            return None  # paid tiers: skip community-specific limits below

        # -- Schedules: community cannot create/update schedules -----------
        if path.startswith('/api/v1/schedules') and method in ('POST', 'PUT'):
            return json_response({
                'error': True,
                'message': lm.get_upgrade_message('scheduled_scans'),
                'upgrade_required': 'pro',
                'feature': 'scheduled_scans',
            }, 403)

        # -- Scans: enforce depth and target count -------------------------
        if path == '/api/v1/scans' and method == 'POST' and body:
            metadata = body.get('metadata', {})
            depth = metadata.get('depth', body.get('scan_type', 'standard'))
            if depth == 'deep' and not lm.check_scan_depth('deep'):
                return json_response({
                    'error': True,
                    'message': lm.get_upgrade_message('scan_depths'),
                    'upgrade_required': 'pro',
                    'feature': 'scan_depths',
                }, 403)
            targets = body.get('targets', [])
            if isinstance(targets, str):
                targets = [targets]
            if not lm.check_targets_count(len(targets)):
                return json_response({
                    'error': True,
                    'message': lm.get_upgrade_message('max_targets_per_scan'),
                    'upgrade_required': 'pro',
                    'feature': 'max_targets_per_scan',
                }, 403)

        # -- Notifications: community limited to email + webhook -----------
        if path == '/api/v1/notifications/channels' and method == 'POST' and body:
            chan_type = body.get('channel_type', '')
            if not lm.check_feature_item('notifications', chan_type):
                return json_response({
                    'error': True,
                    'message': lm.get_upgrade_message('notifications'),
                    'upgrade_required': 'pro',
                    'feature': 'notifications',
                }, 403)

        # -- Export: community limited to CSV + JSON -----------------------
        if path == '/api/v1/export' and method == 'POST' and body:
            formats = body.get('formats', [])
            if isinstance(formats, list):
                for fmt in formats:
                    if not lm.check_feature_item('export_formats', fmt):
                        return json_response({
                            'error': True,
                            'message': lm.get_upgrade_message('export_formats'),
                            'upgrade_required': 'pro',
                            'feature': 'export_formats',
                        }, 403)

        # -- AI: daily query quota -----------------------------------------
        if path.startswith('/api/v1/ai/') and method == 'POST':
            # Summarize is a Pro-only feature
            if '/summarize/' in path and not lm.check_limit('ai_scan_summary'):
                return json_response({
                    'error': True,
                    'message': lm.get_upgrade_message('ai_scan_summary'),
                    'upgrade_required': 'pro',
                    'feature': 'ai_scan_summary',
                }, 403)
            # AI analysis is a Pro feature (BYO key / local model — it never
            # touches DonjonSec infrastructure, so there is no per-day quota and
            # no usage metering). Community Edition uses built-in template analysis.
            if path != '/api/v1/ai/status' and not lm.check_limit('ai_analysis'):
                return json_response({
                    'error': True,
                    'message': lm.get_upgrade_message('ai_analysis'),
                    'upgrade_required': 'pro',
                    'feature': 'ai_analysis',
                }, 403)

        return None  # allowed

    # ---- RBAC enforcement helper -------------------------------------------

    def _check_rbac_access(self, api_key: Optional[str],
                           action: str, resource: str,
                           ) -> Optional[Tuple[bytes, int, str]]:
        """Enforce RBAC when running on enterprise tier.

        Returns an error response tuple if access is denied, or None if allowed.

        Rules:
        - Community / Pro tiers: no RBAC enforcement (all authenticated users
          have full access).  This preserves backwards compatibility.
        - Enterprise / Managed tiers with RBAC active: the API key must map to
          a user_id (via auth.register_key_user), and that user must hold a
          role granting *action* on *resource*.
        - Enterprise tier but key has no user mapping: allowed (single-user
          admin deployment — the auto-generated key has implicit full access).
        """
        if not get_license_manager:
            return None  # licensing not loaded, no enforcement

        tier = get_license_manager().get_tier()
        if tier in ('community', 'pro'):
            return None  # RBAC only applies to enterprise+

        # RBAC module available?
        if handle_rbac_request is None:
            return None  # RBAC module not loaded, skip enforcement

        user_id = self.auth.get_user_for_key(api_key)
        if user_id is None:
            # Key has no user mapping — single-user admin, allow access.
            # Enterprise deployments that want scoping must call
            # auth.register_key_user() for each provisioned key.
            return None

        try:
            from lib.rbac import check_permission
            if not check_permission(user_id=user_id, action=action, resource=resource):
                return json_response({
                    'error': 'forbidden',
                    'message': (
                        f'RBAC: user "{user_id}" does not have '
                        f'permission "{action}" on "{resource}".'
                    ),
                }, 403)
        except Exception as exc:
            # RBAC check failed — fail open with a warning rather than
            # blocking the entire API on a database hiccup.
            import logging
            logging.getLogger(__name__).warning(
                "RBAC check failed for user=%s action=%s resource=%s: %s",
                user_id, action, resource, exc,
            )

        return None  # allowed

    # =====================================================================
    # Route handlers
    # =====================================================================

    def _register_routes(self):
        # -- Favicon -------------------------------------------------------
        self._get('/favicon.ico', self._favicon)

        # -- Health / Stats ------------------------------------------------
        self._get('/api/v1/health', self._health)
        self._get('/api/v1/stats', self._stats)

        # -- Assets --------------------------------------------------------
        self._get('/api/v1/assets', self._list_assets)
        self._post('/api/v1/assets', self._create_asset)
        self._get('/api/v1/assets/<id>', self._get_asset)
        self._put('/api/v1/assets/<id>', self._update_asset)

        # -- Scans ---------------------------------------------------------
        self._post('/api/v1/scans', self._start_scan)
        self._get('/api/v1/scans', self._list_scans)
        self._get('/api/v1/scans/<session_id>', self._get_scan)
        self._get('/api/v1/scans/<session_id>/findings', self._scan_findings)

        # -- Findings ------------------------------------------------------
        self._get('/api/v1/findings', self._list_findings)
        self._get('/api/v1/findings/<id>', self._get_finding)

        # -- Remediation ---------------------------------------------------
        self._get('/api/v1/remediation', self._list_remediation)
        self._post('/api/v1/remediation', self._create_remediation)
        self._put('/api/v1/remediation/<id>', self._update_remediation)
        self._get('/api/v1/remediation/metrics', self._remediation_metrics)

        # -- Risk Register -------------------------------------------------
        self._get('/api/v1/risks', self._list_risks)
        self._post('/api/v1/risks', self._create_risk)
        self._get('/api/v1/risks/posture', self._risk_posture)
        self._get('/api/v1/risks/matrix', self._risk_matrix)

        # -- Exceptions ----------------------------------------------------
        self._get('/api/v1/exceptions', self._list_exceptions)
        self._post('/api/v1/exceptions', self._create_exception)
        self._put('/api/v1/exceptions/<id>/approve', self._approve_exception)

        # -- Reports -------------------------------------------------------
        self._get('/api/v1/reports/executive', self._executive_report)
        self._get('/api/v1/reports/compliance/<fw>', self._compliance_report)

        # -- Export --------------------------------------------------------
        self._post('/api/v1/export', self._export_data)

        # -- Agents --------------------------------------------------------
        self._post('/api/v1/agents/checkin', self._agent_checkin)
        self._get('/api/v1/agents', self._list_agents)
        self._post('/api/v1/agents/register', self._register_agent)

        # -- Auth management -----------------------------------------------
        self._post('/api/v1/auth/rotate', self._rotate_key)

        # -- Discovery -----------------------------------------------------
        self._post('/api/v1/discovery/scan', self._discovery_scan)
        self._get('/api/v1/discovery/hosts', self._discovery_hosts)

        # -- Audit ---------------------------------------------------------
        self._get('/api/v1/audit', self._audit_log)

        # -- Schedules -----------------------------------------------------
        self._get('/api/v1/schedules', self._list_schedules)
        self._post('/api/v1/schedules', self._create_schedule)
        self._get('/api/v1/schedules/<id>', self._get_schedule)
        self._put('/api/v1/schedules/<id>', self._update_schedule)
        self._delete('/api/v1/schedules/<id>', self._delete_schedule)
        self._get('/api/v1/schedules/<id>/history', self._schedule_history)

        # -- Notifications -------------------------------------------------
        self._get('/api/v1/notifications/channels', self._list_channels)
        self._post('/api/v1/notifications/channels', self._create_channel)
        self._put('/api/v1/notifications/channels/<id>', self._update_channel)
        self._delete('/api/v1/notifications/channels/<id>', self._delete_channel)
        self._post('/api/v1/notifications/test', self._test_notification)
        self._get('/api/v1/notifications/history', self._notification_history)
        self._get('/api/v1/notifications/stats', self._notification_stats)

        # -- Scanners (metadata) -------------------------------------------
        self._get('/api/v1/scanners', self._list_scanners)

        # -- License / Tier ------------------------------------------------
        self._get('/api/v1/license', self._license_info)
        self._post('/api/v1/license/activate', self._activate_license)

        # -- Network auto-discovery ----------------------------------------
        self._get('/api/v1/network/local', self._network_local_info)

        # -- Export (per-session) ------------------------------------------
        self._get('/api/v1/scans/<session_id>/export', self._export_scan)

        # -- Maintenance / Purge -------------------------------------------
        self._post('/api/v1/maintenance/purge-scans', self._purge_scans)
        self._post('/api/v1/maintenance/purge-notifications', self._purge_notifications)
        self._post('/api/v1/maintenance/purge-audit', self._purge_audit)

        # -- AI Engine -----------------------------------------------------
        self._post('/api/v1/ai/analyze', self._ai_analyze)
        self._post('/api/v1/ai/triage', self._ai_triage)
        self._post('/api/v1/ai/remediate', self._ai_remediate)
        self._post('/api/v1/ai/summarize/<session_id>', self._ai_summarize)
        self._post('/api/v1/ai/query', self._ai_query)
        self._get('/api/v1/ai/status', self._ai_status)
        self._get('/api/v1/ai/config', self._ai_get_config)
        self._post('/api/v1/ai/config', self._ai_save_config)
        self._post('/api/v1/ai/test', self._ai_test_connection)

        # -- License management (api_license) ------------------------------
        if handle_license_request is not None:
            self._get('/api/v1/license/status', self._api_license_status)
            self._post('/api/v1/license/validate', self._api_license_validate)
            self._post('/api/v1/license/activate-key', self._api_license_activate_key)
            self._post('/api/v1/license/deactivate', self._api_license_deactivate)

        # -- Settings (api_settings) ---------------------------------------
        if handle_settings_request is not None:
            self._get('/api/v1/settings/config', self._api_settings_config_get)
            self._put('/api/v1/settings/config', self._api_settings_config_put)
            self._get('/api/v1/settings/paths', self._api_settings_paths_get)
            self._put('/api/v1/settings/paths', self._api_settings_paths_put)
            self._get('/api/v1/settings/license', self._api_settings_license_get)
            self._put('/api/v1/settings/license', self._api_settings_license_put)

        # -- Patch verification (api_patch_verify) -------------------------
        if patch_verify is not None:
            self._post('/api/v1/patch/verify', self._api_patch_verify)

        # -- RBAC (api_rbac) — enterprise tier -----------------------------
        if handle_rbac_request is not None:
            self._get('/api/v1/rbac/roles', self._api_rbac_roles_list)
            self._post('/api/v1/rbac/roles', self._api_rbac_roles_create)
            self._get('/api/v1/rbac/roles/<user_id>', self._api_rbac_roles_user)
            self._post('/api/v1/rbac/assign', self._api_rbac_assign)
            self._post('/api/v1/rbac/check', self._api_rbac_check)

        # -- SSO (api_sso) — enterprise tier -------------------------------
        if handle_sso_request is not None:
            self._get('/api/v1/sso/metadata', self._api_sso_metadata)
            self._post('/api/v1/sso/login', self._api_sso_login)
            self._post('/api/v1/sso/callback', self._api_sso_callback)
            self._post('/api/v1/sso/logout', self._api_sso_logout)

        # -- Tenants (api_tenants) — enterprise tier -----------------------
        if handle_tenants_request is not None:
            self._post('/api/v1/tenants', self._api_tenants_create)
            self._get('/api/v1/tenants/<tenant_id>', self._api_tenants_get)

        # -- Audit trail (api_audit) — enterprise tier ---------------------
        if handle_audit_request is not None:
            self._get('/api/v1/audit/trail', self._api_audit_trail)
            self._post('/api/v1/audit/trail', self._api_audit_trail_post)

        # -- MSSP Clients (api_mssp_clients) — managed tier ----------------
        if handle_mssp_clients is not None:
            self._get('/api/v1/mssp/clients', self._api_mssp_clients_list)
            self._post('/api/v1/mssp/clients', self._api_mssp_clients_create)
            self._get('/api/v1/mssp/clients/<client_id>', self._api_mssp_client_get)
            self._put('/api/v1/mssp/clients/<client_id>', self._api_mssp_client_update)
            self._delete('/api/v1/mssp/clients/<client_id>', self._api_mssp_client_delete)
            self._get('/api/v1/mssp/clients/<client_id>/isolation', self._api_mssp_client_isolation)

        # -- MSSP Ops (api_mssp_ops) — managed tier ------------------------
        if handle_mssp_ops_request is not None:
            self._post('/api/v1/mssp/bulk-scan', self._api_mssp_bulk_scan)
            self._get('/api/v1/mssp/templates', self._api_mssp_templates_list)
            self._post('/api/v1/mssp/templates/create', self._api_mssp_templates_create)
            self._post('/api/v1/mssp/usage', self._api_mssp_usage)
            self._get('/api/v1/mssp/license/check', self._api_mssp_license_check)

        # -- MSSP Reporting (api_mssp_reporting) — managed tier -------------
        if handle_mssp_reporting_request is not None:
            self._post('/api/v1/mssp/clients/dashboard', self._api_mssp_client_dashboard)
            self._post('/api/v1/mssp/reports/rollup', self._api_mssp_reports_rollup)
            self._post('/api/v1/mssp/reports/cross-client', self._api_mssp_reports_cross_client)
            self._get('/api/v1/mssp/license/status', self._api_mssp_license_status)

        # -- Intel Management (gap-closure v2) ---------------------------------
        if handle_intel_request is not None:
            self._get('/api/v1/intel/status', self._api_intel_status)
            self._post('/api/v1/intel/update', self._api_intel_update)

        # -- Compliance Overlap (gap-closure v2) --------------------------------
        if handle_overlap_request is not None:
            self._get('/api/v1/compliance/overlap', self._api_compliance_overlap)

        # -- Trial License (gap-closure v2) ------------------------------------
        if handle_trial_request is not None:
            self._post('/api/v1/license/trial', self._api_trial_activate)
            self._get('/api/v1/license/trial/status', self._api_trial_status)

        # -- Tool Status (gap-closure v2) --------------------------------------
        if check_all_tools is not None:
            self._get('/api/v1/tools', self._api_tools_list)
            self._get('/api/v1/tools/<scanner_id>', self._api_tools_scanner)

        # -- Scan Profiles (gap-closure v2) ------------------------------------
        if ScanProfileManager is not None:
            self._get('/api/v1/profiles', self._api_profiles_list)
            self._post('/api/v1/profiles', self._api_profile_create)
            self._get('/api/v1/profiles/<profile_id>', self._api_profile_get)
            self._delete('/api/v1/profiles/<profile_id>', self._api_profile_delete)

        # -- System Storage (gap-closure v2) -----------------------------------
        if get_data_stats is not None:
            self._get('/api/v1/system/storage', self._api_storage_stats)
            self._post('/api/v1/system/cleanup', self._api_storage_cleanup)

        # -- Legal ---------------------------------------------------------
        self._get('/api/v1/legal/eula', self._legal_eula)

        # -- Usage Telemetry -----------------------------------------------
        if get_usage_reporter is not None:
            self._get('/api/v1/usage', self._api_usage_report)

    # ------------------------------------------------------------------
    # API sub-module adapters
    # ------------------------------------------------------------------

    # -- api_license adapters --

    def _api_license_status(self, **kw) -> Tuple[bytes, int, str]:
        try:
            fn = handle_license_request("/api/license/status", None)
            return json_response(fn())
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_license_validate(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            fn = handle_license_request("/api/license/validate", body)
            return json_response(fn())
        except ValueError as exc:
            return error_response(str(exc), 400)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_license_activate_key(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            fn = handle_license_request("/api/license/activate", body)
            return json_response(fn())
        except ValueError as exc:
            return error_response(str(exc), 400)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_license_deactivate(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            fn = handle_license_request("/api/license/deactivate", body)
            return json_response(fn())
        except ValueError as exc:
            return error_response(str(exc), 400)
        except Exception as exc:
            return error_response(str(exc), 500)

    # -- api_settings adapters --

    def _api_settings_config_get(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_settings_request("/settings/config", None)
            return json_response(result["json_response"]())
        except Exception as exc:
            # If tier-gated, return basic config instead of 500
            if 'tier' in str(exc).lower() or 'license' in str(exc).lower():
                try:
                    cfg = _config.to_dict() if _config else {}
                    cfg['_read_only'] = True
                    cfg['_upgrade_note'] = 'Upgrade to Pro to modify settings'
                    return json_response(cfg)
                except Exception:
                    pass
            return error_response(str(exc), 500)

    def _api_settings_config_put(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_settings_request("/settings/config", body)
            return json_response(result["json_response"]())
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_settings_paths_get(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_settings_request("/settings/paths", None)
            return json_response(result["json_response"]())
        except Exception as exc:
            if 'tier' in str(exc).lower() or 'license' in str(exc).lower():
                try:
                    from lib.paths import paths as _paths
                    return json_response({
                        'data': str(_paths.data),
                        'config': str(_paths.config_active),
                        '_read_only': True,
                        '_upgrade_note': 'Upgrade to Pro to modify paths',
                    })
                except Exception:
                    pass
            return error_response(str(exc), 500)

    def _api_settings_paths_put(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_settings_request("/settings/paths", body)
            return json_response(result["json_response"]())
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_settings_license_get(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_settings_request("/settings/license", None)
            return json_response(result["json_response"]())
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_settings_license_put(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_settings_request("/settings/license", body)
            return json_response(result["json_response"]())
        except Exception as exc:
            return error_response(str(exc), 500)

    # -- api_patch_verify adapter --

    def _api_patch_verify(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        if not body:
            return error_response('Request body required', 400)
        baseline = body.get('baseline_session', '')
        current = body.get('current_session', '')
        try:
            fn = patch_verify(baseline, current)
            return json_response(fn())
        except ValueError as exc:
            return error_response(str(exc), 400)
        except Exception as exc:
            return error_response(str(exc), 500)

    # -- api_rbac adapters --

    def _api_rbac_roles_list(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_rbac_request("/rbac/roles", None)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_rbac_roles_create(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_rbac_request("/rbac/roles", body)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_rbac_roles_user(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_rbac_request(f"/rbac/roles/{params['user_id']}", None)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_rbac_assign(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_rbac_request("/rbac/assign", body)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_rbac_check(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_rbac_request("/rbac/check", body)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    # -- api_sso adapters --

    def _api_sso_metadata(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_sso_request("/api/sso/metadata", None)
            data = result["json_response"]()
            status = data.get("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data.get("body", data), status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_sso_login(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_sso_request("/api/sso/login", body)
            data = result["json_response"]()
            status = data.get("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data.get("body", data), status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_sso_callback(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_sso_request("/api/sso/callback", body)
            data = result["json_response"]()
            status = data.get("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data.get("body", data), status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_sso_logout(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_sso_request("/api/sso/logout", body)
            data = result["json_response"]()
            status = data.get("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data.get("body", data), status)
        except Exception as exc:
            return error_response(str(exc), 500)

    # -- api_tenants adapters --

    def _api_tenants_create(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_tenants_request("/api/tenants", body)
            data = result["json_response"]()
            status = data.get("status", 200) if isinstance(data.get("status"), int) else 200
            body_data = data.get("body", data)
            return json_response(body_data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_tenants_get(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_tenants_request(f"/api/tenants/{params['tenant_id']}", None)
            data = result["json_response"]()
            status = data.get("status", 200) if isinstance(data.get("status"), int) else 200
            body_data = data.get("body", data)
            return json_response(body_data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    # -- api_audit adapters --

    def _api_audit_trail(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_audit_request("/audit/trail", None)
            if "error" in result:
                return error_response(result["error"], result.get("status", 403))
            return json_response(result["json_response"]())
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_audit_trail_post(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_audit_request("/audit/trail", body)
            if "error" in result:
                return error_response(result["error"], result.get("status", 403))
            return json_response(result["json_response"]())
        except Exception as exc:
            return error_response(str(exc), 500)

    # -- api_mssp_clients adapters --

    def _api_mssp_clients_list(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_mssp_clients("/clients", None)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_clients_create(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_mssp_clients("/clients", body)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_client_get(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_mssp_clients(f"/clients/{params['client_id']}", None)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_client_update(self, params: Dict, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_mssp_clients(f"/clients/{params['client_id']}", body)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_client_delete(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        try:
            # Pass a sentinel body to distinguish DELETE from GET
            result = handle_mssp_clients(f"/clients/{params['client_id']}", {"_method": "DELETE"})
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_client_isolation(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_mssp_clients(f"/clients/{params['client_id']}/isolation", None)
            data = result["json_response"]()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data, status)
        except Exception as exc:
            return error_response(str(exc), 500)

    # -- api_mssp_ops adapters --

    def _api_mssp_bulk_scan(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            fn = handle_mssp_ops_request("/mssp/bulk-scan", body)
            data = fn()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data.get("data", data), status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_templates_list(self, **kw) -> Tuple[bytes, int, str]:
        try:
            fn = handle_mssp_ops_request("/mssp/templates", None)
            data = fn()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data.get("data", data), status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_templates_create(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            fn = handle_mssp_ops_request("/mssp/templates/create", body)
            data = fn()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data.get("data", data), status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_usage(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            fn = handle_mssp_ops_request("/mssp/usage", body)
            data = fn()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data.get("data", data), status)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_license_check(self, **kw) -> Tuple[bytes, int, str]:
        try:
            fn = handle_mssp_ops_request("/mssp/license/check", None)
            data = fn()
            status = data.pop("status", 200) if isinstance(data.get("status"), int) else 200
            return json_response(data.get("data", data), status)
        except Exception as exc:
            return error_response(str(exc), 500)

    # -- api_mssp_reporting adapters --

    def _api_mssp_client_dashboard(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_mssp_reporting_request("mssp/clients/dashboard", body)
            fn = result["json_response"]
            return json_response(fn())
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_reports_rollup(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_mssp_reporting_request("mssp/reports/rollup", body)
            fn = result["json_response"]
            return json_response(fn())
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_reports_cross_client(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_mssp_reporting_request("mssp/reports/cross-client", body)
            fn = result["json_response"]
            return json_response(fn())
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_mssp_license_status(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_mssp_reporting_request("mssp/license/status", None)
            fn = result["json_response"]
            return json_response(fn())
        except Exception as exc:
            return error_response(str(exc), 500)

    # ------------------------------------------------------------------
    # Legal
    # ------------------------------------------------------------------

    # ------------------------------------------------------------------
    # Gap-closure v2 adapters
    # ------------------------------------------------------------------

    def _api_intel_status(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_intel_request("GET", "/status")
            fn = result.get("json_response")
            return json_response(fn() if callable(fn) else result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_intel_update(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_intel_request("POST", "/update")
            fn = result.get("json_response")
            return json_response(fn() if callable(fn) else result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_compliance_overlap(self, query: Dict = None, **kw) -> Tuple[bytes, int, str]:
        try:
            frameworks = (query or {}).get("frameworks", "").split(",")
            frameworks = [f.strip() for f in frameworks if f.strip()]
            # Build framework_controls dict for the overlap function
            from lib.compliance import get_compliance_mapper
            mapper = get_compliance_mapper()
            fw_controls = {}
            for fw in frameworks:
                try:
                    controls = mapper.get_all_controls_for_framework(fw)
                    fw_controls[fw] = {c.get("id", "") for c in controls} if controls else set()
                except Exception:
                    fw_controls[fw] = set()
            result = handle_overlap_request(fw_controls)
            return json_response(result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_trial_activate(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_trial_request({"action": "activate"})
            if "error" in result:
                return error_response(result["error"], result.get("status", 400))
            return json_response(result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_trial_status(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_trial_request({"action": "status"})
            return json_response(result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_tools_list(self, **kw) -> Tuple[bytes, int, str]:
        try:
            tools = check_all_tools()
            return json_response({"tools": tools})
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_tools_scanner(self, params: Dict = None, **kw) -> Tuple[bytes, int, str]:
        try:
            scanner_id = (params or {}).get("scanner_id", "")
            result = check_scanner_requirements(scanner_id)
            return json_response(result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_profiles_list(self, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_scan_profile("list", {})
            return json_response(result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_profile_create(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_scan_profile("create", body or {})
            return json_response(result, 201)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_profile_get(self, params: Dict = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_scan_profile("get", {"profile_id": (params or {}).get("profile_id", "")})
            if "error" in result:
                return error_response(result["error"], 404)
            return json_response(result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_profile_delete(self, params: Dict = None, **kw) -> Tuple[bytes, int, str]:
        try:
            result = handle_scan_profile("delete", {"profile_id": (params or {}).get("profile_id", "")})
            return json_response(result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_storage_stats(self, **kw) -> Tuple[bytes, int, str]:
        try:
            stats = get_data_stats()
            return json_response(stats)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_storage_cleanup(self, body: Optional[Dict] = None, **kw) -> Tuple[bytes, int, str]:
        try:
            from lib.license_guard import require_feature, LicenseError
            require_feature("settings")
        except LicenseError:
            return error_response("Cleanup requires Pro license", 403)
        except ImportError:
            pass
        try:
            result = run_cleanup(body or {})
            return json_response(result)
        except Exception as exc:
            return error_response(str(exc), 500)

    def _favicon(self, **kw) -> Tuple[bytes, int, str]:
        """Return a simple SVG favicon."""
        svg = (
            '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
            '<rect width="32" height="32" rx="6" fill="#0891b2"/>'
            '<path d="M8 8h6a8 8 0 010 16H8V8z" stroke="white" stroke-width="2.5" fill="none"/>'
            '<circle cx="22" cy="16" r="3" fill="white"/>'
            '</svg>'
        )
        return svg.encode('utf-8'), 200, 'image/svg+xml'

    def _legal_eula(self, **kw) -> Tuple[bytes, int, str]:
        try:
            from lib.eula import get_eula_text, EULA_VERSION, get_acceptance_record
            record = get_acceptance_record()
            return json_response({
                'eula_version': EULA_VERSION,
                'text': get_eula_text(),
                'accepted': record is not None and record.get('eula_version') == EULA_VERSION,
                'accepted_at': record.get('accepted_at') if record else None,
            })
        except Exception as exc:
            return error_response(str(exc), 500)

    def _api_usage_report(self, **kw) -> Tuple[bytes, int, str]:
        """GET /api/v1/usage - Return local usage telemetry report."""
        try:
            reporter = get_usage_reporter()
            return json_response({"ok": True, "usage": reporter.get_report()})
        except Exception as exc:
            return error_response(str(exc), 500)

    # ------------------------------------------------------------------
    # Health / Stats
    # ------------------------------------------------------------------

    def _health(self, **kw) -> Tuple[bytes, int, str]:
        uptime = (datetime.now(timezone.utc) - self._start_time).total_seconds()
        modules = {
            'evidence': get_evidence_manager is not None,
            'asset_inventory': get_asset_inventory is not None,
            'remediation': get_remediation_tracker is not None,
            'risk_register': get_risk_register is not None,
            'audit': get_audit_trail is not None,
            'exceptions': get_exception_manager is not None,
            'export': ExportManager is not None,
            'discovery': get_discovery_engine is not None,
            'reports': get_report_generator is not None,
            'compliance': get_compliance_mapper is not None,
            'scheduler': get_scheduler is not None,
            'notifications': get_notification_manager is not None,
            'ai_engine': get_ai_engine is not None,
            'licensing': get_license_manager is not None,
        }
        return json_response({
            'status': 'healthy',
            'version': _config.version if _config else '7.5.0',
            'uptime_seconds': round(uptime, 2),
            'timestamp': datetime.now(timezone.utc).isoformat(),
            'modules': modules,
        })

    def _stats(self, **kw) -> Tuple[bytes, int, str]:
        stats: Dict[str, Any] = {}

        def _safe_stat(name, fn):
            """Run a stat collection with timeout protection."""
            try:
                stats[name] = fn()
            except Exception as e:
                stats[name] = {'error': str(e)}

        if get_asset_inventory:
            _safe_stat('assets', lambda: get_asset_inventory().get_statistics())
        if get_evidence_manager:
            def _evidence_stats():
                em = get_evidence_manager()
                sessions = em.get_all_sessions(limit=100)
                findings = em.get_findings_by_severity(status='open')
                by_sev: Dict[str, int] = {}
                for f in findings:
                    s = f.get('severity', 'INFO')
                    by_sev[s] = by_sev.get(s, 0) + 1
                return {
                    'sessions_total': len(sessions),
                    'open_findings': len(findings),
                    'findings_by_severity': by_sev,
                }
            _safe_stat('evidence', _evidence_stats)
            # Flatten evidence stats to top level for backward compat
            if isinstance(stats.get('evidence'), dict) and 'sessions_total' in stats.get('evidence', {}):
                ev = stats.pop('evidence')
                stats['sessions_total'] = ev['sessions_total']
                stats['open_findings'] = ev['open_findings']
                stats['findings_by_severity'] = ev['findings_by_severity']
        if get_remediation_tracker:
            _safe_stat('remediation', lambda: get_remediation_tracker().get_statistics())
        if get_risk_register:
            _safe_stat('risks', lambda: get_risk_register().get_statistics())
        if get_exception_manager:
            _safe_stat('exceptions', lambda: get_exception_manager().get_statistics())
        if get_audit_trail:
            _safe_stat('audit', lambda: get_audit_trail().get_statistics())
        if get_discovery_engine:
            _safe_stat('discovery', lambda: get_discovery_engine().get_statistics())
        stats['agents'] = {
            'connected': len(self._agents),
            'agent_ids': list(self._agents.keys()),
        }
        return json_response(stats)

    # ------------------------------------------------------------------
    # Assets
    # ------------------------------------------------------------------

    def _list_assets(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_asset_inventory:
            return error_response('Asset inventory module not available', 503)
        inv = get_asset_inventory()
        assets = inv.find_assets(
            business_unit=query.get('business_unit'),
            os_type=query.get('os_type'),
            status=query.get('status', 'active'),
        )
        return json_response({'count': len(assets), 'assets': assets})

    def _create_asset(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_asset_inventory:
            return error_response('Asset inventory module not available', 503)
        if not body:
            return error_response('Request body required')
        hostname = body.get('hostname')
        ip_address = body.get('ip_address')
        if not hostname or not ip_address:
            return error_response('hostname and ip_address are required')
        inv = get_asset_inventory()
        asset_id = inv.add_asset(hostname, ip_address, **{
            k: v for k, v in body.items() if k not in ('hostname', 'ip_address')
        })
        if get_audit_trail:
            get_audit_trail().log('asset_created', target_type='asset',
                                 target_id=asset_id,
                                 details={'hostname': hostname, 'ip': ip_address})
        return json_response({'asset_id': asset_id}, 201)

    def _get_asset(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_asset_inventory:
            return error_response('Asset inventory module not available', 503)
        asset = get_asset_inventory().get_asset(params['id'])
        if not asset:
            return error_response('Asset not found', 404)
        return json_response(asset)

    def _update_asset(self, params: Dict, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_asset_inventory:
            return error_response('Asset inventory module not available', 503)
        if not body:
            return error_response('Request body required')
        get_asset_inventory().update_asset(params['id'], **body)
        if get_audit_trail:
            get_audit_trail().log('asset_updated', target_type='asset',
                                 target_id=params['id'],
                                 details=body)
        return json_response({'updated': True, 'asset_id': params['id']})

    # ------------------------------------------------------------------
    # Scans
    # ------------------------------------------------------------------

    def _start_scan(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_evidence_manager:
            return error_response('Evidence module not available', 503)
        if not body:
            return error_response('Request body required')
        scan_type = body.get('scan_type', 'manual')
        targets = body.get('targets', [])
        if isinstance(targets, str):
            targets = [targets]
        em = get_evidence_manager()
        session_id = em.start_session(scan_type, targets, metadata=body.get('metadata'))
        if get_audit_trail:
            get_audit_trail().log('scan_started', target_type='scan',
                                 target_id=session_id,
                                 details={'scan_type': scan_type, 'targets': targets})
        return json_response({'session_id': session_id, 'status': 'running'}, 201)

    def _list_scans(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_evidence_manager:
            return error_response('Evidence module not available', 503)
        limit = min(int(query.get('limit', '50')), 1000)
        sessions = get_evidence_manager().get_all_sessions(limit=limit)
        return json_response({'count': len(sessions), 'sessions': sessions})

    def _get_scan(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_evidence_manager:
            return error_response('Evidence module not available', 503)
        summary = get_evidence_manager().get_session_summary(params['session_id'])
        if not summary:
            return error_response('Session not found', 404)
        return json_response(summary)

    def _scan_findings(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_evidence_manager:
            return error_response('Evidence module not available', 503)
        findings = get_evidence_manager().get_findings_for_session(params['session_id'])
        return json_response({'count': len(findings), 'findings': findings})

    # ------------------------------------------------------------------
    # Findings
    # ------------------------------------------------------------------

    def _list_findings(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        # RBAC enforcement: enterprise tier requires 'read' permission on 'findings'
        rbac_block = self._check_rbac_access(
            kw.get('api_key'), action='read', resource='findings',
        )
        if rbac_block:
            return rbac_block

        if not get_evidence_manager:
            return error_response('Evidence module not available', 503)
        em = get_evidence_manager()
        severity = query.get('severity')
        session_id = query.get('session_id')
        status = query.get('status', 'open')

        if session_id:
            findings = em.get_findings_for_session(session_id)
            if severity:
                findings = [f for f in findings if f.get('severity', '').upper() == severity.upper()]
        else:
            findings = em.get_findings_by_severity(severity=severity, status=status)

        # Optional asset filter
        asset_filter = query.get('asset')
        if asset_filter:
            findings = [f for f in findings
                        if asset_filter.lower() in (f.get('affected_asset', '') or '').lower()]

        return json_response({'count': len(findings), 'findings': findings})

    def _get_finding(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        # RBAC enforcement: enterprise tier requires 'read' permission on 'findings'
        rbac_block = self._check_rbac_access(
            kw.get('api_key'), action='read', resource='findings',
        )
        if rbac_block:
            return rbac_block

        if not get_evidence_manager:
            return error_response('Evidence module not available', 503)
        import sqlite3
        em = get_evidence_manager()
        with sqlite3.connect(str(em.db_path)) as conn:
            conn.row_factory = sqlite3.Row
            row = conn.execute(
                'SELECT * FROM findings WHERE finding_id = ?', (params['id'],)
            ).fetchone()
        if not row:
            return error_response('Finding not found', 404)
        return json_response(dict(row))

    # ------------------------------------------------------------------
    # Remediation
    # ------------------------------------------------------------------

    def _list_remediation(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_remediation_tracker:
            return error_response('Remediation module not available', 503)
        rt = get_remediation_tracker()
        status_filter = query.get('status')
        assigned_to = query.get('assigned_to')
        overdue = query.get('overdue', '').lower() in ('true', '1', 'yes')

        if overdue:
            items = rt.get_overdue_items()
        else:
            items = rt.get_open_items(assigned_to=assigned_to)

        if status_filter:
            items = [i for i in items if i.get('status') == status_filter]

        return json_response({'count': len(items), 'items': items})

    def _create_remediation(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_remediation_tracker:
            return error_response('Remediation module not available', 503)
        if not body:
            return error_response('Request body required')
        finding_id = body.get('finding_id', '')
        title = body.get('title')
        severity = body.get('severity', 'medium')
        if not title:
            return error_response('title is required')
        rt = get_remediation_tracker()
        item_id = rt.create_item(finding_id=finding_id, title=title,
                                 severity=severity, **{
            k: v for k, v in body.items()
            if k not in ('finding_id', 'title', 'severity')
        })
        if get_audit_trail:
            get_audit_trail().log('remediation_created', target_type='remediation',
                                 target_id=item_id, details=body)
        return json_response({'item_id': item_id}, 201)

    def _update_remediation(self, params: Dict, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_remediation_tracker:
            return error_response('Remediation module not available', 503)
        if not body:
            return error_response('Request body required')
        rt = get_remediation_tracker()
        item_id = params['id']

        # Check the item exists
        item = rt.get_item(item_id)
        if not item:
            return error_response('Remediation item not found', 404)

        new_status = body.get('status')
        assigned_to = body.get('assigned_to')
        notes = body.get('notes')
        changed_by = body.get('changed_by', 'api')

        if assigned_to:
            rt.assign(item_id, assigned_to, changed_by=changed_by)
        if new_status:
            rt.update_status(item_id, new_status, notes=notes,
                             changed_by=changed_by)

        if get_audit_trail:
            get_audit_trail().log('remediation_updated', target_type='remediation',
                                 target_id=item_id, details=body)
        updated = rt.get_item(item_id)
        return json_response(updated or {'updated': True})

    def _remediation_metrics(self, **kw) -> Tuple[bytes, int, str]:
        if not get_remediation_tracker:
            return error_response('Remediation module not available', 503)
        rt = get_remediation_tracker()
        return json_response({
            'sla': rt.get_sla_status(),
            'metrics': rt.get_remediation_metrics(),
            'statistics': rt.get_statistics(),
        })

    # ------------------------------------------------------------------
    # Risk Register
    # ------------------------------------------------------------------

    def _list_risks(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_risk_register:
            return error_response('Risk register module not available', 503)
        rr = get_risk_register()
        risks = rr.get_risks(
            category=query.get('category'),
            status=query.get('status'),
            business_unit=query.get('business_unit'),
            min_score=int(query['min_score']) if query.get('min_score') else None,
        )
        return json_response({'count': len(risks), 'risks': risks})

    def _create_risk(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_risk_register:
            return error_response('Risk register module not available', 503)
        if not body:
            return error_response('Request body required')
        title = body.get('title')
        category = body.get('category', 'technical')
        likelihood = int(body.get('likelihood', 3))
        impact = int(body.get('impact', 3))
        if not title:
            return error_response('title is required')
        rr = get_risk_register()
        risk_id = rr.add_risk(title=title, category=category,
                              likelihood=likelihood, impact=impact, **{
            k: v for k, v in body.items()
            if k not in ('title', 'category', 'likelihood', 'impact')
        })
        if get_audit_trail:
            get_audit_trail().log('risk_created', target_type='risk',
                                 target_id=risk_id, details=body)
        return json_response({'risk_id': risk_id}, 201)

    def _risk_posture(self, **kw) -> Tuple[bytes, int, str]:
        if not get_risk_register:
            return error_response('Risk register module not available', 503)
        return json_response(get_risk_register().calculate_risk_posture())

    def _risk_matrix(self, **kw) -> Tuple[bytes, int, str]:
        if not get_risk_register:
            return error_response('Risk register module not available', 503)
        matrix = get_risk_register().get_risk_matrix()
        return json_response({'matrix': matrix})

    # ------------------------------------------------------------------
    # Exceptions
    # ------------------------------------------------------------------

    def _list_exceptions(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_exception_manager:
            return error_response('Exception module not available', 503)
        em = get_exception_manager()
        status_filter = query.get('status')
        if status_filter == 'pending':
            exceptions = em.get_pending_exceptions()
        elif status_filter == 'active':
            exceptions = em.get_active_exceptions()
        elif status_filter == 'expiring':
            exceptions = em.get_expiring_soon(days=30)
        else:
            exceptions = em.get_all_exceptions()
        return json_response({'count': len(exceptions), 'exceptions': exceptions})

    def _create_exception(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_exception_manager:
            return error_response('Exception module not available', 503)
        if not body:
            return error_response('Request body required')
        required = ('title_pattern', 'exception_type', 'justification')
        for field in required:
            if not body.get(field):
                return error_response(f'{field} is required')
        em = get_exception_manager()
        exc_id = em.create_exception(
            finding_type=body.get('finding_type', ''),
            title_pattern=body['title_pattern'],
            exception_type=body['exception_type'],
            justification=body['justification'],
            **{k: v for k, v in body.items() if k not in required and k != 'finding_type'}
        )
        if get_audit_trail:
            get_audit_trail().log('exception_created', target_type='exception',
                                 target_id=exc_id, details=body)
        return json_response({'exception_id': exc_id}, 201)

    def _approve_exception(self, params: Dict, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_exception_manager:
            return error_response('Exception module not available', 503)
        body = body or {}
        approved_by = body.get('approved_by', 'api')
        notes = body.get('notes', '')
        em = get_exception_manager()
        try:
            em.approve_exception(params['id'], approved_by=approved_by, notes=notes)
        except ValueError as e:
            return error_response(str(e), 400)
        return json_response({'approved': True, 'exception_id': params['id']})

    # ------------------------------------------------------------------
    # Reports
    # ------------------------------------------------------------------

    def _executive_report(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_report_generator:
            return error_response('Report module not available', 503)
        days = int(query.get('days', '30'))
        rg = get_report_generator()
        html = rg.generate_executive_summary(days=days)
        return html_response(html)

    def _compliance_report(self, params: Dict, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_report_generator:
            return error_response('Report module not available', 503)
        rg = get_report_generator()
        html = rg.generate_compliance_report(framework=params['fw'])
        return html_response(html)

    # ------------------------------------------------------------------
    # Export
    # ------------------------------------------------------------------

    def _export_data(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not ExportManager:
            return error_response('Export module not available', 503)
        if not body:
            return error_response('Request body required')
        session_id = body.get('session_id')
        formats = body.get('formats')
        if not session_id:
            return error_response('session_id is required')
        exporter = ExportManager()
        try:
            results = exporter.export_all(session_id, formats=formats)
            output = {fmt: str(path) for fmt, path in results.items()}
            if get_audit_trail:
                get_audit_trail().log('export_performed', target_type='export',
                                     details={'session_id': session_id,
                                              'formats': list(output.keys())})
            return json_response({'exported': output})
        except ValueError as e:
            return error_response(str(e), 404)
        except Exception as e:
            return error_response(str(e), 500)

    # ------------------------------------------------------------------
    # Agents
    # ------------------------------------------------------------------

    def _agent_checkin(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not body:
            return error_response('Request body required')
        agent_id = body.get('agent_id')
        if not agent_id:
            return error_response('agent_id is required')

        # Verify per-agent token if the agent has one registered
        if hasattr(self.auth, '_agent_tokens') and agent_id in self.auth._agent_tokens:
            agent_token = body.get('token', '')
            if not self.auth.verify_agent_token(agent_id, agent_token):
                return error_response('Invalid agent token', 403)

        self._agents[agent_id] = {
            'agent_id': agent_id,
            'last_checkin': datetime.now(timezone.utc).isoformat(),
            'hostname': body.get('hostname', ''),
            'ip_address': body.get('ip_address', ''),
            'results': body.get('results', {}),
        }
        # Ingest any findings from the agent
        findings_count = 0
        if get_evidence_manager and body.get('results'):
            em = get_evidence_manager()
            results = body['results']
            session_id = results.get('session_id')
            if session_id and results.get('findings'):
                for f in results['findings']:
                    em.add_finding(
                        session_id=session_id,
                        severity=f.get('severity', 'INFO'),
                        title=f.get('title', 'Agent finding'),
                        description=f.get('description', ''),
                        affected_asset=f.get('affected_asset', body.get('hostname', '')),
                        cvss_score=float(f.get('cvss_score', 0)),
                        cve_ids=f.get('cve_ids', []),
                        remediation=f.get('remediation', ''),
                    )
                    findings_count += 1

        return json_response({
            'acknowledged': True,
            'agent_id': agent_id,
            'findings_ingested': findings_count,
        })

    def _list_agents(self, **kw) -> Tuple[bytes, int, str]:
        agents = list(self._agents.values())
        # Strip raw results from listing for brevity
        for a in agents:
            a.pop('results', None)
        return json_response({'count': len(agents), 'agents': agents})

    def _register_agent(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        """POST /api/v1/agents/register — Admin: register a new agent and return its token."""
        if not body:
            return error_response('Request body required')
        agent_id = body.get('agent_id')
        if not agent_id:
            return error_response('agent_id is required')
        token = self.auth.register_agent_token(agent_id)
        if get_audit_trail:
            get_audit_trail().log('agent_registered', target_type='agent',
                                 target_id=agent_id)
        return json_response({
            'agent_id': agent_id,
            'token': token,
            'message': 'Store this token securely. It will not be shown again.',
        }, 201)

    def _rotate_key(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        """POST /api/v1/auth/rotate — Admin: rotate an API key with grace period."""
        if not body:
            return error_response('Request body required')
        old_key = body.get('api_key')
        grace = int(body.get('grace_seconds', 3600))
        if not old_key:
            return error_response('api_key is required')
        try:
            result = self.auth.rotate_key(old_key, grace_seconds=grace)
        except ValueError as e:
            return error_response(str(e), 400)
        # Cleanup any expired grace-period keys
        cleaned = self.auth.cleanup_expired_keys()
        result['expired_keys_cleaned'] = cleaned
        if get_audit_trail:
            get_audit_trail().log('api_key_rotated', target_type='auth',
                                 details={'grace_seconds': grace})
        return json_response(result)

    # ------------------------------------------------------------------
    # Discovery
    # ------------------------------------------------------------------

    @staticmethod
    def _validate_cidr(cidr: str) -> Optional[str]:
        """Validate CIDR is a private/internal network. Returns error msg or None."""
        if cidr == 'auto':
            return None
        try:
            import ipaddress
            net = ipaddress.ip_network(cidr, strict=False)
            if not net.is_private:
                return f"CIDR {cidr} is not a private network. Only RFC-1918/RFC-4193 ranges allowed."
            # Block link-local and metadata endpoints
            if net.is_link_local or net.overlaps(ipaddress.ip_network('169.254.0.0/16')):
                return f"CIDR {cidr} includes link-local addresses (potential SSRF target)."
        except ValueError as e:
            return f"Invalid CIDR: {e}"
        return None

    def _discovery_scan(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_discovery_engine:
            return error_response('Discovery module not available', 503)
        body = body or {}
        cidr = body.get('cidr', 'auto')
        cidr_err = self._validate_cidr(cidr)
        if cidr_err:
            return error_response(cidr_err, 400)
        methods = body.get('methods', ['arp', 'icmp', 'tcp'])
        # Validate methods against allowlist
        allowed_methods = {'arp', 'icmp', 'tcp', 'udp', 'syn'}
        invalid = set(methods) - allowed_methods
        if invalid:
            return error_response(f"Invalid discovery methods: {invalid}", 400)
        engine = get_discovery_engine()
        hosts = engine.discover_network(cidr=cidr, methods=methods)
        if get_audit_trail:
            get_audit_trail().log('scan_started', actor='api',
                                 target_type='discovery',
                                 details={'cidr': cidr, 'hosts_found': len(hosts)})
        return json_response({
            'cidr': cidr,
            'hosts_found': len(hosts),
            'hosts': hosts,
        })

    def _discovery_hosts(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_discovery_engine:
            return error_response('Discovery module not available', 503)
        engine = get_discovery_engine()
        hosts = engine.get_discovered_hosts(
            status=query.get('status'),
            os_guess=query.get('os'),
        )
        return json_response({'count': len(hosts), 'hosts': hosts})

    # ------------------------------------------------------------------
    # Audit Log
    # ------------------------------------------------------------------

    def _audit_log(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_audit_trail:
            return error_response('Audit module not available', 503)
        try:
            from lib.license_guard import require_feature, LicenseError
            require_feature("audit_trail")
        except LicenseError:
            return error_response('Audit log requires Enterprise license', 403)
        at = get_audit_trail()
        entries = at.get_log(
            limit=min(int(query.get('limit', '100')), 1000),
            action=query.get('action'),
            actor=query.get('actor'),
            since=query.get('since'),
        )
        return json_response({'count': len(entries), 'entries': entries})

    # ------------------------------------------------------------------
    # Schedules
    # ------------------------------------------------------------------

    def _list_schedules(self, **kw) -> Tuple[bytes, int, str]:
        if not get_scheduler:
            return error_response('Scheduler module not available', 503)
        schedules = get_scheduler().get_all_schedules()
        return json_response({'count': len(schedules), 'schedules': schedules})

    def _create_schedule(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_scheduler:
            return error_response('Scheduler module not available', 503)
        if not body:
            return error_response('Request body required')
        name = body.get('name')
        scanner_type = body.get('scanner_type')
        cron_expression = body.get('cron_expression')
        if not name or not scanner_type or not cron_expression:
            return error_response('name, scanner_type, and cron_expression are required')
        try:
            sched = get_scheduler()
            schedule_id = sched.create_schedule(
                name=name,
                scanner_type=scanner_type,
                cron_expression=cron_expression,
                targets=body.get('targets', []),
                scan_type=body.get('scan_type', 'standard'),
                description=body.get('description', ''),
                created_by=body.get('created_by', 'api'),
            )
        except ValueError as e:
            return error_response(str(e), 400)
        if get_audit_trail:
            get_audit_trail().log('schedule_created', target_type='schedule',
                                 target_id=schedule_id, details=body)
        return json_response({'schedule_id': schedule_id}, 201)

    def _get_schedule(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_scheduler:
            return error_response('Scheduler module not available', 503)
        schedule = get_scheduler().get_schedule(params['id'])
        if not schedule:
            return error_response('Schedule not found', 404)
        return json_response(schedule)

    def _update_schedule(self, params: Dict, body: Optional[Dict],
                         **kw) -> Tuple[bytes, int, str]:
        if not get_scheduler:
            return error_response('Scheduler module not available', 503)
        if not body:
            return error_response('Request body required')
        try:
            get_scheduler().update_schedule(params['id'], **body)
        except ValueError as e:
            return error_response(str(e), 400)
        if get_audit_trail:
            get_audit_trail().log('schedule_updated', target_type='schedule',
                                 target_id=params['id'], details=body)
        return json_response({'updated': True, 'schedule_id': params['id']})

    def _delete_schedule(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_scheduler:
            return error_response('Scheduler module not available', 503)
        schedule = get_scheduler().get_schedule(params['id'])
        if not schedule:
            return error_response('Schedule not found', 404)
        get_scheduler().delete_schedule(params['id'])
        if get_audit_trail:
            get_audit_trail().log('schedule_deleted', target_type='schedule',
                                 target_id=params['id'])
        return json_response({'deleted': True, 'schedule_id': params['id']})

    def _schedule_history(self, params: Dict, query: Dict,
                          **kw) -> Tuple[bytes, int, str]:
        if not get_scheduler:
            return error_response('Scheduler module not available', 503)
        limit = min(int(query.get('limit', '20')), 1000)
        runs = get_scheduler().get_run_history(params['id'], limit=limit)
        return json_response({'count': len(runs), 'runs': runs})

    # ------------------------------------------------------------------
    # Notifications
    # ------------------------------------------------------------------

    @staticmethod
    def _is_secret_field(name: str) -> bool:
        """Detect secret fields by pattern rather than explicit allowlist."""
        lower = name.lower()
        return any(s in lower for s in (
            'pass', 'secret', 'token', 'key', 'credential', 'auth', 'private',
        ))

    @staticmethod
    def _mask_config(config: Dict) -> Dict:
        """Mask sensitive config fields for API responses."""
        masked = {}
        for k, v in config.items():
            if DonjonAPI._is_secret_field(k) and isinstance(v, str) and len(v) > 2:
                masked[k] = v[:2] + '********'
            else:
                masked[k] = v
        return masked

    def _list_channels(self, **kw) -> Tuple[bytes, int, str]:
        if not get_notification_manager:
            return error_response('Notification module not available', 503)
        channels = get_notification_manager().get_channels()
        for ch in channels:
            if isinstance(ch.get('config'), dict):
                ch['config'] = self._mask_config(ch['config'])
        return json_response({'count': len(channels), 'channels': channels})

    def _create_channel(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_notification_manager:
            return error_response('Notification module not available', 503)
        if not body:
            return error_response('Request body required')
        name = body.get('name')
        channel_type = body.get('channel_type')
        config = body.get('config', {})
        if not name or not channel_type:
            return error_response('name and channel_type are required')
        try:
            channel_id = get_notification_manager().add_channel(name, channel_type, config)
        except ValueError as e:
            return error_response(str(e), 400)
        if get_audit_trail:
            get_audit_trail().log('channel_created', target_type='notification_channel',
                                 target_id=channel_id, details={'name': name, 'type': channel_type})
        return json_response({'channel_id': channel_id}, 201)

    def _update_channel(self, params: Dict, body: Optional[Dict],
                        **kw) -> Tuple[bytes, int, str]:
        if not get_notification_manager:
            return error_response('Notification module not available', 503)
        if not body:
            return error_response('Request body required')
        try:
            get_notification_manager().update_channel(params['id'], **body)
        except ValueError as e:
            return error_response(str(e), 400)
        return json_response({'updated': True, 'channel_id': params['id']})

    def _delete_channel(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_notification_manager:
            return error_response('Notification module not available', 503)
        get_notification_manager().remove_channel(params['id'])
        if get_audit_trail:
            get_audit_trail().log('channel_deleted', target_type='notification_channel',
                                 target_id=params['id'])
        return json_response({'deleted': True, 'channel_id': params['id']})

    def _test_notification(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_notification_manager:
            return error_response('Notification module not available', 503)
        if not body:
            return error_response('Request body required')
        channel_id = body.get('channel_id')
        if not channel_id:
            return error_response('channel_id is required')
        nm = get_notification_manager()
        channels = nm.get_channels()
        channel = None
        for ch in channels:
            if ch['channel_id'] == channel_id:
                channel = ch
                break
        if not channel:
            return error_response('Channel not found', 404)
        try:
            nm._dispatch(
                channel,
                'Test Notification',
                'This is a test notification from Donjon.',
                'INFO',
            )
            return json_response({'success': True, 'message': 'Test notification sent'})
        except Exception as e:
            return json_response({'success': False, 'message': str(e)}, 200)

    def _notification_history(self, query: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_notification_manager:
            return error_response('Notification module not available', 503)
        nm = get_notification_manager()
        history = nm.get_notification_history(
            limit=min(int(query.get('limit', '50')), 1000),
            event_type=query.get('event_type'),
            status=query.get('status'),
        )
        return json_response({'count': len(history), 'notifications': history})

    def _notification_stats(self, **kw) -> Tuple[bytes, int, str]:
        if not get_notification_manager:
            return error_response('Notification module not available', 503)
        stats = get_notification_manager().get_statistics()
        return json_response(stats)

    # ------------------------------------------------------------------
    # Scanners (metadata)
    # ------------------------------------------------------------------

    def _list_scanners(self, **kw) -> Tuple[bytes, int, str]:
        # Community tier includes these 7 scanners
        _COMMUNITY_SCANNERS = {
            'network', 'vulnerability', 'web', 'ssl',
            'windows', 'linux', 'compliance',
        }

        scanners = [
            {'id': 'network', 'name': 'Network Scanner',
             'description': 'TCP/UDP port scanning, service detection, OS fingerprinting',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'vulnerability', 'name': 'Vulnerability Scanner',
             'description': 'CVE-based vulnerability detection with CVSS scoring',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'web', 'name': 'Web Application Scanner',
             'description': 'OWASP Top 10, XSS, SQLi, directory traversal, header analysis',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'ssl', 'name': 'SSL/TLS Scanner',
             'description': 'Certificate validation, cipher suite analysis, protocol checks',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'windows', 'name': 'Windows Security Scanner',
             'description': 'Windows configuration, GPO, patch level, user/group audit',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'linux', 'name': 'Linux Security Scanner',
             'description': 'Linux hardening checks, SSH config, file permissions, services',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'ad', 'name': 'Active Directory Scanner',
             'description': 'AD security assessment, Kerberos, LDAP, trust relationships',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'cloud', 'name': 'Cloud Security Scanner',
             'description': 'AWS/Azure/GCP misconfiguration, IAM, storage, networking',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'container', 'name': 'Container Scanner',
             'description': 'Docker/K8s security, image vulnerabilities, runtime config',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'sbom', 'name': 'SBOM Scanner',
             'description': 'Software Bill of Materials, dependency analysis, license audit',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'compliance', 'name': 'Compliance Scanner',
             'description': 'Framework compliance checks (NIST, HIPAA, PCI-DSS, SOC2, ISO)',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'credential', 'name': 'Credential Scanner',
             'description': 'Password policy, leaked credential detection, default creds',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'asm', 'name': 'Attack Surface Scanner',
             'description': 'External attack surface mapping, exposed services, DNS enum',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'openvas', 'name': 'OpenVAS Integration',
             'description': 'OpenVAS/GVM vulnerability scanning integration',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'malware', 'name': 'Malware Scanner',
             'description': 'YARA rule matching + ClamAV antivirus for malware detection',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'shadow_ai', 'name': 'Shadow AI Scanner',
             'description': 'Detect unauthorized AI: local LLMs, web AI, browser extensions, API keys, model files',
             'scan_depths': ['quick', 'standard', 'deep']},
            {'id': 'full', 'name': 'Full Scan Suite',
             'description': 'Runs all applicable scanners against targets',
             'scan_depths': ['quick', 'standard', 'deep']},
        ]

        # Determine current tier for availability filtering
        tier = 'community'
        if get_license_manager:
            try:
                tier = get_license_manager().get_tier()
            except Exception:
                pass

        is_paid = tier in ('pro', 'enterprise', 'managed')

        for scanner in scanners:
            in_community = scanner['id'] in _COMMUNITY_SCANNERS
            scanner['available'] = is_paid or in_community
            scanner['required_tier'] = 'community' if in_community else 'pro'

        return json_response({
            'count': len(scanners),
            'scanners': scanners,
            'current_tier': tier,
        })

    # ------------------------------------------------------------------
    # AI Engine
    # ------------------------------------------------------------------

    def _ai_analyze(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_ai_engine:
            return error_response('AI engine not available', 503)
        if not body or not body.get('finding'):
            return error_response('finding object required in body')
        try:
            result = get_ai_engine().analyze_finding(body['finding'])
            return json_response(result)
        except Exception as e:
            return error_response(str(e), 500)

    def _ai_triage(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_ai_engine:
            return error_response('AI engine not available', 503)
        if not body or not body.get('findings'):
            return error_response('findings list required in body')
        try:
            result = get_ai_engine().triage_findings(body['findings'])
            return json_response({'findings': result})
        except Exception as e:
            return error_response(str(e), 500)

    def _ai_remediate(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_ai_engine:
            return error_response('AI engine not available', 503)
        if not body or not body.get('finding'):
            return error_response('finding object required in body')
        try:
            result = get_ai_engine().generate_remediation(body['finding'])
            return json_response({'remediation': result})
        except Exception as e:
            return error_response(str(e), 500)

    def _ai_summarize(self, params: Dict, **kw) -> Tuple[bytes, int, str]:
        if not get_ai_engine:
            return error_response('AI engine not available', 503)
        try:
            result = get_ai_engine().summarize_scan(params['session_id'])
            return json_response({'summary': result})
        except Exception as e:
            return error_response(str(e), 500)

    def _ai_query(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        if not get_ai_engine:
            return error_response('AI engine not available', 503)
        if not body or not body.get('question'):
            return error_response('question is required in body')
        try:
            result = get_ai_engine().query(
                body['question'],
                context=body.get('context'),
            )
            return json_response({'answer': result})
        except Exception as e:
            return error_response(str(e), 500)

    # ------------------------------------------------------------------
    # License / Tier
    # ------------------------------------------------------------------

    def _license_info(self, **kw) -> Tuple[bytes, int, str]:
        if not get_license_manager:
            return json_response({'tier': 'community', 'limits': {}})
        lm = get_license_manager()
        return json_response({
            'tier': lm.get_tier(),
            'label': lm.get_tier_label(),
            'limits': lm.get_limits(),
            'license': lm.get_license_info(),
        })

    # ------------------------------------------------------------------
    # License activation
    # ------------------------------------------------------------------

    def _activate_license(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        """Activate a Pro/Enterprise license by saving the license payload."""
        if not get_license_manager:
            return error_response('License module not available', 503)
        if not body:
            return error_response('Request body required', 400)

        lm = get_license_manager()

        # Accept v2 (dual signatures) or v1 (legacy HMAC) license payload
        if 'tier' in body and ('signatures' in body or 'signature' in body):
            license_data = body
        else:
            return error_response(
                'Invalid license format. Provide a signed license JSON from the Donjon license admin tool.', 400
            )

        # Validate the license using the unified validator
        if not lm.validate_license(license_data):
            return error_response('Invalid or expired license', 400)

        # Auto-bind machine fingerprint on activation.
        # If the license doesn't already carry a fingerprint (floating license),
        # we record the current machine fingerprint in the activation record
        # so subsequent startups can verify the license is on the same machine.
        from lib.licensing import generate_machine_fingerprint
        activation_fingerprint = generate_machine_fingerprint()
        if not license_data.get('machine_fingerprint'):
            license_data['machine_fingerprint'] = activation_fingerprint
        license_data['activation_record'] = {
            'activated_at': datetime.now(timezone.utc).isoformat(),
            'machine_fingerprint': activation_fingerprint,
        }

        # Save to data/license.json
        license_path = lm.license_path
        license_path.parent.mkdir(parents=True, exist_ok=True)
        with open(license_path, 'w', encoding='utf-8') as f:
            json.dump(license_data, f, indent=2)

        # Reload
        lm._load_license()

        if get_audit_trail:
            try:
                get_audit_trail().log('license_activated', 'api',
                                      details=json.dumps({'tier': lm.get_tier()}))
            except Exception:
                pass

        return json_response({
            'activated': True,
            'tier': lm.get_tier(),
            'label': lm.get_tier_label(),
            'organization': license_data.get('organization', ''),
            'expires': license_data.get('expires', ''),
        })

    # ------------------------------------------------------------------
    # Network local info (auto-discovery for quick scan)
    # ------------------------------------------------------------------

    def _network_local_info(self, **kw) -> Tuple[bytes, int, str]:
        """Return local network info for auto-populating scan targets."""
        import socket
        info = {
            'hostname': socket.gethostname(),
            'interfaces': [],
            'suggested_targets': [],
        }

        # Get local IPs
        try:
            # Get all IPs associated with this hostname
            hostname = socket.gethostname()
            addrs = socket.getaddrinfo(hostname, None, socket.AF_INET)
            seen = set()
            for addr in addrs:
                ip = addr[4][0]
                if ip not in seen and not ip.startswith('127.') and not ip.startswith('169.254.'):
                    seen.add(ip)
                    # Derive /24 subnet
                    parts = ip.split('.')
                    subnet = '.'.join(parts[:3]) + '.0/24'
                    # Skip virtual/WSL adapters for suggested targets
                    is_virtual = ip.startswith('172.') and int(parts[1]) in range(16, 32)
                    info['interfaces'].append({
                        'ip': ip,
                        'subnet': subnet,
                    })
                    if not is_virtual:
                        info['suggested_targets'].append(subnet)
        except Exception:
            pass

        # Enrich with psutil network interface data (netmask, interface names)
        try:
            import psutil as _psutil
            import socket as _socket
            for iface, addrs in _psutil.net_if_addrs().items():
                for addr in addrs:
                    if addr.family != _socket.AF_INET:
                        continue
                    ip = addr.address
                    netmask = addr.netmask or ''
                    if not ip or ip.startswith('127.') or ip.startswith('169.254.'):
                        continue
                    if ip in [i['ip'] for i in info['interfaces']]:
                        # Already found via socket — enrich with netmask/iface
                        for entry in info['interfaces']:
                            if entry['ip'] == ip:
                                entry.setdefault('netmask', netmask)
                                entry.setdefault('interface', iface)
                        continue
                    parts = ip.split('.')
                    subnet = '.'.join(parts[:3]) + '.0/24'
                    info['interfaces'].append({
                        'ip': ip,
                        'netmask': netmask,
                        'subnet': subnet,
                        'interface': iface,
                    })
                    if ip not in info['suggested_targets']:
                        info['suggested_targets'].append(ip)
                    if subnet not in info['suggested_targets']:
                        info['suggested_targets'].append(subnet)
        except Exception:
            pass

        # Add known assets from inventory
        if get_asset_inventory:
            try:
                inv = get_asset_inventory()
                assets = inv.find_assets(status='active')
                asset_ips = [a.get('ip_address') for a in assets
                             if a.get('ip_address') and a['ip_address'] not in info['suggested_targets']]
                info['known_assets'] = assets[:50]  # Limit for UI
                info['suggested_targets'].extend(asset_ips[:20])
            except Exception:
                info['known_assets'] = []
        else:
            info['known_assets'] = []

        return json_response(info)

    # ------------------------------------------------------------------
    # Export scan results (per session, CSV or JSON)
    # ------------------------------------------------------------------

    def _export_scan(self, params: Dict, query: Dict, **kw) -> Tuple[bytes, int, str]:
        """Export scan findings as CSV or JSON."""
        if not get_evidence_manager:
            return error_response('Evidence module not available', 503)

        session_id = params.get('session_id', '')
        fmt = query.get('format', 'json')
        em = get_evidence_manager()

        findings = em.get_findings_for_session(session_id)
        if not findings:
            return error_response('No findings for this session', 404)

        if fmt == 'csv':
            # Build CSV
            if not findings:
                return (b'No findings', 200, 'text/plain')
            headers = ['severity', 'title', 'description', 'affected_asset',
                       'cvss_score', 'cve_ids', 'remediation', 'timestamp']
            lines = [','.join(headers)]
            for f in findings:
                row = []
                for h in headers:
                    val = str(f.get(h, '')).replace('"', '""')
                    if ',' in val or '"' in val or '\n' in val:
                        val = '"' + val + '"'
                    row.append(val)
                lines.append(','.join(row))
            csv_bytes = '\n'.join(lines).encode('utf-8')
            return (csv_bytes, 200, 'text/csv')
        else:
            return json_response({
                'session_id': session_id,
                'count': len(findings),
                'findings': findings,
            })

    # ------------------------------------------------------------------
    # Maintenance / Purge
    # ------------------------------------------------------------------

    def _purge_scans(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        """Purge old scan data. Body: {older_than_days: 30}"""
        if not get_evidence_manager:
            return error_response('Evidence module not available', 503)
        days = (body or {}).get('older_than_days', 30)
        em = get_evidence_manager()
        try:
            count = em.cleanup_old_evidence(days)
            if get_audit_trail:
                get_audit_trail().log('purge_scans', 'api',
                                      details=json.dumps({'days': days, 'purged': count}))
            return json_response({'purged': count, 'older_than_days': days})
        except Exception as e:
            return error_response(str(e), 500)

    def _purge_notifications(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        """Purge old notification history."""
        if not get_notification_manager:
            return error_response('Notification module not available', 503)
        days = (body or {}).get('older_than_days', 30)
        nm = get_notification_manager()
        try:
            # Use the DB directly to purge old notifications
            import sqlite3
            db_path = nm.db_path
            cutoff = (datetime.now(timezone.utc) - __import__('datetime').timedelta(days=days)).isoformat()
            with sqlite3.connect(db_path) as conn:
                cursor = conn.execute(
                    'DELETE FROM notification_history WHERE timestamp < ?', (cutoff,)
                )
                count = cursor.rowcount
            if get_audit_trail:
                get_audit_trail().log('purge_notifications', 'api',
                                      details=json.dumps({'days': days, 'purged': count}))
            return json_response({'purged': count, 'older_than_days': days})
        except Exception as e:
            return error_response(str(e), 500)

    def _purge_audit(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        """Purge old audit log entries."""
        if not get_audit_trail:
            return error_response('Audit module not available', 503)
        days = (body or {}).get('older_than_days', 90)
        at = get_audit_trail()
        try:
            count = at.purge_old(days)
            return json_response({'purged': count, 'older_than_days': days})
        except Exception as e:
            return error_response(str(e), 500)

    def _ai_get_config(self, **kw) -> Tuple[bytes, int, str]:
        """Return current AI configuration (with masked keys)."""
        if not get_ai_engine:
            return json_response({'backend': 'none', 'available': False})
        try:
            engine = get_ai_engine()
            config = engine.get_config()
            return json_response(config)
        except Exception as e:
            return error_response(str(e), 500)

    def _ai_save_config(self, body: Optional[Dict], **kw) -> Tuple[bytes, int, str]:
        """Save AI configuration and reconfigure the engine."""
        if not get_ai_engine:
            return error_response('AI engine module not available', 503)
        if not body:
            return error_response('Request body required')
        try:
            engine = get_ai_engine()
            engine.reconfigure(body)
            if get_audit_trail:
                get_audit_trail().log('ai_config_updated', target_type='ai',
                                     details={'backend': body.get('backend', '')})
            return json_response({
                'saved': True,
                'backend': engine.backend,
                'model': engine.model_name,
            })
        except Exception as e:
            return error_response(str(e), 500)

    def _ai_test_connection(self, **kw) -> Tuple[bytes, int, str]:
        """Test the AI backend connection."""
        if not get_ai_engine:
            return error_response('AI engine module not available', 503)
        try:
            engine = get_ai_engine()
            result = engine.test_connection()
            return json_response(result)
        except Exception as e:
            return json_response({
                'success': False,
                'error': str(e),
            })

    def _ai_status(self, **kw) -> Tuple[bytes, int, str]:
        if not get_ai_engine:
            return json_response({
                'available': False,
                'backend': 'none',
                'message': 'AI engine module not installed',
            })
        try:
            engine = get_ai_engine()
            return json_response({
                'available': True,
                'backend': engine.backend,
                'model': getattr(engine, 'model_name', 'unknown'),
                'message': f'AI engine ready ({engine.backend})',
            })
        except Exception as e:
            return json_response({
                'available': False,
                'backend': 'error',
                'message': str(e),
            })


# =========================================================================
# Stdlib HTTP Server (zero-dependency mode)
# =========================================================================

class DonjonHTTPHandler(BaseHTTPRequestHandler):
    """HTTP request handler backed by DonjonAPI."""

    api: DonjonAPI = None  # set by server factory

    def log_message(self, format, *args):
        """Override to use a cleaner log format."""
        sys.stderr.write(
            f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')}] "
            f"{self.address_string()} - {format % args}\n"
        )

    # ---- CORS -----------------------------------------------------------

    def _send_cors_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods',
                         'GET, POST, PUT, DELETE, OPTIONS')
        self.send_header('Access-Control-Allow-Headers',
                         'Content-Type, X-API-Key, Authorization')
        self.send_header('Access-Control-Max-Age', '86400')

    def do_OPTIONS(self):
        """Handle CORS preflight."""
        self.send_response(204)
        self._send_cors_headers()
        self.end_headers()

    # ---- Request parsing ------------------------------------------------

    def _parse_request(self) -> Tuple[str, Dict[str, str], Optional[Dict]]:
        """Parse the URL and body, return (path, query_dict, body_dict)."""
        parsed = urlparse(self.path)
        path = parsed.path.rstrip('/')
        if not path:
            path = '/'

        # Parse query string (take first value for each key)
        qs = parse_qs(parsed.query)
        query = {k: v[0] for k, v in qs.items()}

        # Parse JSON body for POST/PUT
        body = None
        max_request_size = 10 * 1024 * 1024  # 10 MB
        content_length = int(self.headers.get('Content-Length', 0))
        if content_length > max_request_size:
            self.send_response(413)
            self.send_header('Content-Type', 'application/json')
            msg = json.dumps({'error': 'Request entity too large', 'max_bytes': max_request_size}).encode()
            self.send_header('Content-Length', str(len(msg)))
            self.end_headers()
            self.wfile.write(msg)
            return '/', {}, None
        if content_length > 0:
            raw = self.rfile.read(content_length)
            try:
                body = json.loads(raw.decode('utf-8'))
            except (json.JSONDecodeError, UnicodeDecodeError):
                body = {}

        return path, query, body

    def _get_api_key(self) -> Optional[str]:
        """Extract API key from X-API-Key header only.

        Query parameter authentication was removed to prevent key leakage
        in server logs, browser history, and referer headers.
        """
        return self.headers.get('X-API-Key')

    def _handle(self, method: str):
        """Dispatch the request to the API."""
        path, query, body = self._parse_request()
        api_key = self._get_api_key()

        source_ip = self.client_address[0] if self.client_address else '0.0.0.0'
        resp_body, status, content_type = self.api.dispatch(
            method, path, query, body, api_key, source_ip=source_ip
        )

        self.send_response(status)
        self.send_header('Content-Type', content_type)
        self.send_header('Content-Length', str(len(resp_body)))
        self._send_cors_headers()
        self.end_headers()
        self.wfile.write(resp_body)

    def do_GET(self):
        self._handle('GET')

    def do_POST(self):
        self._handle('POST')

    def do_PUT(self):
        self._handle('PUT')

    def do_DELETE(self):
        self._handle('DELETE')


def create_stdlib_server(host: str = '0.0.0.0', port: int = 8443,  # nosec B104 -- intentional bind-all for server
                         auth: Optional[APIKeyAuth] = None) -> HTTPServer:
    """Create an HTTPServer using stdlib only (zero dependencies)."""
    api = DonjonAPI(auth=auth)
    DonjonHTTPHandler.api = api
    server = HTTPServer((host, port), DonjonHTTPHandler)
    return server


# =========================================================================
# Flask mode (production, if Flask is available)
# =========================================================================

def create_flask_app(auth: Optional[APIKeyAuth] = None):
    """Create a Flask application if Flask is installed."""
    try:
        from flask import Flask, request, jsonify, Response
    except ImportError:
        return None

    app = Flask(__name__)
    api = DonjonAPI(auth=auth)

    @app.before_request
    def _cors_preflight():
        if request.method == 'OPTIONS':
            resp = Response('', 204)
            resp.headers['Access-Control-Allow-Origin'] = '*'
            resp.headers['Access-Control-Allow-Methods'] = \
                'GET, POST, PUT, DELETE, OPTIONS'
            resp.headers['Access-Control-Allow-Headers'] = \
                'Content-Type, X-API-Key, Authorization'
            return resp

    @app.after_request
    def _cors_headers(response):
        response.headers['Access-Control-Allow-Origin'] = '*'
        return response

    @app.route('/', defaults={'path': ''})
    @app.route('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
    def catch_all(path):
        full_path = '/' + path if path else '/'
        method = request.method
        query = {k: v for k, v in request.args.items()}
        body = request.get_json(silent=True)
        api_key = request.headers.get('X-API-Key')

        resp_body, status, content_type = api.dispatch(
            method, full_path, query, body, api_key
        )

        return Response(resp_body, status=status,
                        content_type=content_type)

    return app


# =========================================================================
# Server factory (auto-detects Flask)
# =========================================================================

def start_server(host: str = '0.0.0.0', port: int = 8443,  # nosec B104 -- intentional bind-all for server
                 no_auth: bool = False, prefer_flask: bool = True,
                 tls_cert: str = None, tls_key: str = None):
    """
    Start the Donjon web server.

    Tries Flask first (if installed and preferred), falls back to stdlib.
    If tls_cert and tls_key are provided, serves HTTPS.
    """
    import ssl as _ssl

    auth = get_auth(enabled=not no_auth)

    # Build TLS context if certificates are provided
    ssl_ctx = None
    if tls_cert and tls_key:
        if not os.path.exists(tls_cert):
            print(f"[Donjon] ERROR: TLS cert not found: {tls_cert}", file=sys.stderr)
            sys.exit(1)
        if not os.path.exists(tls_key):
            print(f"[Donjon] ERROR: TLS key not found: {tls_key}", file=sys.stderr)
            sys.exit(1)
        ssl_ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
        ssl_ctx.minimum_version = _ssl.TLSVersion.TLSv1_2
        ssl_ctx.load_cert_chain(tls_cert, tls_key)

    scheme = "https" if ssl_ctx else "http"

    flask_app = None
    if prefer_flask:
        flask_app = create_flask_app(auth=auth)

    if flask_app is not None:
        print(f"[Donjon] Starting Flask server on {host}:{port}")
        print(f"[Donjon] Dashboard: {scheme}://{host}:{port}/")
        print(f"[Donjon] API Base:  {scheme}://{host}:{port}/api/v1/")
        print(f"[Donjon] TLS: {'enabled' if ssl_ctx else 'disabled'}")
        print(f"[Donjon] Auth: {'enabled' if auth.enabled else 'DISABLED'}")
        flask_app.run(host=host, port=port, debug=False,
                      ssl_context=ssl_ctx)
    else:
        print(f"[Donjon] Starting stdlib HTTP server on {host}:{port}")
        print(f"[Donjon] (Flask not found - using http.server)")
        print(f"[Donjon] Dashboard: {scheme}://{host}:{port}/")
        print(f"[Donjon] API Base:  {scheme}://{host}:{port}/api/v1/")
        print(f"[Donjon] TLS: {'enabled' if ssl_ctx else 'disabled'}")
        print(f"[Donjon] Auth: {'enabled' if auth.enabled else 'DISABLED'}")
        server = create_stdlib_server(host, port, auth)
        if ssl_ctx:
            server.socket = ssl_ctx.wrap_socket(server.socket, server_side=True)
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            print("\n[Donjon] Server stopped.")
            server.server_close()


# =========================================================================
# CLI entry point
# =========================================================================
if __name__ == '__main__':
    start_server()
