#!/usr/bin/env python3
"""Zip everything a reader without this codebase needs to diagnose usage.

One command, one archive, no model calls, no prompt text. The scan runs once
per window so the JSON and its rendering agree to the byte; doctor runs per
harness and a failure is filed in the archive rather than being fatal; the
scanner's own source and the schema reference ride along because the reader
may not have the repository the numbers came from.

  usage_bundle.py [--since 7d] [--harness NAME] [--out PATH]
"""
import argparse
import json
import os
import platform
import subprocess
import sys
import time
import zipfile

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import dispatch_log  # noqa: E402
import pricing  # noqa: E402
import routing  # noqa: E402
import usage_scan  # noqa: E402
from state import _data_root  # noqa: E402

# Never in the bundle: transcripts, prompt text, and the raw dispatch log,
# which carries prompt heads when LEOS_AGENT_DISPATCH_LOG_PROMPTS=1. The
# rendered guard summary goes in instead.
SNAPSHOT_SOURCES = ("usage_scan.py", "dispatch_log.py", "pricing.py", "routing_engine.py", "routing.py")
COPIED_FILES = ((".claude-plugin/plugin.json", "plugin.json"),
                ("skills/review-usage/reference/sources.md", "sources.md"))
CLI_VERSIONS = (("claude", ["claude", "--version"]), ("codex", ["codex", "--version"]),
                ("gh", ["gh", "--version"]), ("node", ["node", "--version"]))

README = """# leos-agent usage diagnostics

Generated by `scripts/usage_bundle.py` from local records on the user's
machine. No model calls were made. No prompt text, transcript content, or
repository source outside this plugin is included: only the scanner's
aggregate counters and read-only installation diagnostics.

## Window

`--since {since}`, relative to `generated_at` in `environment.txt`. A relative
window ends at collection time, not at the end of a calendar day.

## Files

| File | What it is |
|---|---|
| `usage-{since}.json` | Primary artifact: `usage_scan.py --since {since} --json` |
| `usage-{since}.txt` | The same report rendered as text, from the same run |
{trend_row}| `doctor-<harness>.json` | `doctor.py --harness <h>`; an `.exit.txt` sibling carries a non-zero exit (usually: not installed here); an `.error.txt` sibling means the run produced no report |
| `routing-show.txt` | Configured cheap/standard/premium tiers per harness |
| `guard-report.txt` | Rendered dispatch-guard summary over the whole retained log |
| `environment.txt` | OS, Python, plugin version, harness CLI versions, timestamps |
| `plugin.json` | Installed plugin manifest |
| `sources.md` | How to read the scan schema and what it cannot show |
| `scanner/*.py` | The exact scanner source that produced the numbers |
| `model-prices.json` | The exact price catalog collection used: the local refreshed cache when one was valid, else the bundled snapshot |

## Headline

{headline}

## Reading it

Reference costs are current public list rates, not invoices; they do not
reflect subscription allocation or negotiated pricing. Delegation share is
not savings, and nothing here measures savings without a comparable task
baseline. Guard `completed`/`executed` rows are lifecycle observations, each
child counted once in its final state; missing rows do not establish that
the guard did not run. Token counts are not amounts of work.
"""


def run(argv, timeout=60):
    """(returncode, stdout, stderr) for a child process; never raises."""
    try:
        proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False)
        return proc.returncode, proc.stdout, proc.stderr
    except FileNotFoundError:
        return 127, "", "%s: not found on PATH" % argv[0]
    except subprocess.TimeoutExpired:
        return 124, "", "%s: timed out after %ss" % (argv[0], timeout)
    except OSError as exc:
        return 1, "", "%s: %s" % (argv[0], exc)


def _json_object(text):
    try:
        return isinstance(json.loads(text), dict)
    except ValueError:
        return False


def scan(since_text, only, catalog):
    """One collect per window, one catalog for every window: the JSON, the text,
    and the archived prices all describe the same report."""
    report = usage_scan.collect(usage_scan.parse_since(since_text), only, catalog)
    return report, json.dumps(report, indent=1, sort_keys=True), usage_scan.render(report)


def plugin_version():
    try:
        with open(os.path.join(ROOT, ".claude-plugin", "plugin.json"), encoding="utf-8") as fh:
            return str(json.load(fh).get("version", "unknown"))
    except (OSError, ValueError):
        return "unknown"


def _os_description():
    # platform.platform() shells out to `file` on macOS and can fail in a
    # constrained environment; a diagnostics bundle must not die on the line
    # that describes the machine. Fall back to the fields that need no child.
    try:
        return platform.platform()
    except Exception as exc:
        return "%s %s %s (platform.platform failed: %s)" % (
            platform.system(), platform.release(), platform.machine(), type(exc).__name__)


def environment(args, generated):
    lines = ["generated_at_utc=" + time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(generated)),
             "generated_at_local=" + time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime(generated)),
             "os=" + _os_description(),
             "python=" + platform.python_version(),
             "plugin_version=" + plugin_version(),
             "plugin_root=" + ROOT,
             "data_root=" + _data_root(),
             "scan_window=--since %s (relative to generated_at)" % args.since,
             "harness_filter=" + (args.harness or "all")]
    for name, argv in CLI_VERSIONS:
        code, out, err = run(argv, timeout=15)
        text = (out or err).strip()
        lines.append("%s_cli=%s" % (name, text.splitlines()[0] if text else "exit %d" % code))
    return "\n".join(lines) + "\n"


def headline(report):
    """A few lines the reader can check the JSON against."""
    if not report:
        return "- the primary scan failed; see its .error.txt"
    out = []
    for name, data in sorted(report.get("harnesses", {}).items()):
        if data.get("status") not in ("ok", "partial"):
            out.append("- %s: %s" % (name, data.get("status")))
            continue
        costs = data.get("reference_cost", {})
        out.append("- %s: %d session(s), %d main + %d subagent request(s); reference $%.2f-$%.2f; %d unpriced token(s)"
                   % (name, data.get("sessions", 0), data["main"]["requests"], data["subagent"]["requests"],
                      sum(c.get("minimum_usd", 0) for c in costs.values()),
                      sum(c.get("maximum_usd", 0) for c in costs.values()),
                      sum(c.get("unpriced_tokens", 0) for c in costs.values())))
    guard = report.get("guard") or {}
    if guard and not guard.get("error"):
        out.append("- guard: %d record(s), %d dispatch attempt(s), %d confirmed execution(s), %d blocked, %d error(s)"
                   % (guard.get("records", 0), guard.get("dispatch_attempts", 0), guard.get("confirmed_executions", 0),
                      guard.get("blocked", 0), guard.get("errors", 0)))
    return "\n".join(out) or "- no harness data in this window"


def build(args):
    """{archive name: text or bytes}, plus the names of components that failed."""
    generated = time.time()
    members, failures = {}, []
    # Load the catalog once and archive that object: pricing.load() prefers a
    # refreshed local cache over the bundled snapshot, and a bundle that ships
    # the snapshot while the report was priced from the cache cannot explain
    # its own numbers.
    catalog = pricing.load()
    members["model-prices.json"] = json.dumps(catalog, indent=1, sort_keys=True) + "\n"
    report = None
    try:
        report, as_json, as_text = scan(args.since, args.harness, catalog)
        members["usage-%s.json" % args.since] = as_json + "\n"
        members["usage-%s.txt" % args.since] = as_text + "\n"
    except Exception as exc:  # the primary scan failing must not cost the rest of the archive
        members["usage-%s.error.txt" % args.since] = "%s: %s\n" % (type(exc).__name__, exc)
        failures.append("usage-%s" % args.since)
    trend_row = ""
    if args.since != "7d":
        try:
            _, trend_json, _ = scan("7d", args.harness, catalog)
            members["usage-7d.json"] = trend_json + "\n"
            trend_row = "| `usage-7d.json` | Seven-day scan, for trend context |\n"
        except Exception as exc:  # the primary window already succeeded; file this one
            members["usage-7d.error.txt"] = "%s: %s\n" % (type(exc).__name__, exc)
            failures.append("usage-7d")
    python = sys.executable or "python3"
    for name in ([args.harness] if args.harness in routing.HARNESSES else list(routing.HARNESSES)):
        code, out, err = run([python, os.path.join(HERE, "doctor.py"), "--harness", name, "--json"])
        # doctor exits non-zero for an installation that is not current. That is
        # a finding, and its JSON is the diagnostic; only a run that produced no
        # report at all is a failure of the bundle's own.
        if _json_object(out):
            members["doctor-%s.json" % name] = out
            if code != 0:
                members["doctor-%s.exit.txt" % name] = "exit %d\n%s" % (code, err)
        else:
            members["doctor-%s.error.txt" % name] = "exit %d\n%s%s" % (code, out, err)
            failures.append("doctor-" + name)
    code, out, err = run([python, os.path.join(HERE, "routing.py"), "show"])
    members["routing-show.txt"] = out if code == 0 else "exit %d\n%s%s" % (code, out, err)
    try:
        members["guard-report.txt"] = dispatch_log.render(dispatch_log.summarise(dispatch_log.read())) + "\n"
    except Exception as exc:
        members["guard-report.error.txt"] = "%s: %s\n" % (type(exc).__name__, exc)
        failures.append("guard-report")
    members["environment.txt"] = environment(args, generated)
    for rel, arc in COPIED_FILES:
        try:
            with open(os.path.join(ROOT, rel), "rb") as fh:
                members[arc] = fh.read()
        except OSError as exc:
            members[arc + ".error.txt"] = str(exc) + "\n"
    for name in SNAPSHOT_SOURCES:
        try:
            with open(os.path.join(HERE, name), "rb") as fh:
                members["scanner/" + name] = fh.read()
        except OSError as exc:
            members["scanner/" + name + ".error.txt"] = str(exc) + "\n"
    readme = README.format(since=args.since, trend_row=trend_row, headline=headline(report))
    if failures:
        readme += "\n## Components that failed\n\n" + "".join("- %s (see its `.error.txt`)\n" % f for f in failures)
    members["README.md"] = readme
    return members, failures


def write_zip(path, members):
    with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        for arc in sorted(members):
            data = members[arc]
            archive.writestr(arc, data if isinstance(data, bytes) else data.encode("utf-8"))


def default_out(since):
    stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime())
    return os.path.join(os.getcwd(), "leos-agent-usage-%s-%s.zip" % (since, stamp))


def main(argv=None):
    parser = argparse.ArgumentParser(prog="usage_bundle.py", description=__doc__.splitlines()[0])
    parser.add_argument("--since", default="7d", help="window, e.g. 24h, 7d, 2w (default 7d)")
    parser.add_argument("--harness", choices=sorted(usage_scan.SOURCES), help="only this harness")
    parser.add_argument("--out", help="zip path (default: ./leos-agent-usage-<since>-<stamp>.zip)")
    args = parser.parse_args(argv)
    out = args.out or default_out(args.since)
    members, failures = build(args)
    write_zip(out, members)
    print(out)
    if failures:
        print("usage_bundle: %d component(s) failed and are filed in the archive: %s"
              % (len(failures), ", ".join(failures)), file=sys.stderr)
    return 0


if __name__ == "__main__":
    sys.exit(main())
