"""X13 orchestrator — classify → fetch → ingest → record.

The single entry point (`fetch_and_ingest`) wires the three tiers to the
**canonical** precedent-ingest pipeline (INV-CF1 — no parallel ingest path)
and keeps the `court_fetch_jobs` row honest at every step (INV-CF2 — a job
always ends in an explicit terminal state, never a silent drop).

Tier routing (from `court_citation.classify`):
  * ``skip``    — ועדת-ערר → never fetched; logged as a missing_precedent gap.
  * ``supreme`` — Tier 0, in-process httpx (`court_fetch_supreme`).
  * ``admin``   — Tier 1, the host-side stealth-browser service over loopback.

Fallback (INV-CF3): after ``MAX_AUTONOMOUS_ATTEMPTS`` autonomous failures the
job flips to ``manual`` and a missing_precedent row is opened so the chair
sees the gap and can solve the CAPTCHA live (VNC) or drop the file manually.

This module runs **in the local MCP server only** — `ingest_precedent` drives
halacha extraction via the local ``claude`` CLI (see `claude_session.py`). It
is invoked from the `court_verdict_fetch` MCP tool, not from the container.
"""

from __future__ import annotations

import logging
import os
import tempfile
from pathlib import Path
from uuid import UUID

import httpx

from legal_mcp.services import court_citation, db
from legal_mcp.services.court_fetch_supreme import (
    SupremeFetchError,
    fetch_supreme_verdict,
)

logger = logging.getLogger(__name__)

# After this many autonomous failures, stop auto-retrying and escalate to a
# human (INV-CF3). Kept low — the .gov site shouldn't be hammered (INV-CF4).
MAX_AUTONOMOUS_ATTEMPTS = int(os.environ.get("COURT_FETCH_MAX_ATTEMPTS", "2"))

# The host-side Tier-1 browser service (pm2). It binds the docker0 bridge
# gateway (10.0.1.1) — same as legal-chat-service — so both the host MCP server
# and containers can reach it; the host reaches 10.0.1.1 as a local interface.
# Override with COURT_FETCH_SERVICE_URL.
COURT_FETCH_SERVICE_URL = os.environ.get(
    "COURT_FETCH_SERVICE_URL", "http://10.0.1.1:8771"
)
_SHARED_SECRET = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
_TIER1_TIMEOUT_S = float(os.environ.get("COURT_FETCH_TIER1_TIMEOUT_S", "300"))

# Provenance level by tier — Supreme rulings are binding; admin-court verdicts
# are administrative (set is_binding conservatively True, chair can downgrade).
_LEVEL_BY_TIER = {"supreme": "עליון", "admin": "מנהלי"}


class _Tier1Unavailable(RuntimeError):
    """The host browser service is not reachable / not configured."""


async def _ingest_bytes(
    *, content: bytes, filename: str, citation: str, tier: str,
    court: str, source_url: str,
) -> dict:
    """Stage bytes to a temp file and run the canonical ingest (INV-CF1)."""
    from legal_mcp.services import precedent_library

    suffix = Path(filename).suffix or ".pdf"
    tmp = tempfile.NamedTemporaryFile(
        prefix="court_fetch_", suffix=suffix, delete=False
    )
    try:
        tmp.write(content)
        tmp.flush()
        tmp.close()
        result = await precedent_library.ingest_precedent(
            file_path=tmp.name,
            citation=citation,
            court=court,
            source_type="court_ruling",          # INV-CF6
            precedent_level=_LEVEL_BY_TIER.get(tier, ""),
            is_binding=True,
        )
        # Stamp provenance on the new case_law row (INV-CF7).
        case_law_id = result.get("case_law_id")
        if case_law_id and source_url:
            try:
                await db.update_case_law(
                    UUID(str(case_law_id)), source_url=source_url
                )
            except Exception:  # provenance is best-effort, never blocks ingest
                logger.warning("could not stamp source_url on %s", case_law_id)
        return result
    finally:
        try:
            os.unlink(tmp.name)
        except OSError:
            pass


async def _fetch_tier1_admin(cit: court_citation.CourtCitation) -> dict:
    """Call the host-side browser service to fetch an admin-court verdict.

    Returns the service's JSON: ``{ok, content_b64, filename, source_url,
    court, reason}``. Raises ``_Tier1Unavailable`` if the service can't be
    reached, ``SupremeFetchError``-style RuntimeError on a fetch failure the
    service reports.
    """
    if not (cit.file_number and cit.month and cit.year):
        raise RuntimeError(
            f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט (תיק-חודש-שנה)"
        )
    headers = {"Authorization": f"Bearer {_SHARED_SECRET}"} if _SHARED_SECRET else {}
    payload = {
        "file_number": cit.file_number,
        "month": cit.month,
        "year": cit.year,
        "case_number": cit.case_number_norm,
        "court": cit.court_prefix,
    }
    try:
        async with httpx.AsyncClient(timeout=_TIER1_TIMEOUT_S) as client:
            resp = await client.post(
                f"{COURT_FETCH_SERVICE_URL}/fetch", json=payload, headers=headers
            )
    except httpx.ConnectError as e:
        raise _Tier1Unavailable(
            f"שירות-האחזור (legal-court-fetch-service) אינו זמין ב-"
            f"{COURT_FETCH_SERVICE_URL}: {e}"
        ) from e
    if resp.status_code != 200:
        raise RuntimeError(f"שירות-האחזור החזיר {resp.status_code}: {resp.text[:200]}")
    return resp.json()


async def fetch_and_ingest(
    citation: str, *, digest_id: UUID | None = None
) -> dict:
    """Classify a citation, fetch the verdict, ingest it, and record the job.

    Idempotent on the canonical case number (INV-CF5): a case already fetched
    (job ``done``) is returned without re-fetching.
    """
    cit = court_citation.classify(citation)

    # ── skip: ועדת-ערר — never auto-fetched (INV-CF6). Surface as a gap. ──
    if cit.tier == "skip":
        await _open_gap(citation, reason="ועדת-ערר — לא ניתן לאחזור ציבורי (נדרש נבו)")
        return {"status": "skipped", "tier": "skip", "citation": citation,
                "reason": "appeals_committee — needs Nevo"}
    if cit.tier == "unknown" or not cit.case_number_norm:
        return {"status": "unrecognized", "citation": citation}

    # ── idempotent job row ──
    job = await db.court_fetch_job_upsert(
        case_number_norm=cit.case_number_norm,
        citation_raw=citation,
        tier=cit.tier,
        court=cit.court_prefix,
        digest_id=digest_id,
    )
    if job.get("status") == "done":
        return {"status": "already_done", "job": job}
    if job.get("status") == "manual":
        return {"status": "awaiting_manual", "job": job}

    job_id = UUID(str(job["id"]))
    await db.court_fetch_job_update(job_id, status="running", bump_attempts=True)

    # ── fetch ──
    # Route by what the number lets us do, not just the court prefix: נט המשפט
    # (Tier 1) serves ALL courts — Supreme included — as long as the citation
    # carries a נט-format triple (file-month-year). Validated live on both
    # district (עת"מ 43830-12-24) and Supreme (בר"מ 72182-06-25). Only a serial-
    # only Supreme number (e.g. עע"מ 5886/24, no month) can't be looked up that
    # way → fall through to Tier 0 (supremedecisions).
    has_net_format = bool(cit.file_number and cit.month and cit.year)
    try:
        if has_net_format:
            res = await _fetch_tier1_admin(cit)
            if not res.get("ok"):
                raise RuntimeError(res.get("reason") or "אחזור נכשל")
            import base64
            content = base64.b64decode(res["content_b64"])
            filename = res.get("filename") or f"{cit.case_number_norm}.pdf"
            source_url = res.get("source_url", "")
            court = res.get("court") or cit.court_prefix
        elif cit.tier == "supreme":
            fetched = await fetch_supreme_verdict(
                citation=citation, case_number_norm=cit.case_number_norm
            )
            content, filename = fetched.content, fetched.filename
            source_url, court = fetched.source_url, fetched.court
        else:
            raise RuntimeError(
                f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט ואינו עליון — "
                "אין מסלול-אחזור ציבורי"
            )
    except Exception as e:  # noqa: BLE001 — any fetch error is recorded, never
        # left hanging in 'running' (INV-CF2). _record_failure escalates to
        # 'manual' after MAX_AUTONOMOUS_ATTEMPTS (INV-CF3).
        return await _record_failure(job_id, cit, citation, str(e))

    # ── ingest into the canonical pipeline (INV-CF1) ──
    try:
        result = await _ingest_bytes(
            content=content, filename=filename, citation=citation,
            tier=cit.tier, court=court, source_url=source_url,
        )
    except Exception as e:  # noqa: BLE001 — recorded, never swallowed (INV-CF2)
        logger.exception("ingest failed for %s", cit.case_number_norm)
        return await _record_failure(job_id, cit, citation, f"קליטה נכשלה: {e}")

    case_law_id = result.get("case_law_id")
    await db.court_fetch_job_update(
        job_id, status="done",
        case_law_id=UUID(str(case_law_id)) if case_law_id else None,
        source_url=source_url, error="",
    )
    # Close the digest gap (INV-DIG3): if this fetch traces back to a digest,
    # link it to the freshly-ingested ruling. Best-effort; never fails the job.
    link_digest_id = digest_id or job.get("digest_id")
    if case_law_id and link_digest_id:
        try:
            await db.link_digest_to_case_law(link_digest_id, UUID(str(case_law_id)))
            logger.info("linked digest %s → case_law %s", link_digest_id, case_law_id)
        except Exception:
            logger.warning("could not relink digest %s after fetch", link_digest_id)

    # Close any open missing-precedent gap this fetch fills (the citation graph
    # often records the same ruling as a gap). Best-effort.
    if case_law_id:
        await _close_matching_gaps(cit.case_number_norm, UUID(str(case_law_id)))

    return {"status": "done", "tier": cit.tier, "case_law_id": case_law_id,
            "citation": citation, "source_url": source_url, "ingest": result}


async def _close_matching_gaps(case_number_norm: str, case_law_id: UUID) -> None:
    """Close open missing_precedents whose citation matches the fetched case."""
    try:
        gaps = await db.list_missing_precedents(status="open", limit=500)
        for g in gaps:
            if court_citation.normalize_case_number(g.get("citation", "")) == case_number_norm:
                await db.close_missing_precedent(
                    UUID(str(g["id"])), linked_case_law_id=case_law_id,
                    status="closed", notes="נקלט אוטומטית דרך אחזור-פסיקה (X13)",
                )
                logger.info("closed missing_precedent %s", g["id"])
    except Exception:
        logger.warning("could not close gaps for %s", case_number_norm)


# Politeness between consecutive court fetches in a drain (INV-CF4) — serial,
# spaced. Mirrors the precedent-extraction queue cadence.
_INTER_FETCH_COOLDOWN_S = float(os.environ.get("COURT_FETCH_DRAIN_COOLDOWN_S", "20"))


async def drain_pending(limit: int = 10) -> dict:
    """Process queued court-fetch jobs (status pending/failed) serially.

    Drains the ``court_fetch_jobs`` queue the digest trigger fills — fetch +
    ingest each, link back to its digest. Serial with a cooldown (INV-CF4); a
    job that fails is recorded and retried next drain until it escalates to
    ``manual`` (INV-CF3). Local-only (runs the ingest pipeline / claude CLI).
    """
    import asyncio

    jobs = await db.court_fetch_job_list(status="pending", limit=limit)
    jobs += await db.court_fetch_job_list(status="failed", limit=limit)
    seen, queue = set(), []
    for j in jobs:
        k = j["case_number_norm"]
        if k not in seen:
            seen.add(k); queue.append(j)
    results = []
    for i, j in enumerate(queue[:limit]):
        if i:
            await asyncio.sleep(_INTER_FETCH_COOLDOWN_S)
        digest_id = j.get("digest_id")
        try:
            r = await fetch_and_ingest(j["citation_raw"], digest_id=digest_id)
        except Exception as e:  # noqa: BLE001 — recorded per-job, never aborts the drain
            logger.exception("drain item failed: %s", j["case_number_norm"])
            r = {"status": "error", "citation": j["citation_raw"], "error": str(e)}
        results.append(r)
    done = sum(1 for r in results if r.get("status") in ("done", "already_done"))
    return {"processed": len(results), "done": done, "results": results}


async def _record_failure(
    job_id: UUID, cit: court_citation.CourtCitation, citation: str, err: str
) -> dict:
    """Record a fetch/ingest failure; escalate to manual after N attempts (INV-CF3)."""
    job = await db.court_fetch_job_get(cit.case_number_norm)
    attempts = (job or {}).get("attempts", 1)
    if attempts >= MAX_AUTONOMOUS_ATTEMPTS:
        await db.court_fetch_job_update(job_id, status="manual", error=err)
        await _open_gap(
            citation,
            reason=f"אחזור אוטונומי נכשל ({attempts} נסיונות) — נדרשת הורדה ידנית. {err}",
        )
        logger.warning("court fetch escalated to manual: %s — %s", citation, err)
        return {"status": "manual", "citation": citation, "error": err,
                "attempts": attempts}
    await db.court_fetch_job_update(job_id, status="failed", error=err)
    logger.warning("court fetch failed (will retry): %s — %s", citation, err)
    return {"status": "failed", "citation": citation, "error": err,
            "attempts": attempts}


async def _open_gap(citation: str, *, reason: str) -> None:
    """Open a missing_precedent gap so the chair sees it (INV-CF2/CF3).

    Best-effort + de-duplicated (designator-aware via citation_norm, #143); a
    failure here is logged, never raised (it must not mask the original outcome).
    """
    try:
        if await db.find_missing_precedent_by_citation(citation):
            return
        await db.create_missing_precedent(
            citation=citation, notes=reason, discovery_source="court_fetch",
        )
    except Exception:
        logger.warning("could not open missing_precedent for %s", citation)
