"""The basic JobManager (docs/V3-PLAN.md §4 P3) — long-running agentic tools.

A job is a subprocess (the engine's ``-m`` entrypoints: digest_now, sweep,
attention_digest) or a Python callable, tracked in memory for the life of the
server. Results follow the progressive-disclosure convention: ``job_result``
returns a ``tldr`` by default, the ``summary`` next, and the full output only
on ``detail="full"`` — a job's output must never flood a seat's context.

In-memory only, deliberately: jobs are how a *running* server tracks its own
work; a restart forgetting finished jobs is fine (the durable artifacts — the
digest email, the filed notes — already landed in the vault/mailbox).
"""
import itertools
import subprocess
import threading
from datetime import datetime, timezone

RUNNING, DONE, ERROR, CANCELLED = "running", "done", "error", "cancelled"
_KEEP = 50  # most-recent jobs retained


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


class JobManager:
    def __init__(self):
        self._jobs = {}
        self._lock = threading.Lock()
        self._seq = itertools.count(1)

    # ---- start -----------------------------------------------------------------

    def _new(self, tool, seat_id):
        with self._lock:
            jid = "job-%04d" % next(self._seq)
            self._jobs[jid] = {
                "job_id": jid, "tool": tool, "seat": seat_id, "status": RUNNING,
                "started": _now(), "finished": None,
                "tldr": None, "summary": None, "detail": None, "error": None,
                "progress": None, "_proc": None, "_cancel": threading.Event(),
            }
            # bound the ledger — drop the oldest finished jobs beyond the cap
            finished = [j for j in self._jobs.values() if j["status"] != RUNNING]
            for j in sorted(finished, key=lambda j: j["started"])[:max(0, len(self._jobs) - _KEEP)]:
                self._jobs.pop(j["job_id"], None)
            return self._jobs[jid]

    def start_subprocess(self, tool, seat_id, argv, summarize=None, env=None):
        """Run ``argv``; stdout+stderr become the job detail, ``summarize(output,
        returncode) -> (tldr, summary)`` shapes the disclosure layers."""
        job = self._new(tool, seat_id)

        def run():
            try:
                proc = subprocess.Popen(argv, stdout=subprocess.PIPE,
                                        stderr=subprocess.STDOUT, text=True, env=env)
                job["_proc"] = proc
                out, _ = proc.communicate()
                out = out or ""
                if job["_cancel"].is_set():
                    self._finish(job, CANCELLED, detail=out, tldr="cancelled")
                    return
                if proc.returncode != 0:
                    self._finish(job, ERROR, detail=out,
                                 error=f"exit {proc.returncode}",
                                 tldr=f"{tool} failed (exit {proc.returncode})")
                    return
                tldr, summary = (summarize or _default_summarize)(out, proc.returncode)
                self._finish(job, DONE, detail=out, tldr=tldr, summary=summary)
            except Exception as e:  # a job must record its failure, never kill the server
                self._finish(job, ERROR, error=str(e), tldr=f"{tool} failed: {e}")

        threading.Thread(target=run, name=f"vault-mcp-{job['job_id']}", daemon=True).start()
        return job["job_id"]

    def start_callable(self, tool, seat_id, fn):
        """Run ``fn(job_ctx) -> {tldr, summary, detail}`` in a worker thread.

        ``job_ctx`` is how a long callable participates in the job protocol:
        ``ctx["cancelled"]()`` answers whether ``cancel()`` was called (the
        callable should wind down and return its partials), and
        ``ctx["progress"](dict)`` publishes a live progress hint surfaced by
        ``job_result`` while the job runs. A run that returns after a cancel
        finishes CANCELLED — with whatever partial result it brought back."""
        job = self._new(tool, seat_id)

        def set_progress(info):
            with self._lock:
                job["progress"] = info

        ctx = {"cancelled": job["_cancel"].is_set, "progress": set_progress}

        def run():
            try:
                result = fn(ctx) or {}
                status = CANCELLED if job["_cancel"].is_set() else DONE
                self._finish(job, status, tldr=result.get("tldr"),
                             summary=result.get("summary"), detail=result.get("detail"))
            except Exception as e:
                self._finish(job, ERROR, error=str(e), tldr=f"{tool} failed: {e}")

        threading.Thread(target=run, name=f"vault-mcp-{job['job_id']}", daemon=True).start()
        return job["job_id"]

    def _finish(self, job, status, detail=None, tldr=None, summary=None, error=None):
        with self._lock:
            job.update(status=status, finished=_now(), detail=detail,
                       tldr=tldr, summary=summary or tldr, error=error)

    # ---- inspect ----------------------------------------------------------------

    def _get(self, job_id):
        with self._lock:
            job = self._jobs.get(job_id)
        if job is None:
            raise KeyError(f"unknown job {job_id!r}")
        return job

    def status(self, job_id) -> dict:
        j = self._get(job_id)
        out = {k: j[k] for k in ("job_id", "tool", "seat", "status", "started", "finished")}
        if j["status"] == RUNNING and j["progress"] is not None:
            out["progress"] = j["progress"]   # a live hint, never part of the result
        return out

    def result(self, job_id, detail="tldr") -> dict:
        """Progressive disclosure: tldr → summary → full."""
        j = self._get(job_id)
        out = self.status(job_id)
        out["tldr"] = j["tldr"]
        if j["error"]:
            out["error"] = j["error"]
        if detail in ("summary", "full"):
            out["summary"] = j["summary"]
        if detail == "full":
            out["detail"] = j["detail"]
        elif j["status"] == DONE:
            out["more"] = 'job_result(job_id, detail="summary"|"full") for the rest'
        return out

    def cancel(self, job_id) -> dict:
        j = self._get(job_id)
        if j["status"] == RUNNING:
            j["_cancel"].set()
            proc = j.get("_proc")
            if proc is not None and proc.poll() is None:
                proc.terminate()
        return self.status(job_id)

    def list(self) -> list:
        with self._lock:
            ids = sorted(self._jobs)
        return [self.status(i) for i in ids]


def _default_summarize(output, returncode):
    lines = [ln for ln in output.strip().splitlines() if ln.strip()]
    tldr = lines[-1] if lines else "done (no output)"
    return tldr, "\n".join(lines[-10:]) or tldr
