"""
fraudreport.py
──────────────
Standalone Fraud Report FastAPI application.

This module is intentionally decoupled from the main People Hub app:
it has its own FastAPI instance, its own static files, and its own
data path. Mount it under a different port (default 8001) to run
side-by-side with the main app.

Run:
    uvicorn fraud_report.fraudreport:app --port 8001 --reload

Endpoints:
    GET  /                            → serves the dashboard HTML
    GET  /api/fraud/access-keys       → all available access keys
    GET  /api/fraud/report/{key}      → full fraud report for one key
    GET  /api/fraud/stages            → canonical stage track (static)
    GET  /api/fraud/health            → liveness probe

Design notes:
    • All business logic lives in the `services` section so the routes
      stay thin and the same logic can be reused by future endpoints
      (export-to-PDF, websocket live feed, analytics aggregations…).
    • Exception translation happens in one place (the
      `@app.exception_handler` block) so swapping the data backend
      doesn't drag handlers along.
    • The frontend is served from this same app for convenience —
      production can equally well put a CDN in front of /static/.
"""
from __future__ import annotations

import logging
from collections import Counter, defaultdict
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, HTTPException, Path as FPath
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles

from .data_source import (
    AccessKeyNotFoundError,
    FraudDataCorruptError,
    FraudDataEmptyError,
    FraudDataNotFoundError,
    FraudRepository,
    get_repository,
)
from .models import (
    AccessKeyEntry,
    AccessKeyList,
    FraudEvent,
    FraudEventOut,
    FraudReport,
    ReportSummary,
    StageInfo,
    StageSummary,
)
from .stage_map import (
    canonical_stage_track,
    describe_round,
    round_from_page,
    round_label_for_page,
)


# ── Logging ─────────────────────────────────────────────────────────
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-7s  %(name)s — %(message)s",
)
log = logging.getLogger("fraud_report")


# ── App factory ─────────────────────────────────────────────────────
#
# Using a factory keeps `app` patch-friendly in tests while still
# exposing the conventional `app` symbol that `uvicorn` looks for.
def create_app(repository: Optional[FraudRepository] = None) -> FastAPI:
    application = FastAPI(
        title="People Hub — Fraud Report",
        version="1.0.0",
        description=(
            "Premium dashboard surface for fraud_log.json. Pluggable "
            "storage layer means the same API will work against "
            "SQL / Mongo without any frontend changes."
        ),
    )

    # ── CORS (broad in dev, lock down in prod via env later) ────────
    application.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_credentials=False,
        allow_methods=["GET"],
        allow_headers=["*"],
    )

    # ── Static frontend ─────────────────────────────────────────────
    static_dir = Path(__file__).resolve().parent / "static"
    application.mount(
        "/static",
        StaticFiles(directory=str(static_dir)),
        name="static",
    )

    # The repository is injected so tests / future backends can swap
    # it without touching the routes themselves.
    repo: FraudRepository = repository or get_repository()
    application.state.repo = repo

    _register_exception_handlers(application)
    _register_routes(application)
    return application


# ── Exception → HTTP translation ────────────────────────────────────
#
# Every domain exception thrown by any repository goes through here.
# Routes never see raw OSError / JSONDecodeError.
def _register_exception_handlers(app: FastAPI) -> None:

    @app.exception_handler(FraudDataNotFoundError)
    async def _missing(_, exc: FraudDataNotFoundError):
        log.warning("fraud data missing: %s", exc)
        return JSONResponse(
            status_code=503,
            content={
                "error": "fraud_data_missing",
                "message": str(exc),
                "hint": "Set FRAUD_LOG_PATH or seed data/fraud_log.json.",
            },
        )

    @app.exception_handler(FraudDataCorruptError)
    async def _corrupt(_, exc: FraudDataCorruptError):
        log.error("fraud data corrupt: %s", exc)
        return JSONResponse(
            status_code=500,
            content={"error": "fraud_data_corrupt", "message": str(exc)},
        )

    @app.exception_handler(FraudDataEmptyError)
    async def _empty(_, exc: FraudDataEmptyError):
        log.info("fraud data empty: %s", exc)
        return JSONResponse(
            status_code=200,
            content={
                "error": "fraud_data_empty",
                "message": str(exc),
                "count": 0,
                "items": [],
            },
        )

    @app.exception_handler(AccessKeyNotFoundError)
    async def _no_key(_, exc: AccessKeyNotFoundError):
        return JSONResponse(
            status_code=404,
            content={"error": "access_key_not_found", "message": str(exc)},
        )


# ╭───────────────────────────────────────────────────────────────────╮
# │  SERVICES — pure functions over `list[FraudEvent]`               │
# │  (kept here for one-file portability; split into a `services.py` │
# │  if it ever exceeds ~200 lines).                                 │
# ╰───────────────────────────────────────────────────────────────────╯
_SEVERITY_RANK = {"low": 1, "medium": 2, "high": 3, "critical": 4}


def _infer_severity(ev: FraudEvent) -> str:
    """Derive severity from metadata, action, and event type.

    Order of authority:
      1. Explicit metadata.severity (the new client monitor sets it).
      2. risk_score on disqualification rows (CRITICAL).
      3. Action — disqualify → critical, warn → medium.
      4. Event-type heuristic.
    """
    meta = ev.metadata or {}
    s = (meta.get("severity") or "").lower().strip()
    if s in _SEVERITY_RANK:
        return s

    rscore = (meta.get("risk_score") or "").upper().strip()
    if rscore == "CRITICAL":
        return "critical"

    action = (ev.action or "").lower()
    if action == "disqualify":
        return "critical"
    if action == "warn":
        return "medium"

    et = (ev.event_type or "").upper()
    if et in {"TAB_SWITCH", "STORAGE_TAMPER", "PASTE", "COPY", "CUT"}:
        return "high"
    if et in {"WINDOW_BLUR", "INACTIVITY", "IDLE"}:
        return "low"
    return "low"


def _max_severity(values: list[str]) -> str:
    if not values:
        return "low"
    return max(values, key=lambda v: _SEVERITY_RANK.get(v, 0))


def _enrich(ev: FraudEvent) -> FraudEventOut:
    meta = ev.metadata or {}
    page = meta.get("page")

    # ── Resolve the round id ───────────────────────────────────────
    # Authority order:
    #   1. The event's explicit `round` integer (if it's > 0).
    #   2. The round we can derive from `metadata.page` — gets us a
    #      meaningful stage for legacy rows where round == 0 but the
    #      page tells us it actually happened on, e.g., the role-play
    #      screen.
    #   3. Fall back to 0 (Pre-Session / Login).
    raw_round = int(ev.round or 0)
    if raw_round <= 0:
        from_page = round_from_page(page)
        if from_page is not None:
            raw_round = from_page

    payload = ev.model_dump()
    # Override the round we report so the stage stepper, summary and
    # round-wise table all agree.
    payload["round"] = raw_round

    return FraudEventOut(
        **payload,
        severity=_infer_severity(ev),
        stage=StageInfo(**describe_round(raw_round)),
        detection_source=(meta.get("source") or "client-monitor"),
        session_id=meta.get("session_id"),
        scenario_id=str(meta.get("scenario_id")) if meta.get("scenario_id") is not None else None,
        screenshot_url=meta.get("screenshot_url"),
        page=page,
        user_agent=meta.get("ua_hint"),
    )


def _summarise(access_key: str, events: list[FraudEventOut]) -> ReportSummary:
    by_type: Counter[str]     = Counter(e.event_type for e in events)
    by_sev:  Counter[str]     = Counter(e.severity   for e in events)
    by_round: Counter[str]    = Counter(str(e.round) for e in events)

    sessions: list[str] = sorted({
        e.session_id for e in events if e.session_id
    })

    timestamps = sorted(
        [e.created_at or e.timestamp for e in events if (e.created_at or e.timestamp)]
    )
    first = timestamps[0]  if timestamps else None
    last  = timestamps[-1] if timestamps else None

    disqualified = any(
        (e.action or "").lower() == "disqualify"
        or (e.event_type or "").upper() == "DISQUALIFIED"
        or (e.metadata or {}).get("disqualified") is True
        for e in events
    )

    # Risk score — combines severity histogram + disqualification.
    if disqualified or by_sev.get("critical", 0) > 0:
        risk = "CRITICAL"
    elif by_sev.get("high", 0) >= 3:
        risk = "HIGH"
    elif by_sev.get("high", 0) > 0 or by_sev.get("medium", 0) >= 3:
        risk = "MEDIUM"
    elif events:
        risk = "LOW"
    else:
        risk = "CLEAN"

    fraud_status = (
        "DISQUALIFIED" if disqualified
        else "WARN" if risk in {"HIGH", "MEDIUM", "CRITICAL"}
        else "PASS"
    )
    session_status = "DISQUALIFIED" if disqualified else "ENDED" if events else "ACTIVE"

    candidate_id = next(
        (e.candidate_id for e in events if e.candidate_id),
        None,
    )

    return ReportSummary(
        access_key=access_key,
        candidate_id=candidate_id,
        total_events=len(events),
        by_event_type=dict(by_type),
        by_severity=dict(by_sev),
        by_round=dict(by_round),
        sessions_seen=sessions,
        first_event_at=first,
        last_event_at=last,
        risk_score=risk,
        fraud_status=fraud_status,
        session_status=session_status,
        is_disqualified=disqualified,
    )


def _stage_track(events: list[FraudEventOut]) -> list[StageSummary]:
    """Build the round-by-round stepper used by the UI.

    Returns the canonical track first (Pre-Session → HR) so the stepper
    is always the same shape; any *extra* rounds present in the data
    (Round 7+) are appended in numerical order so unusual flows still
    surface.
    """
    by_round: dict[int, list[FraudEventOut]] = defaultdict(list)
    for e in events:
        by_round[int(e.round or 0)].append(e)

    canonical_ids = [s["round_id"] for s in canonical_stage_track()]
    extra_ids = sorted(rid for rid in by_round.keys() if rid not in canonical_ids)
    ordered_ids = canonical_ids + extra_ids

    out: list[StageSummary] = []
    for rid in ordered_ids:
        bucket = by_round.get(rid, [])
        out.append(StageSummary(
            stage=StageInfo(**describe_round(rid)),
            event_count=len(bucket),
            severity_max=_max_severity([e.severity for e in bucket]) if bucket else "low",
            actions=sorted({(e.action or "none") for e in bucket}),
            flagged=bool(bucket),
            disqualified=any((e.action or "").lower() == "disqualify" for e in bucket),
        ))
    return out


def _build_report(repo: FraudRepository, access_key: str) -> FraudReport:
    """Top-level service: raw events → enriched FraudReport."""
    raw = repo.fetch_events(access_key=access_key)
    if not raw:
        raise AccessKeyNotFoundError(
            f"no fraud events found for access_key={access_key!r}"
        )

    enriched = [_enrich(ev) for ev in raw]
    # Chronological order — UI timeline expects oldest first.
    enriched.sort(key=lambda e: (e.created_at or e.timestamp or ""))

    return FraudReport(
        summary=_summarise(access_key, enriched),
        stage_track=_stage_track(enriched),
        events=enriched,
    )


# ╭───────────────────────────────────────────────────────────────────╮
# │  ROUTES                                                          │
# ╰───────────────────────────────────────────────────────────────────╯
def _register_routes(app: FastAPI) -> None:

    # ── Frontend ─────────────────────────────────────────────────
    @app.get("/", include_in_schema=False)
    async def index():
        static_dir = Path(__file__).resolve().parent / "static"
        return FileResponse(str(static_dir / "fraudreport.html"))

    # ── Health probe ─────────────────────────────────────────────
    @app.get("/api/fraud/health")
    async def health():
        return {"ok": True, "service": "fraud-report", "version": app.version}

    # ── Stage track (static — handy for tooling) ─────────────────
    @app.get("/api/fraud/stages")
    async def stages():
        return {"items": canonical_stage_track()}

    # ── List access keys ─────────────────────────────────────────
    @app.get("/api/fraud/access-keys", response_model=AccessKeyList)
    async def access_keys():
        repo: FraudRepository = app.state.repo
        keys = repo.list_access_keys()
        items: list[AccessKeyEntry] = []
        for k in keys:
            evs = repo.fetch_events(access_key=k)
            if not evs:
                continue
            enriched = [_enrich(e) for e in evs]
            summ = _summarise(k, enriched)
            items.append(AccessKeyEntry(
                access_key=k,
                candidate_id=summ.candidate_id,
                event_count=summ.total_events,
                last_event_at=summ.last_event_at,
                risk_score=summ.risk_score,
            ))
        items.sort(key=lambda e: e.last_event_at or "", reverse=True)
        return AccessKeyList(count=len(items), items=items)

    # ── Per-key report ───────────────────────────────────────────
    @app.get("/api/fraud/report/{access_key}", response_model=FraudReport)
    async def report(
        access_key: str = FPath(
            ...,
            min_length=1,
            max_length=64,
            pattern=r"^[A-Za-z0-9_\-]+$",
            description="Candidate access key (alphanumeric / dash / underscore).",
        ),
    ):
        repo: FraudRepository = app.state.repo
        try:
            return _build_report(repo, access_key)
        except AccessKeyNotFoundError as e:
            # Translated to 404 by the registered exception handler,
            # but explicit re-raise keeps the path visible in logs.
            raise HTTPException(status_code=404, detail=str(e))


# ── Module-level singleton expected by uvicorn ──────────────────────
app = create_app()
