#!/usr/bin/env python3
"""Extract error signals from a Claude Code, Codex, Cursor, or Pi JSONL session file.

Usage:
  cat <session.jsonl> | python3 extract-errors.py
  cat <session.jsonl> | python3 extract-errors.py --output PATH

Auto-detects platform from the JSONL structure.
Note: Cursor agent transcripts do not log tool results, so no errors can be extracted.
Finds failed tool calls / commands and outputs them with timestamps.

When --output PATH is given, the extracted error log is written to PATH and
stdout receives only a one-line JSON status (_meta with wrote/bytes/stats).
This lets callers route bulk content to a scratch file without round-tripping
extraction bytes through orchestrator tool results.

Without --output, extracted content goes to stdout and ends with a _meta line.
"""
import argparse
import io
import os
import sys
import json

parser = argparse.ArgumentParser(add_help=True)
parser.add_argument(
    "--output",
    metavar="PATH",
    help="Write extracted errors to PATH instead of stdout. Stdout receives a one-line _meta status.",
)
args = parser.parse_args()

sys.stdin.reconfigure(encoding="utf-8", errors="replace")
sys.stdout.reconfigure(encoding="utf-8", errors="replace")

_original_stdout = sys.stdout
if args.output:
    sys.stdout = io.StringIO()

stats = {"lines": 0, "parse_errors": 0, "errors_found": 0}


def summarize_error(raw):
    """Extract a short error summary instead of dumping the full payload."""
    text = str(raw).strip()
    # Take the first non-empty line as the error message
    for line in text.split("\n"):
        line = line.strip()
        if line:
            return line[:200]
    return text[:200]


def json_object(line):
    """Decode one JSONL record and retain only object-shaped entries."""
    try:
        value = json.loads(line)
    except json.JSONDecodeError:
        return None
    return value if isinstance(value, dict) else None


def handle_claude(obj):
    if obj.get("type") == "user":
        content = obj.get("message", {}).get("content", [])
        if isinstance(content, list):
            for block in content:
                if block.get("type") == "tool_result" and block.get("is_error"):
                    ts = obj.get("timestamp", "")[:19]
                    summary = summarize_error(block.get("content", ""))
                    print(f"[{ts}] [error] {summary}")
                    print("---")
                    stats["errors_found"] += 1


def handle_codex(obj):
    if obj.get("type") == "event_msg":
        p = obj.get("payload", {})
        if not isinstance(p, dict):
            return
        if p.get("type") == "exec_command_end":
            output = p.get("aggregated_output", "")
            stderr = p.get("stderr", "")
            command = p.get("command", [])
            if isinstance(command, list):
                cmd_str = str(command[-1]) if command else ""
            elif isinstance(command, str):
                cmd_str = command
            else:
                cmd_str = ""

            exit_match = _codex_exit_code(p, output)
            if exit_match is not None:
                ts = obj.get("timestamp", "")[:19]
                error_summary = summarize_error(stderr if stderr else output)
                print(f"[{ts}] [error] exit={exit_match} cmd={cmd_str[:120]}: {error_summary}")
                print("---")
                stats["errors_found"] += 1


def _codex_exit_code(payload, output):
    """Return a non-zero Codex command exit code, if the event states one.

    Successful tools commonly write progress diagnostics to stderr. Stderr is
    useful context for a confirmed failure, but it is not itself an error
    signal. Prefer a structured exit-code field when the producer provides
    one, then retain the legacy aggregated-output form.
    """
    for key in ("exit_code", "exitCode", "status"):
        value = payload.get(key)
        if isinstance(value, int) and not isinstance(value, bool):
            return value if value != 0 else None
        if isinstance(value, str) and value.isdigit():
            parsed = int(value)
            return parsed if parsed != 0 else None

    if isinstance(output, str) and "Process exited with code " in output:
        try:
            code_str = output.split("Process exited with code ", 1)[1].split("\n", 1)[0]
            exit_code = int(code_str)
            return exit_code if exit_code != 0 else None
        except (IndexError, ValueError):
            return None
    return None


def _pi_content_summary(content):
    if isinstance(content, str):
        return summarize_error(content)
    if isinstance(content, list):
        text = "\n".join(
            block.get("text", "")
            for block in content
            if isinstance(block, dict) and block.get("type") in ("text", "toolError")
        )
        return summarize_error(text)
    return summarize_error(content)


def _pi_active_path_objects(objects):
    """Return only entries on Pi's active leaf-to-root path."""
    by_id = {
        obj.get("id"): obj
        for obj in objects
        if isinstance(obj.get("id"), str) and obj.get("type") != "session"
    }
    leaf_id = None
    for obj in objects:
        if obj.get("type") != "session" and isinstance(obj.get("id"), str):
            leaf_id = obj["id"]
    if not leaf_id:
        return objects

    active_ids = set()
    current = leaf_id
    while isinstance(current, str) and current and current not in active_ids:
        active_ids.add(current)
        parent = by_id.get(current, {}).get("parentId")
        current = parent if isinstance(parent, str) else None
    return [
        obj
        for obj in objects
        if obj.get("type") == "session" or obj.get("id") in active_ids
    ]


def _pi_context_objects(objects):
    """Return Pi entries that participate in active LLM context."""
    active = _pi_active_path_objects(objects)
    compactions = [obj for obj in active if obj.get("type") == "compaction"]
    if not compactions:
        return active

    first_kept = compactions[-1].get("firstKeptEntryId")
    if not isinstance(first_kept, str):
        return active

    latest_compaction_id = compactions[-1].get("id")
    started = False
    found_first_kept = False
    context = [obj for obj in active if obj.get("type") == "session"]
    context.append(compactions[-1])
    for obj in active:
        if obj.get("type") == "session":
            continue
        if obj.get("id") == first_kept:
            started = True
            found_first_kept = True
        if obj.get("id") == latest_compaction_id:
            continue
        if started:
            context.append(obj)
    return context if found_first_kept and len(context) > 1 else active


def handle_pi(obj):
    if obj.get("type") != "message":
        return
    msg = obj.get("message", {})
    if msg.get("role") == "bashExecution":
        exit_code = msg.get("exitCode")
        if exit_code in (None, 0) and not msg.get("cancelled"):
            return
        ts = obj.get("timestamp", "")[:19]
        command = msg.get("command", "")
        output = msg.get("output", "")
        summary = summarize_error(output)
        status = "cancelled" if msg.get("cancelled") else f"exit={exit_code}"
        print(f"[{ts}] [error] {status} cmd={command[:120]}: {summary}")
        print("---")
        stats["errors_found"] += 1
        return

    if msg.get("role") != "toolResult":
        return
    content = msg.get("content", [])
    is_error = bool(msg.get("isError"))
    if isinstance(content, list):
        is_error = is_error or any(
            isinstance(block, dict) and block.get("type") == "toolError"
            for block in content
        )
    if not is_error:
        return

    ts = obj.get("timestamp", "")[:19]
    tool = msg.get("toolName", "unknown")
    summary = _pi_content_summary(content)
    print(f"[{ts}] [error] tool={tool}: {summary}")
    print("---")
    stats["errors_found"] += 1


# Auto-detect platform before processing all input. Codex sessions may start
# with an arbitrary number of metadata records, so a fixed probe window makes
# a valid session silently look like a no-op.
detected = None
buffer = []

for raw_line in sys.stdin.buffer:
    try:
        line = raw_line.decode("utf-8").strip()
    except UnicodeDecodeError:
        stats["parse_errors"] += 1
        continue
    if not line:
        continue
    buffer.append(line)
    stats["lines"] += 1

    if not detected:
        obj = json_object(line)
        if obj is not None:
            if obj.get("type") == "session" and "cwd" in obj:
                detected = "pi"
            elif obj.get("type") in ("user", "assistant"):
                detected = "claude"
            elif obj.get("type") in ("session_meta", "turn_context", "response_item", "event_msg"):
                detected = "codex"
            elif obj.get("role") in ("user", "assistant") and "type" not in obj:
                detected = "cursor"

# Cursor transcripts don't log tool results — no errors to extract
def handle_noop(obj):
    pass

handlers = {"claude": handle_claude, "codex": handle_codex, "cursor": handle_noop, "pi": handle_pi}
handler = handlers.get(detected, handle_noop)

objects = []
for line in buffer:
    obj = json_object(line)
    if obj is None:
        stats["parse_errors"] += 1
    else:
        objects.append(obj)

if detected == "pi":
    objects = _pi_context_objects(objects)

for obj in objects:
    try:
        handler(obj)
    except (AttributeError, KeyError, TypeError):
        stats["parse_errors"] += 1

print(json.dumps({"_meta": True, **stats}))

if args.output:
    body = sys.stdout.getvalue()
    sys.stdout = _original_stdout
    with open(args.output, "w", encoding="utf-8") as f:
        f.write(body)
    bytes_written = os.path.getsize(args.output)
    print(json.dumps({"_meta": True, "wrote": args.output, "bytes": bytes_written, **stats}))
