#!/usr/bin/env python3
"""delegate-report — Token, cost, and failure report for delegate runs.

Usage:
  delegate-report [--since DAYS] [--project NAME] [--fails] [--adapt]
                   [--all] [--delegate NAME] [--self-improve]
"""

import json, sys, argparse
from pathlib import Path
from datetime import datetime, timezone, timedelta
from collections import defaultdict

LOG = Path.home() / '.local' / 'share' / 'delegate-runs.jsonl'


def load_runs(since_days=None, project=None, delegate='vibe'):
    """Load runs from the shared log.

    The log is shared across delegate tools (vibe, opencode, gemini, …).
    `delegate` scopes the report:
      'vibe' (default) → vibe runs + claude-manual interventions on vibe tasks
      None             → every delegate (cross-delegate comparison)
      '<name>'         → that delegate only
    """
    if not LOG.exists():
        print(f"No log found at {LOG}")
        sys.exit(0)
    cutoff = None
    if since_days:
        cutoff = datetime.now(timezone.utc) - timedelta(days=since_days)
    runs = []
    for line in LOG.read_text().splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            r = json.loads(line)
        except Exception:
            continue
        if delegate is not None:
            dg = r.get('delegate', '')
            if delegate == 'vibe':
                # claude-manual rows are vibe-workflow interventions — keep them
                if dg not in ('vibe', 'claude-manual'):
                    continue
            elif dg != delegate:
                continue
        if project and r.get('project') != project:
            continue
        if cutoff:
            ts = r.get('ts', '')
            try:
                run_time = datetime.fromisoformat(ts.replace('Z', '+00:00'))
                if run_time < cutoff:
                    continue
            except Exception:
                pass
        runs.append(r)
    return runs


def ceq(r):
    """Claude Sonnet 4.6 equivalent cost — use logged value or recompute."""
    if 'cost_claude_eq' in r:
        return r['cost_claude_eq']
    return (r.get('tokens_in', 0) * 3.0 + r.get('tokens_out', 0) * 15.0) / 1_000_000


def fmt_cost(v):
    return f"${v:.4f}" if v else "  -   "


def fmt_pct(a, b):
    return f"{100*a//b:3d}%" if b else "  - "


def col_widths(headers, rows):
    widths = [len(h) for h in headers]
    for row in rows:
        for i, cell in enumerate(row):
            widths[i] = max(widths[i], len(str(cell)))
    return widths


def print_table(headers, rows):
    if not rows:
        print("  (no data)")
        return
    widths = col_widths(headers, rows)
    sep = "  "
    fmt = sep.join(f"{{:<{w}}}" for w in widths)
    print(fmt.format(*headers))
    print(sep.join("-" * w for w in widths))
    for row in rows:
        print(fmt.format(*[str(x) for x in row]))


def date_range(runs):
    dates = sorted(r.get('ts', '') for r in runs if r.get('ts'))
    if not dates:
        return ""
    return f"  {dates[0][:10]} → {dates[-1][:10]}"


def is_manual(r):
    return r.get('delegate') == 'claude-manual'


def show_overview(runs, scope='vibe only'):
    delegate_runs = [r for r in runs if not is_manual(r)]
    manual_runs   = [r for r in runs if is_manual(r)]

    total = len(delegate_runs)
    ok = sum(1 for r in delegate_runs if r.get('exit_code') == 0)
    tokens = sum(r.get('tokens_total', 0) for r in delegate_runs)
    cost = sum(r.get('cost_usd', 0) for r in delegate_runs)
    claude_eq = sum(ceq(r) for r in delegate_runs)
    manual_cost = sum(r.get('cost_usd', 0) for r in manual_runs)
    actual_saved = claude_eq - cost - manual_cost
    warns = sum(r.get('warn_count', 0) for r in delegate_runs)
    sr_f = sum(r.get('search_replace_fails', 0) for r in delegate_runs)
    syntax = sum(r.get('syntax_errors', 0) for r in delegate_runs)
    timeouts = sum(1 for r in delegate_runs if r.get('timed_out'))
    wrote_nothing = sum(1 for r in delegate_runs if r.get('wrote_nothing'))
    avg_dur = sum(r.get('duration_secs', 0) for r in delegate_runs) / total if total else 0
    saved_pct = f"  ({int(actual_saved / claude_eq * 100)}% cheaper than Claude)" if claude_eq > 0 and actual_saved > 0 else ""

    print(f"\n{'=' * 56}")
    print(f"  DELEGATE REPORT{date_range(runs)}")
    print(f"  Scope         : {scope}")
    print(f"{'=' * 56}")
    print(f"  Runs          : {total}  (ok: {ok}, failed: {total-ok}, timeout: {timeouts})")
    print(f"  Success rate  : {fmt_pct(ok, total)}")
    print(f"  Avg duration  : {avg_dur:.1f}s")
    print(f"  Tokens total  : {tokens:,}")
    print(f"  Delegate cost*: {fmt_cost(cost)}")
    print(f"  Claude equiv* : {fmt_cost(claude_eq)}")
    if manual_runs:
        manual_lines = sum(r.get('lines_added', 0) for r in manual_runs)
        print(f"  Manual (Claude): {len(manual_runs)} intervention(s)  ~{manual_lines} lines  est. {fmt_cost(manual_cost)}")
        print(f"  Actual saved  : {fmt_cost(actual_saved)}{saved_pct}  (after manual cost)")
    else:
        print(f"  Saved         : {fmt_cost(actual_saved)}{saved_pct}")
    if warns or sr_f or syntax or wrote_nothing:
        print(f"  --- bugs/warns ---")
        if warns:         print(f"  Warnings      : {warns}")
        if sr_f:          print(f"  SR failures   : {sr_f}")
        if syntax:        print(f"  Syntax errors : {syntax}")
        if wrote_nothing: print(f"  Wrote nothing : {wrote_nothing}  (prompt too vague or task already done)")
    print(f"\n  * cost = cache-blind estimate from config.toml pricing; real spend can be")
    print(f"    far lower (e.g. cached input). See README → cost methodology.")
    print()


def show_by_model(runs):
    delegate_runs = [r for r in runs if not is_manual(r)]
    manual_runs   = [r for r in runs if is_manual(r)]

    by_model = defaultdict(list)
    for r in delegate_runs:
        by_model[r.get('model', 'unknown')].append(r)

    print("BY MODEL")
    headers = ["Model", "Runs", "OK%", "Tokens", "Cost", "Claude eq", "Saved%", "Warns", "SR fails"]
    rows = []
    for model, mrs in sorted(by_model.items(), key=lambda x: -len(x[1])):
        ok = sum(1 for r in mrs if r.get('exit_code') == 0)
        tokens = sum(r.get('tokens_total', 0) for r in mrs)
        c = sum(r.get('cost_usd', 0) for r in mrs)
        claude_eq = sum(ceq(r) for r in mrs)
        saved_pct = f"{int((claude_eq - c) / claude_eq * 100)}%" if claude_eq > 0 else "-"
        rows.append([
            model, len(mrs), fmt_pct(ok, len(mrs)),
            f"{tokens:,}", fmt_cost(c), fmt_cost(claude_eq), saved_pct,
            sum(r.get('warn_count', 0) for r in mrs),
            sum(r.get('search_replace_fails', 0) for r in mrs),
        ])
    if manual_runs:
        tokens = sum(r.get('tokens_total', 0) for r in manual_runs)
        c = sum(r.get('cost_usd', 0) for r in manual_runs)
        lines = sum(r.get('lines_added', 0) for r in manual_runs)
        rows.append([
            "claude-manual", len(manual_runs), " — ",
            f"~{tokens:,}", f"~{fmt_cost(c)}", " — ", " — ",
            f"~{lines}L", "(est.)",
        ])
    print_table(headers, rows)
    print()


def show_by_project(runs):
    by_proj = defaultdict(list)
    for r in runs:
        by_proj[r.get('project', '?')].append(r)

    print("BY PROJECT")
    headers = ["Project", "Runs", "OK%", "Cost", "Saved", "Warns", "SR fails"]
    rows = []
    for proj, prs in sorted(by_proj.items(), key=lambda x: -len(x[1])):
        ok = sum(1 for r in prs if r.get('exit_code') == 0)
        c = sum(r.get('cost_usd', 0) for r in prs)
        claude_eq = sum(ceq(r) for r in prs)
        rows.append([
            proj[:30], len(prs), fmt_pct(ok, len(prs)),
            fmt_cost(c), fmt_cost(claude_eq - c),
            sum(r.get('warn_count', 0) for r in prs),
            sum(r.get('search_replace_fails', 0) for r in prs),
        ])
    print_table(headers, rows)
    print()


# ── Failure classification ────────────────────────────────────────────────────

FAIL_INFO = {
    'timeout':       ('Context saturated or task too large',
                      'Decompose into sub-tasks; reduce --max-turns to ≤8'),
    'exit_err':      ('Vibe verification failed or crashed',
                      'Read git diff; understand partial work before relaunching'),
    'exit_error':    ('Vibe verification failed or crashed',
                      'Read git diff; understand partial work before relaunching'),
    'syntax':        ('Vibe wrote syntactically invalid code',
                      'Run py_compile/node --check; fix manually before committing'),
    'syntax_error':  ('Vibe wrote syntactically invalid code',
                      'Run py_compile/node --check; fix manually before committing'),
    'sr_fail':       ('search_replace match failed — UTF-8 or wrong context',
                      'Use python str.replace() for accented chars; verify exact match'),
    'silent_exit':   ('Vibe exited immediately — 0 tokens, 0 tool calls',
                      'Check auth/API key; run a short test prompt; verify vibe CLI works'),
    'near_empty':    ('Vibe returned scaffold header only — output < 50 tokens',
                      'Prompt may be too short; add explicit file target and task verb'),
    'empty':         ('Vibe ran tool calls but wrote nothing',
                      'Grep for target first; rephrase prompt as an imperative verb'),
    'wrote_nothing': ('Vibe ran tool calls but wrote nothing',
                      'Grep for target first; rephrase prompt as an imperative verb'),
    'warn_only':     ('Non-fatal tool errors during run',
                      'Check [WARN] lines in run output; may still be correct'),
    'warn':          ('Non-fatal tool errors during run',
                      'Check [WARN] lines in run output; may still be correct'),
    'precheck_abort':('Required --require anchor missing — run never launched',
                      'A wasted run was avoided; grep the anchor locally, fix prompt or workdir'),
    'precheck':      ('Required --require anchor missing — run never launched',
                      'A wasted run was avoided; grep the anchor locally, fix prompt or workdir'),
    'denied':        ('Tool calls denied by sandbox/approval — 0 files written',
                      'Check the path is inside workdir; --trust does not cover paths outside it'),
    'narrated_no_tools': ('Model described the edit in text instead of calling write/edit tools',
                      'Rephrase prompt as an explicit imperative tool-use instruction, or switch model'),
}

# Canonical failure type names (new format) → display codes (backwards compat)
_REASON_DISPLAY = {
    'silent_exit':  'silent_exit',
    'near_empty':   'near_empty',
    'wrote_nothing':'wrote_nothing',
    'exit_error':   'exit_err',
    'syntax_error': 'syntax',
    'sr_fail':      'sr_fail',
    'warn_only':    'warn',
    'timeout':      'timeout',
    'precheck_abort':'precheck',
    'ok':           None,
}


def classify_fail(r):
    """Return primary failure type code, or None if run was clean."""
    # Use logged failure_reason when available (new log format)
    fr = r.get('failure_reason')
    if fr:
        return _REASON_DISPLAY.get(fr, fr) if fr != 'ok' else None
    # Legacy: derive from individual flag fields
    if r.get('timed_out'):
        return 'timeout'
    if r.get('exit_code', 0) not in (0, 124):
        return 'exit_err'
    if r.get('syntax_errors', 0) > 0:
        return 'syntax'
    if r.get('search_replace_fails', 0) > 0:
        return 'sr_fail'
    if r.get('wrote_nothing'):
        return 'empty'
    if r.get('warn_count', 0) > 0:
        return 'warn'
    return None


def is_notable(r, include_warns=False):
    fr = r.get('failure_reason', '')
    if fr and fr not in ('ok', 'warn_only'):
        return True
    if fr == 'warn_only' and include_warns:
        return True
    # Legacy fallback
    return (
        r.get('exit_code', 0) != 0
        or r.get('syntax_errors', 0) > 0
        or r.get('search_replace_fails', 0) > 0
        or r.get('wrote_nothing')
        or (include_warns and r.get('warn_count', 0) > 0)
    )


def show_adaptations(runs):
    """Failure rate broken down by prompt adaptation flags."""
    # Only runs that have adaptation data
    adapted = [r for r in runs if 'adaptations' in r and not is_manual(r)]
    if not adapted:
        print("ADAPTATIONS  (no runs with adaptation data yet)")
        print()
        return

    # Build all adaptation combos present
    from itertools import combinations as _comb
    combo_runs = defaultdict(list)
    for r in adapted:
        key = tuple(sorted(r.get('adaptations', []))) or ('none',)
        combo_runs[key].append(r)

    print("ADAPTATIONS  (failure rate by prompt adaptation)")
    headers = ["Adaptations", "Runs", "OK%", "silent_exit", "near_empty", "wrote_nothing", "other_fail"]
    rows = []
    for combo, crs in sorted(combo_runs.items(), key=lambda x: -len(x[1])):
        ok = sum(1 for r in crs if classify_fail(r) is None)
        counts = defaultdict(int)
        for r in crs:
            t = classify_fail(r)
            if t:
                counts[t] += 1
        silent  = counts.get('silent_exit', 0)
        near    = counts.get('near_empty', 0)
        wrote   = counts.get('wrote_nothing', 0) + counts.get('empty', 0)
        other   = sum(v for k, v in counts.items()
                      if k not in ('silent_exit', 'near_empty', 'wrote_nothing', 'empty'))
        rows.append([
            '+'.join(combo), len(crs), fmt_pct(ok, len(crs)),
            silent or '.', near or '.', wrote or '.', other or '.',
        ])
    print_table(headers, rows)
    print()


def show_fails_by_model(fails):
    """Benchmark table: failures broken down by model."""
    by_model = defaultdict(list)
    for r in fails:
        by_model[r.get('model', 'unknown')].append(r)

    print("FAILURES BY MODEL  (benchmark)")
    headers = ["Model", "Fails", "Timeout", "exit_err", "syntax", "sr_fail", "silent", "empty", "warn"]
    rows = []
    for model, mrs in sorted(by_model.items(), key=lambda x: -len(x[1])):
        counts = defaultdict(int)
        for r in mrs:
            t = classify_fail(r)
            if t:
                counts[t] += 1
        rows.append([
            model, len(mrs),
            counts['timeout'] or ".",
            counts['exit_err'] or counts['exit_error'] or ".",
            counts['syntax'] or counts['syntax_error'] or ".",
            counts['sr_fail'] or ".",
            counts['silent_exit'] or ".",
            counts['empty'] or counts['wrote_nothing'] or counts['near_empty'] or ".",
            counts['warn'] or counts['warn_only'] or ".",
        ])
    print_table(headers, rows)
    print()


# ── Self-improvement scan ───────────────────────────────────────────────────
# Pure counting, no LLM judgment: flags recurring/rising failure_reason
# patterns for a delegation model so a human (or a follow-up Claude session)
# can turn them into a targeted recommendation. Never writes to SKILL.md —
# that merge is deliberately left as a reviewed, manual step. See
# docs/error-reduction.md for the discipline this mirrors (don't act on
# <5% classes; require enough samples to trust a rate).

SELF_IMPROVE_MIN_SAMPLE = 10     # min runs in window before trusting a rate
SELF_IMPROVE_MIN_RATE   = 0.10   # 10% minimum failure rate to flag at all
SELF_IMPROVE_RISE_DELTA = 0.05   # must rise 5pp past last-flagged rate to re-flag
SELF_IMPROVE_WINDOW_DAYS = 14    # default window when --since isn't given
NON_FAILURE_REASONS = {None, '', 'ok', 'sandbox_blocked_ok'}


def self_improve_state_path(delegate):
    return Path.home() / '.local' / 'share' / f'{delegate or "all"}-self-improve-state.json'


def load_self_improve_state(delegate):
    p = self_improve_state_path(delegate)
    if not p.exists():
        return {}
    try:
        return json.loads(p.read_text())
    except Exception:
        return {}


def save_self_improve_state(delegate, state):
    p = self_improve_state_path(delegate)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(json.dumps(state, indent=2, sort_keys=True))


def show_self_improve(runs, delegate, window_days):
    delegate_runs = [r for r in runs if not is_manual(r)]
    total = len(delegate_runs)

    print(f"\n{'=' * 56}")
    print(f"  SELF-IMPROVE SCAN  (delegate: {delegate or 'all'}, window: {window_days}d)")
    print(f"{'=' * 56}")

    if total < SELF_IMPROVE_MIN_SAMPLE:
        print(f"  Runs: {total}  —  below the {SELF_IMPROVE_MIN_SAMPLE}-run minimum sample size.")
        print(f"  Skipping: a rate computed on this few runs is noise, not signal.")
        print()
        return

    counts = defaultdict(int)
    examples = defaultdict(list)
    for r in delegate_runs:
        fr = r.get('failure_reason')
        if fr in NON_FAILURE_REASONS:
            fr = classify_fail(r)  # legacy fallback for pre-failure_reason log entries
        if not fr or fr in NON_FAILURE_REASONS:
            continue
        counts[fr] += 1
        if len(examples[fr]) < 3:
            # No project name here — this scan's output is meant to be safe to
            # paste into a committed doc (docs/self-improvement-log.md, SKILL.md).
            # For the actual project name, run `delegate-report --fails --project X`
            # locally — that output stays in your terminal, never in this repo.
            examples[fr].append((r.get('ts', '?')[:10], r.get('model', '?')))

    if not counts:
        print(f"  Runs: {total}  —  no failures in window. Nothing to flag.")
        print()
        return

    state = load_self_improve_state(delegate)
    candidates = []
    for fr, n in counts.items():
        rate = n / total
        if rate < SELF_IMPROVE_MIN_RATE:
            continue
        prior = state.get(fr)
        if prior is None:
            status = 'NEW'
        elif rate >= prior.get('rate', 0) + SELF_IMPROVE_RISE_DELTA:
            status = f"RISING ({prior['rate']*100:.0f}% -> {rate*100:.0f}%)"
        else:
            continue  # already flagged before, not worsening — don't re-flag
        candidates.append((fr, n, rate, status))

    if not candidates:
        print(f"  Runs: {total}  —  no new or rising pattern above {SELF_IMPROVE_MIN_RATE*100:.0f}%.")
        print(f"  (Existing patterns already acknowledged — see {self_improve_state_path(delegate)})")
        print()
        return

    print(f"  Runs: {total}  —  {len(candidates)} candidate(s) for a recommendation:\n")
    for fr, n, rate, status in sorted(candidates, key=lambda x: -x[2]):
        info = FAIL_INFO.get(fr, ('Unknown issue', 'Check run output manually'))
        print(f"  [{status}] {fr} — {n}/{total} runs ({rate*100:.0f}%)")
        print(f"      cause : {info[0]}")
        print(f"      fix   : {info[1]}")
        print(f"      seen:")
        for ts, model in examples[fr]:
            print(f"        {ts}  ({model})")
        print()
        state[fr] = {
            'rate': round(rate, 4),
            'ts': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
            'window_runs': total,
        }

    save_self_improve_state(delegate, state)
    print(f"  State saved to {self_improve_state_path(delegate)}")
    print(f"  Next step (manual, not automated by this tool): read the evidence runs above,")
    print(f"  draft a dated entry in docs/self-improvement-log.md, then propose a SKILL.md")
    print(f"  diff for review. This tool never edits SKILL.md itself.")
    print()


def show_fails(runs, limit=20, include_warns=False, benchmark=False):
    fails = [r for r in runs if is_notable(r, include_warns)]
    if not fails:
        print("No failures or issues found.")
        return

    if benchmark:
        show_fails_by_model(fails)

    fails_sorted = sorted(fails, key=lambda r: r.get('ts', ''), reverse=True)[:limit]
    print(f"FAILURES / ISSUES  (last {len(fails_sorted)} of {len(fails)})")
    headers = ["Date", "Project", "Model", "Type", "Exit", "Warns", "SR", "Syn", "Dur"]
    rows = []
    seen_types = []
    for r in fails_sorted:
        ftype = classify_fail(r) or "?"
        if ftype not in seen_types:
            seen_types.append(ftype)
        rows.append([
            r.get('ts', '')[:10],
            r.get('project', '?')[:22],
            r.get('model', '?')[:20],
            ftype,
            r.get('exit_code', '?'),
            r.get('warn_count', 0) or ".",
            r.get('search_replace_fails', 0) or ".",
            r.get('syntax_errors', 0) or ".",
            f"{r.get('duration_secs', 0):.0f}s",
        ])
    print_table(headers, rows)

    # Legend: only for types that appear in this report
    if seen_types:
        print()
        print("LEGEND")
        for ftype in seen_types:
            info = FAIL_INFO.get(ftype, ('Unknown issue', 'Check run output manually'))
            print(f"  {ftype:<10}  {info[0]}")
            print(f"             → {info[1]}")
    print()


def main():
    ap = argparse.ArgumentParser(description="Delegate run report")
    ap.add_argument('--since', type=int, metavar='DAYS', help='Last N days only')
    ap.add_argument('--project', metavar='NAME', help='Filter by project name')
    ap.add_argument('--fails', action='store_true', help='Show only failures and issues')
    ap.add_argument('--adapt', action='store_true', help='Show failure rates by prompt adaptation')
    ap.add_argument('--all', action='store_true', help='Include all delegates, not just vibe')
    ap.add_argument('--delegate', metavar='NAME', help='Filter to a specific delegate (e.g. vibe, opencode, gemini)')
    ap.add_argument('--self-improve', action='store_true',
                     help='Scan for new/rising failure_reason patterns for this delegate '
                          '(pure detection — never edits SKILL.md itself)')
    args = ap.parse_args()

    if args.all:
        delegate, scope = None, 'all delegates'
    elif args.delegate:
        delegate, scope = args.delegate, f'{args.delegate} only'
    else:
        delegate, scope = 'vibe', 'vibe only'

    if args.self_improve:
        window_days = args.since or SELF_IMPROVE_WINDOW_DAYS
        runs = load_runs(window_days, args.project, delegate)
        if not runs:
            print(f"No runs found (scope: {scope}, window: {window_days}d).")
            return
        show_self_improve(runs, delegate, window_days)
        return

    runs = load_runs(args.since, args.project, delegate)
    if not runs:
        print(f"No runs found (scope: {scope}).")
        return

    if args.fails:
        print(f"\n[scope: {scope}]")
        show_fails(runs, limit=50, include_warns=True, benchmark=True)
        return

    if args.adapt:
        print(f"\n[scope: {scope}]")
        show_adaptations(runs)
        show_fails(runs, limit=20, include_warns=False, benchmark=False)
        return

    show_overview(runs, scope)
    show_by_model(runs)
    show_by_project(runs)
    show_fails(runs, limit=10, include_warns=False, benchmark=False)


if __name__ == '__main__':
    main()
