"""``fedres`` — the per-project contract surface mgf-fed-web (and CI) consume.

All commands are deterministic, emit structured JSON on ``--format json``, and exit
nonzero on failure. ``fedres check`` is the freshness gate: nonzero unless IN_SYNC.
"""

from __future__ import annotations

import argparse
import importlib
import json
import os
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from . import __version__, live, session_close
from .generate import GenerateError, generate
from .provider import DiskProvider
from .states import SyncState
from .sync import SyncReport, deep_check, shallow_check

#: The family-adapter plugin path (CIR-26). A colon-separated list of importable
#: module paths, imported by the CLI AFTER the built-in projectors register, so each
#: module's import-time ``register_projector(...)`` calls REPLACE the built-in slots
#: (last write wins). This makes a second family's registry projectors reachable from
#: the stock CLI — ``MGF_FEDRES_PROJECTORS=<module> fedres ... check`` — with no
#: wrapper script. The Sooth family is the first consumer.
PROJECTOR_PLUGINS_ENV = "MGF_FEDRES_PROJECTORS"


class ProjectorPluginError(Exception):
    """A module named in ``$MGF_FEDRES_PROJECTORS`` could not be imported (fail-loud)."""


def _load_projector_plugins(spec: str | None) -> list[str]:
    """Import each family-adapter module named in ``$MGF_FEDRES_PROJECTORS``.

    Dependency-free (stdlib ``importlib`` only). An empty/unset value is a no-op —
    the stock CLI keeps the built-in projectors. A named module that cannot be
    imported is a loud, named error (never a silent fall-back to the built-ins).
    """
    if not spec:
        return []
    loaded: list[str] = []
    for module in (m.strip() for m in spec.split(":")):
        if not module:
            continue
        try:
            importlib.import_module(module)
        except ImportError as exc:
            raise ProjectorPluginError(
                f"{PROJECTOR_PLUGINS_ENV}: cannot import projector module {module!r}: {exc} "
                "— it must be importable (on sys.path / PYTHONPATH), and its import must "
                f"register its projectors. Unset {PROJECTOR_PLUGINS_ENV} to use the built-ins."
            ) from exc
        loaded.append(module)
    return loaded


def _now_iso() -> str:
    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


def _print(obj: dict[str, Any], as_json: bool, human: str) -> None:
    print(json.dumps(obj, ensure_ascii=False) if as_json else human)


def _report_dict(report: SyncReport) -> dict[str, Any]:
    out: dict[str, Any] = {"state": report.state.value, "detail": report.detail}
    if report.changed_files:
        out["changed_files"] = list(report.changed_files)
    if report.reason:
        out["reason"] = report.reason
    if report.error:
        out["error"] = report.error
    return out


def _cmd_status(provider: DiskProvider, as_json: bool) -> int:
    report = shallow_check(provider)
    payload = {"schema_version": "0.1", "command": "status", **_report_dict(report)}
    _print(payload, as_json, f"{report.state.value}  {report.detail}")
    return 0


def _cmd_check(provider: DiskProvider, as_json: bool) -> int:
    report = deep_check(provider)
    payload = {"schema_version": "0.1", "command": "check", **_report_dict(report)}
    _print(payload, as_json, f"{report.state.value}  {report.detail}")
    # the freshness gate: nonzero unless IN_SYNC
    return 0 if report.state is SyncState.IN_SYNC else 1


def _cmd_generate(provider: DiskProvider, as_json: bool) -> int:
    try:
        result = generate(provider, generated_at=_now_iso(), write=True)
    except GenerateError as exc:
        _print({"command": "generate", "ok": False, "error": str(exc)}, as_json, f"ERROR  {exc}")
        return 1
    files = sorted(result.files)
    payload = {
        "schema_version": "0.1",
        "command": "generate",
        "regenerated": True,
        "project": result.project["id"],
        "files": files,
        "new_source_commit": provider.head_commit(),
        "committed": False,
        "note": "generated/ rebuilt in the working tree; uncommitted — review and commit project-side",
    }
    human = f"regenerated {len(files)} file(s): {', '.join(files)} (uncommitted)"
    _print(payload, as_json, human)
    return 0


def _cmd_run(
    provider: DiskProvider, op: str, command: list[str], task: str | None, as_json: bool
) -> int:
    if not command:
        _print(
            {
                "command": "run",
                "ok": False,
                "error": "no command given (use: fedres run <op> -- <cmd...>)",
            },
            as_json,
            "ERROR  no command given",
        )
        return 2
    outcome = live.run_traced(provider, op=op, command=command, task=task)
    payload = {
        "schema_version": "0.1",
        "command": "run",
        "session": outcome.session,
        "op": op,
        "result": outcome.result,
        "duration_s": outcome.duration_s,
        "run_record": outcome.run_record,
        "live_log": outcome.live_log,
    }
    _print(
        payload, as_json, f"{outcome.result}  {op} in {outcome.duration_s}s -> {outcome.run_record}"
    )
    return 0 if outcome.result == "ok" else 1


def _cmd_session_close(
    provider: DiskProvider,
    *,
    init: bool,
    doc: bool,
    write: str | None,
    allow_dirty: bool,
    date: str,
    as_json: bool,
) -> int:
    if doc:
        print(session_close.canonical_doc(), end="")
        return 0
    if init:
        text = session_close.render_template(provider, date=date)
        if write:
            Path(write).write_text(text, encoding="utf-8")
            _print(
                {"schema_version": "0.1", "command": "session-close", "wrote": write},
                as_json,
                f"wrote state-at-exit scaffold to {write}",
            )
        elif as_json:
            print(json.dumps({"command": "session-close", "scaffold": text}, ensure_ascii=False))
        else:
            print(text, end="")
        return 0

    findings, ok = session_close.check(provider, allow_dirty=allow_dirty)
    payload = {
        "schema_version": "0.1",
        "command": "session-close",
        "ok": ok,
        "findings": [{"level": f.level, "code": f.code, "message": f.message} for f in findings],
    }
    if as_json:
        print(json.dumps(payload, ensure_ascii=False))
    else:
        marks = {"fail": "✗", "warn": "!", "info": "·"}
        for finding in findings:
            print(f"{marks[finding.level]} {finding.message}")
        print("PASS  session-close checks green" if ok else "FAIL  resolve the ✗ items")
    # non-blocking-but-loud: nonzero on any fail so a hook/CI can surface it
    return 0 if ok else 1


def _cmd_tail(provider: DiskProvider, session: str | None, as_json: bool) -> int:
    sid = session or live.current_session(provider)
    if sid is None:
        _print(
            {
                "command": "tail",
                "active": False,
                "detail": "no live session — run `fedres run <op> --trace`",
            },
            as_json,
            "no live session — run `fedres run <op> --trace`",
        )
        return 0
    events = live.read_session(provider, sid)
    if as_json:
        print(
            json.dumps(
                {"command": "tail", "active": True, "session": sid, "events": events},
                ensure_ascii=False,
            )
        )
    else:
        for event in events:
            print(
                f"#{event.get('seq'):>3} {event.get('ts')} {event.get('kind'):<14} {event.get('msg', event.get('label', ''))}"
            )
    return 0


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        prog="fedres", description="fed-resources project contract surface"
    )
    parser.add_argument("--version", action="version", version=f"fedres {__version__}")
    parser.add_argument("-C", "--root", default=".", help="project root (default: cwd)")
    parser.add_argument("--format", choices=["text", "json"], default="text")
    sub = parser.add_subparsers(dest="command", required=True)

    sub.add_parser("status", help="shallow sync verdict (manifest only, instant)")
    sub.add_parser("check", help="deep sync verdict (regenerate -> diff); the freshness gate")
    sub.add_parser(
        "generate", help="force-sync: rebuild generated/ from source/ in the working tree"
    )
    p_run = sub.add_parser("run", help="run a traced op, emitting live events + a run-record")
    p_run.add_argument("op", help="operation label (e.g. 'pytest')")
    p_run.add_argument("--task", default=None, help="task id this op advances")
    p_tail = sub.add_parser("tail", help="read the active (or named) live session")
    p_tail.add_argument("--session", default=None)
    p_sc = sub.add_parser(
        "session-close", help="end-of-session hygiene gate + state-at-exit scaffold"
    )
    p_sc.add_argument("--init", action="store_true", help="print the (pre-filled) scaffold")
    p_sc.add_argument(
        "--doc", action="store_true", help="print the canonical Session-Close standard"
    )
    p_sc.add_argument("--write", default=None, help="with --init, write to this path not stdout")
    p_sc.add_argument(
        "--allow-dirty", action="store_true", help="downgrade the uncommitted failure to a warning"
    )

    # everything after a standalone `--` is the command to run; the rest is parsed
    raw = sys.argv[1:] if argv is None else list(argv)
    if "--" in raw:
        split = raw.index("--")
        head, command_tail = raw[:split], raw[split + 1 :]
    else:
        head, command_tail = raw, []
    args = parser.parse_args(head)

    # Family-adapter seam (CIR-26): import any $MGF_FEDRES_PROJECTORS modules AFTER the
    # built-ins have registered (that happened at package import) and BEFORE dispatch, so
    # their import-time register_projector(...) calls override the slots for this run.
    try:
        _load_projector_plugins(os.environ.get(PROJECTOR_PLUGINS_ENV))
    except ProjectorPluginError as exc:
        print(f"ERROR  {exc}", file=sys.stderr)
        return 2

    provider = DiskProvider(args.root)
    as_json = args.format == "json"

    if args.command == "status":
        return _cmd_status(provider, as_json)
    if args.command == "check":
        return _cmd_check(provider, as_json)
    if args.command == "generate":
        return _cmd_generate(provider, as_json)
    if args.command == "run":
        return _cmd_run(provider, args.op, command_tail, args.task, as_json)
    if args.command == "tail":
        return _cmd_tail(provider, args.session, as_json)
    if args.command == "session-close":
        return _cmd_session_close(
            provider,
            init=args.init,
            doc=args.doc,
            write=args.write,
            allow_dirty=args.allow_dirty,
            date=_now_iso()[:10],
            as_json=as_json,
        )
    parser.error(f"unknown command {args.command!r}")
    return 2


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
