"""psyc CLI — flat Typer commands, hyphenated names."""

from __future__ import annotations

from typing import List, Optional

import typer
import uvicorn
from dotenv import load_dotenv

from psyc import db, log


load_dotenv()  # per-dev .env (API keys) is loaded into os.environ for venv CLI
from psyc.cockpit import inference
from psyc.lines import classify, courier, lookup, proof, respond, route, scout, seal, train
from psyc.lines import map as map_line
from psyc.models import Outcome
from psyc.result import Err, Ok


app = typer.Typer(add_completion=False, help="psyc — defensive CTI routing & sealing")
log.configure()
_log = log.get(__name__)


@app.command("init")
def init() -> None:
    path = db.init_db()
    typer.echo(f"db ready @ {path}")


@app.command("status")
def status() -> None:
    typer.echo(f"cases: {db.case_count()}")


@app.command("backup")
def backup(out_dir: str = typer.Option("data/backups", "--out", help="directory to write the archive into")) -> None:
    """Tar the audit trail (db + sealed packages + keys + ledger + datasets) to a timestamped archive.

    Excludes the HF model cache, mock-cert receipts, model adapters (re-trainable),
    and the backups dir itself — the goal is the irrecoverable evidence, not bulk artifacts.
    """
    import tarfile
    from datetime import datetime, timezone
    from pathlib import Path

    from psyc import DATA_DIR

    out_path = Path(out_dir)
    out_path.mkdir(parents=True, exist_ok=True)
    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    archive = out_path / f"psyc-data-{ts}.tar.gz"
    excluded = {".hf-cache", "adapters", "mock_cert", out_path.name}
    included: list[str] = []
    with tarfile.open(archive, "w:gz") as tar:
        for entry in DATA_DIR.iterdir():
            if entry.name in excluded:
                continue
            tar.add(entry, arcname=entry.name)
            included.append(entry.name)
    size_mb = archive.stat().st_size / (1024 * 1024)
    typer.echo(f"wrote {archive} ({size_mb:.2f} MB) — included: {', '.join(sorted(included))}")


def _ingest(source: str, limit: int) -> None:
    db.init_db()
    typer.echo(f"fetching {source} (limit={limit})…")
    cases = scout.fetch_and_signal(source, limit=limit)
    for c in cases:
        db.upsert_case(c)
    typer.echo(f"ingested {len(cases)} case(s) from {source}. total now: {db.case_count()}")


@app.command("fetch-urlhaus")
def fetch_urlhaus(limit: int = typer.Option(50, help="max rows to ingest")) -> None:
    _ingest("urlhaus", limit)


@app.command("fetch-cisa-kev")
def fetch_cisa_kev(limit: int = typer.Option(100, help="max vulnerabilities to ingest")) -> None:
    _ingest("cisa-kev", limit)


@app.command("fetch-feodo")
def fetch_feodo(limit: int = typer.Option(50, help="max C2 records to ingest")) -> None:
    _ingest("feodo", limit)


@app.command("fetch-threatfox")
def fetch_threatfox(limit: int = typer.Option(200, help="max IOCs to ingest")) -> None:
    """ThreatFox (abuse.ch) — needs THREATFOX_AUTH_KEY in .env."""
    _ingest("threatfox", limit)


@app.command("fetch-malware-bazaar")
def fetch_malware_bazaar(limit: int = typer.Option(100, help="max samples to ingest")) -> None:
    """MalwareBazaar (abuse.ch) — also uses THREATFOX_AUTH_KEY."""
    _ingest("malware-bazaar", limit)


@app.command("fetch-otx")
def fetch_otx(limit: int = typer.Option(100, help="max pulse-cases to ingest")) -> None:
    """AlienVault OTX — needs OTX_API_KEY in .env."""
    _ingest("otx", limit)


@app.command("fetch-all")
def fetch_all() -> None:
    """Fetch every configured source. Keyed feeds skip cleanly when the key is missing."""
    plan = (("urlhaus", 50), ("cisa-kev", 100), ("feodo", 50),
            ("threatfox", 200), ("malware-bazaar", 100), ("otx", 100))
    for source, limit in plan:
        try:
            _ingest(source, limit)
        except Exception as exc:  # noqa: BLE001 — keep going if one feed misbehaves
            typer.echo(f"  skip {source}: {exc}", err=True)


@app.command("classify-case")
def classify_case(case_id: str) -> None:
    case_result = db.get_case(case_id)
    if isinstance(case_result, Err):
        typer.echo(f"error: {case_result.reason}", err=True)
        raise typer.Exit(1)
    case = classify.classify(case_result.value)
    db.upsert_case(case)
    typer.echo(
        f"classified {case.case_id}: "
        f"{case.classification.incident_type.value if case.classification.incident_type else '—'} / "
        f"{case.classification.severity.value if case.classification.severity else '—'} / "
        f"TLP:{case.classification.tlp.value} / "
        f"class:{case.classification.internal_class.value if case.classification.internal_class else '—'}"
    )


@app.command("classify-all")
def classify_all() -> None:
    cases = db.list_cases(limit=10_000)
    for c in cases:
        classify.classify(c)
        db.upsert_case(c)
    typer.echo(f"classified {len(cases)} case(s).")


@app.command("prove-case")
def prove_case(case_id: str) -> None:
    case_result = db.get_case(case_id)
    if isinstance(case_result, Err):
        typer.echo(f"error: {case_result.reason}", err=True)
        raise typer.Exit(1)
    case = proof.prove(case_result.value)
    db.upsert_case(case)
    c = case.confidence
    typer.echo(f"proved {case.case_id}: confidence {c.level} · reliability {c.source_reliability}{c.information_credibility} · {c.freshness}")


@app.command("prove-all")
def prove_all() -> None:
    cases = db.list_cases(limit=10_000)
    for c in cases:
        proof.prove(c)
        db.upsert_case(c)
    typer.echo(f"proved {len(cases)} case(s).")


@app.command("map-case")
def map_case(case_id: str) -> None:
    case_result = db.get_case(case_id)
    if isinstance(case_result, Err):
        typer.echo(f"error: {case_result.reason}", err=True)
        raise typer.Exit(1)
    kev = map_line.kev_cve_set(db.list_cases(limit=10_000))
    case = map_line.resolve(case_result.value)
    case = map_line.resolve_cves(case, kev)
    db.upsert_case(case)
    line = f"mapped {case.case_id}: country={case.victim.country or '—'} ips={case.observables.ips}"
    if case.source_metadata.get("kev_cves"):
        line += f" · known-exploited CVEs: {case.source_metadata['kev_cves']}"
    typer.echo(line)


@app.command("map-all")
def map_all(limit: int = typer.Option(50, help="max cases to process this run")) -> None:
    cases = db.list_cases(limit=limit)
    kev = map_line.kev_cve_set(db.list_cases(limit=10_000))
    geo = 0
    kev_hits = 0
    for c in cases:
        before = c.victim.country
        c = map_line.resolve(c)
        c = map_line.resolve_cves(c, kev)
        db.upsert_case(c)
        if c.victim.country != before:
            geo += 1
        if c.source_metadata.get("kev_cves"):
            kev_hits += 1
    typer.echo(f"mapped {len(cases)} case(s): {geo} geo-resolved, {kev_hits} with known-exploited CVEs.")


@app.command("seal-keys-gen")
def seal_keys_gen(recipient: str) -> None:
    path = seal.generate_recipient_keys(recipient)
    typer.echo(f"keys generated for {recipient}: {path}")


@app.command("seal-keys-list")
def seal_keys_list() -> None:
    recipients = seal.list_recipients()
    if not recipients:
        typer.echo("(no recipients)")
        return
    for r in recipients:
        typer.echo(r)


@app.command("seal-pack")
def seal_pack(case_id: str, recipient: List[str] = typer.Option(..., "--recipient", "-r", help="recipient name (repeatable)")) -> None:
    case_result = db.get_case(case_id)
    if isinstance(case_result, Err):
        typer.echo(f"error: {case_result.reason}", err=True)
        raise typer.Exit(1)
    case = case_result.value
    plaintext = case.model_dump_json().encode("utf-8")
    metadata = dict(
        case_id=case.case_id,
        tlp=case.classification.tlp.value,
        severity=case.classification.severity.value if case.classification.severity else "",
        incident_type=case.classification.incident_type.value if case.classification.incident_type else "",
    )
    pkg_result = seal.seal(plaintext, recipient, metadata=metadata)
    if isinstance(pkg_result, Err):
        typer.echo(f"error: {pkg_result.reason}", err=True)
        raise typer.Exit(1)
    pkg = pkg_result.value
    case.evidence.sealed_package_id = pkg.package_id
    case.evidence.payload_hash = pkg.plaintext_hash
    db.upsert_case(case)
    typer.echo(f"sealed: {pkg.package_id} → {', '.join(recipient)}")


@app.command("seal-list")
def seal_list() -> None:
    pkgs = seal.list_packages()
    if not pkgs:
        typer.echo("(no sealed packages)")
        return
    for p in pkgs:
        typer.echo(p)


@app.command("seal-show")
def seal_show(package_id: str) -> None:
    result = seal.load_package(package_id)
    if isinstance(result, Err):
        typer.echo(f"error: {result.reason}", err=True)
        raise typer.Exit(1)
    typer.echo(result.value.model_dump_json(indent=2))


@app.command("seal-unseal")
def seal_unseal(package_id: str, recipient: str) -> None:
    result = seal.unseal(package_id, recipient)
    if isinstance(result, Err):
        typer.echo(f"error: {result.reason}", err=True)
        raise typer.Exit(1)
    typer.echo(result.value.decode("utf-8"))


@app.command("route-plan")
def route_plan(case_id: str) -> None:
    case_result = db.get_case(case_id)
    if isinstance(case_result, Err):
        typer.echo(f"error: {case_result.reason}", err=True)
        raise typer.Exit(1)
    case = case_result.value
    routes, blocked = route.plan(case)
    typer.echo(f"case {case_id} → {len(routes)} route(s), {len(blocked)} blocked")
    for r in routes:
        typer.echo(f"  ✓ {r.destination_name} (priority={r.priority}, payload={r.payload_kind}, max_tlp={r.max_tlp_allowed.value})")
    for b in blocked:
        typer.echo(f"  ✗ {b.destination_name}: {b.reason}")


@app.command("submit")
def submit_case(case_id: str) -> None:
    case_result = db.get_case(case_id)
    if isinstance(case_result, Err):
        typer.echo(f"error: {case_result.reason}", err=True)
        raise typer.Exit(1)
    case = case_result.value
    routes, blocked = route.plan(case)
    sealed_pkg = None
    if case.evidence.sealed_package_id:
        pkg_result = seal.load_package(case.evidence.sealed_package_id)
        if isinstance(pkg_result, Ok):
            sealed_pkg = pkg_result.value
    typer.echo(f"submitting {case_id} → {len(routes)} route(s), {len(blocked)} blocked")
    courier.execute_blocked_routes(case, blocked)
    results = courier.execute_routes(case, routes, sealed_pkg=sealed_pkg)
    for r in results:
        marker = "✓" if r.outcome != Outcome.FAILED else "✗"
        rcpt = f" → {r.receipt_id}" if r.receipt_id else ""
        detail = f" ({r.detail})" if r.detail else ""
        typer.echo(f"  {marker} {r.destination_name}: {r.outcome.value}{rcpt}{detail}")
    for b in blocked:
        typer.echo(f"  ⊘ {b.destination_name}: {b.reason} (logged)")


@app.command("queue")
def queue_list(
    status: str = typer.Option("pending", help="pending | approved | rejected | all"),
    limit: int = typer.Option(50, help="max rows"),
) -> None:
    """List the approval queue."""
    from psyc.models import ApprovalStatus
    status_filter = None if status == "all" else ApprovalStatus(status)
    rows = courier.list_pending(status=status_filter, limit=limit)
    if not rows:
        typer.echo(f"(no submissions with status={status})")
        return
    for p in rows:
        rev = f" by {p.reviewer}" if p.reviewer else ""
        typer.echo(
            f"  #{p.id} {p.status.value:9s} {p.destination_name:16s} {p.case_id} "
            f"({p.payload_kind}, tlp={p.tlp.value}){rev}"
        )


@app.command("approve")
def approve(
    pending_id: int = typer.Argument(..., help="pending submission id"),
    reviewer: str = typer.Option("operator", "--by", help="reviewer identity"),
) -> None:
    """Approve a pending submission — dispatches to its destination."""
    result = courier.dispatch_pending(pending_id, reviewer=reviewer)
    if isinstance(result, Err):
        typer.echo(f"error: {result.reason}", err=True)
        raise typer.Exit(1)
    r = result.value
    rcpt = f" → {r.receipt_id}" if r.receipt_id else ""
    typer.echo(f"approved #{pending_id} · {r.destination_name}: {r.outcome.value}{rcpt}")


@app.command("reject")
def reject(
    pending_id: int = typer.Argument(..., help="pending submission id"),
    reviewer: str = typer.Option("operator", "--by", help="reviewer identity"),
    reason: str = typer.Option("", "--reason", help="rejection reason"),
) -> None:
    """Reject a pending submission — nothing leaves; ledger row written."""
    result = courier.reject_pending(pending_id, reviewer=reviewer, reason=reason)
    if isinstance(result, Err):
        typer.echo(f"error: {result.reason}", err=True)
        raise typer.Exit(1)
    typer.echo(f"rejected #{pending_id}{(': ' + reason) if reason else ''}")


@app.command("reindex")
def reindex() -> None:
    """Rebuild the IOC index from all cases."""
    db.init_db()  # ensure the iocs table exists (idempotent)
    cases = db.list_cases(limit=1_000_000)
    n = lookup.reindex(cases)
    typer.echo(f"indexed {n} IOC(s) from {len(cases)} case(s). total: {db.ioc_count()}")


@app.command("lookup")
def lookup_ioc(value: str = typer.Argument(..., help="indicator: IP, domain, URL, hash, or CVE")) -> None:
    """Look up an indicator across the case corpus."""
    rows = lookup.lookup(value)
    if not rows:
        typer.echo(f"'{value}' — not found in the corpus (no known-bad match)")
        return
    typer.echo(f"'{value}' — {len(rows)} match(es):")
    for r in rows:
        sev = r["severity"] or "?"
        typer.echo(f"  [{r['ioc_type']}] {r['case_id']}  feed={r['feed'] or '?'}  severity={sev}  seen={(r['first_seen'] or '')[:10]}")


@app.command("export-blocklist")
def export_blocklist(
    ioc_type: str = typer.Option("ip", "--type", "-t", help=f"one of: {', '.join(lookup.IOC_TYPES)}"),
    min_severity: str = typer.Option("", "--min-severity", help="low | medium | high | critical"),
    out: str = typer.Option("", "--out", help="write to file instead of stdout"),
) -> None:
    """Emit a deduplicated blocklist of indicators (firewall/DNS/SIEM ingestion)."""
    values = lookup.export_blocklist(ioc_type, min_severity or None)
    text = "\n".join(values)
    if out:
        from pathlib import Path as _Path
        _Path(out).write_text(text + "\n", encoding="utf-8")
        typer.echo(f"wrote {len(values)} {ioc_type}(s) → {out}")
    else:
        typer.echo(text)


@app.command("respond")
def respond_propose(case_id: str = typer.Argument(..., help="case to propose response actions for")) -> None:
    """Propose human-gated response actions for a high-severity case."""
    result = db.get_case(case_id)
    if isinstance(result, Err):
        typer.echo(f"error: {result.reason}", err=True)
        raise typer.Exit(1)
    ids = respond.propose_for_case(result.value)
    if not ids:
        typer.echo(f"{case_id}: no actions proposed (not high-severity, or already has actions)")
        return
    typer.echo(f"{case_id}: proposed {len(ids)} action(s) → ids {', '.join(map(str, ids))}")


@app.command("actions")
def actions_list(status: str = typer.Option("proposed", help="proposed | executed | rejected | failed | all")) -> None:
    """List response actions."""
    from psyc.models import ActionStatus
    sf = None if status == "all" else ActionStatus(status)
    rows = respond.list_actions(status=sf)
    if not rows:
        typer.echo(f"(no actions with status={status})")
        return
    for a in rows:
        appr = f" by {a.approver}" if a.approver else ""
        typer.echo(f"  #{a.id} {a.status.value:9s} [{a.action_type.value:9s}] {a.case_id}  sev={a.severity or '?'}{appr}")
        typer.echo(f"        {a.summary}")


@app.command("act-approve")
def act_approve(
    action_id: int = typer.Argument(..., help="response action id"),
    approver: str = typer.Option("operator", "--by", help="approver identity"),
) -> None:
    """Approve + fire a response action (pushes to the enforcement sink)."""
    result = respond.execute_action(action_id, approver=approver)
    if isinstance(result, Err):
        typer.echo(f"error: {result.reason}", err=True)
        raise typer.Exit(1)
    a = result.value
    typer.echo(f"⚡ enforced #{action_id} [{a.action_type.value}] → {a.detail}")


@app.command("act-reject")
def act_reject(
    action_id: int = typer.Argument(..., help="response action id"),
    approver: str = typer.Option("operator", "--by", help="reviewer identity"),
    reason: str = typer.Option("", "--reason", help="why declined"),
) -> None:
    """Decline a proposed response action — nothing fires."""
    result = respond.reject_action(action_id, approver=approver, reason=reason)
    if isinstance(result, Err):
        typer.echo(f"error: {result.reason}", err=True)
        raise typer.Exit(1)
    typer.echo(f"declined #{action_id}{(': ' + reason) if reason else ''}")


@app.command("mock-cert")
def mock_cert_serve(host: str = "127.0.0.1", port: int = 8770) -> None:
    uvicorn.run("psyc.mock_cert:app", host=host, port=port)


@app.command("train-build")
def train_build(
    task: str = typer.Option(..., "--task", "-t", help=f"one of: {', '.join(train.TASKS)}"),
    limit: int = typer.Option(10_000, help="max cases to process"),
    defang_frac: float = typer.Option(0.0, "--defang-frac", help="fraction of ioc_extraction inputs to defang ([0.0, 1.0])"),
    seed: Optional[int] = typer.Option(None, "--seed", help="rng seed for reproducible defanging"),
) -> None:
    if task not in train.TASKS:
        typer.echo(f"unknown task: {task}; choices: {', '.join(train.TASKS)}", err=True)
        raise typer.Exit(1)
    cases = db.list_cases(limit=limit)
    options = train.BuildOptions(defang_frac=defang_frac, seed=seed)
    report = train.build(task, cases, options=options)
    typer.echo(f"task: {report.task}")
    typer.echo(f"path: {report.path}")
    typer.echo(f"  written:           {report.written}")
    typer.echo(f"  skipped (empty):   {report.skipped_empty}")
    typer.echo(f"  skipped (tlp red): {report.skipped_tlp_red}")
    typer.echo(f"  skipped (source):  {report.skipped_restricted_source}")
    typer.echo(f"  skipped (quality): {report.skipped_quality}")


@app.command("train-build-all")
def train_build_all(
    limit: int = typer.Option(10_000, help="max cases per task"),
    defang_frac: float = typer.Option(0.0, "--defang-frac", help="fraction of ioc_extraction inputs to defang ([0.0, 1.0])"),
    seed: Optional[int] = typer.Option(None, "--seed", help="rng seed for reproducible defanging"),
) -> None:
    cases = db.list_cases(limit=limit)
    options = train.BuildOptions(defang_frac=defang_frac, seed=seed)
    for task in train.TASKS:
        report = train.build(task, cases, options=options)
        typer.echo(f"  {task}: wrote {report.written} → {report.path.name}")


@app.command("train-list-datasets")
def train_list_datasets() -> None:
    rows = train.list_datasets()
    if not rows:
        typer.echo("(no datasets)")
        return
    for r in rows:
        typer.echo(f"{r['name']:<40} {r['examples']:>6} examples  {int(r['size_bytes']):>8} B  {r['modified']}")


@app.command("demo")
def demo() -> None:
    db.init_db()
    typer.echo("== psyc demo: scout → classify → seal → route → submit → ledger ==")
    for recipient in ("CERT-Bund", "MISP-Community"):
        if recipient not in seal.list_recipients():
            seal.generate_recipient_keys(recipient)
            typer.echo(f"  + generated demo keys for {recipient}")
    typer.echo("fetching one URLhaus row…")
    cases = scout.fetch_and_signal("urlhaus", limit=1)
    if not cases:
        typer.echo("no cases ingested; URLhaus may be empty or unreachable", err=True)
        raise typer.Exit(1)
    case = cases[0]
    db.upsert_case(case)
    typer.echo(f"  + ingested {case.case_id}")
    case = classify.classify(case)
    db.upsert_case(case)
    typer.echo(
        f"  + classified: {case.classification.incident_type.value if case.classification.incident_type else '—'} / "
        f"{case.classification.severity.value if case.classification.severity else '—'} / "
        f"TLP:{case.classification.tlp.value}"
    )
    case = proof.prove(case)
    db.upsert_case(case)
    typer.echo(f"  + proved: confidence {case.confidence.level} · reliability {case.confidence.source_reliability}{case.confidence.information_credibility} · {case.confidence.freshness}")
    kev = map_line.kev_cve_set(db.list_cases(limit=10_000))
    case = map_line.resolve(case)
    case = map_line.resolve_cves(case, kev)
    db.upsert_case(case)
    kev_note = f" · KEV CVEs: {case.source_metadata['kev_cves']}" if case.source_metadata.get("kev_cves") else ""
    typer.echo(f"  + mapped: hosting country = {case.victim.country or '—'}{kev_note}")
    plaintext = case.model_dump_json().encode("utf-8")
    metadata = dict(
        case_id=case.case_id,
        tlp=case.classification.tlp.value,
        severity=case.classification.severity.value if case.classification.severity else "",
        incident_type=case.classification.incident_type.value if case.classification.incident_type else "",
    )
    pkg_result = seal.seal(plaintext, ["CERT-Bund", "MISP-Community"], metadata=metadata)
    if isinstance(pkg_result, Err):
        typer.echo(f"seal failed: {pkg_result.reason}", err=True)
        raise typer.Exit(1)
    pkg = pkg_result.value
    case.evidence.sealed_package_id = pkg.package_id
    case.evidence.payload_hash = pkg.plaintext_hash
    db.upsert_case(case)
    typer.echo(f"  + sealed: {pkg.package_id}")
    routes, blocked = route.plan(case)
    courier.execute_blocked_routes(case, blocked)
    results = courier.execute_routes(case, routes, sealed_pkg=pkg)
    typer.echo(f"  + routed: {len(routes)} allowed, {len(blocked)} blocked")
    for r in results:
        marker = "✓" if r.outcome != Outcome.FAILED else "✗"
        rcpt = f" → {r.receipt_id}" if r.receipt_id else ""
        detail = f" ({r.detail})" if r.detail else ""
        typer.echo(f"      {marker} {r.destination_name}: {r.outcome.value}{rcpt}{detail}")
    for b in blocked:
        typer.echo(f"      ⊘ {b.destination_name}: {b.reason}")
    typer.echo("")
    typer.echo("── see it in the cockpit ──")
    typer.echo(f"  Worker Mesh:  http://127.0.0.1:8767/cases/{case.case_id}/journey")
    typer.echo(f"  Case detail:  http://127.0.0.1:8767/cases/{case.case_id}")
    typer.echo(f"  Ledger:       http://127.0.0.1:8767/ledger")
    adapter = inference.server_adapter()
    if adapter:
        typer.echo(f"  Live model:   up ({adapter}) — the Classifier bot shows its verdict")
    else:
        typer.echo("  Live model:   inference server offline — Classifier bot falls back to rules")


@app.command("serve")
def serve(host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> None:
    db.init_db()
    uvicorn.run("psyc.cockpit.app:app", host=host, port=port, reload=reload)


if __name__ == "__main__":
    app()
