"""session_prompts.py — what the human typed, in order, with the clock, from a Claude Code transcript.

    python session_prompts.py <transcript.jsonl> --from "Build an API" --to "Make a video"
    python session_prompts.py <transcript.jsonl> --from "Build an API" --json build/prompts_ep22.json

For an episode about building without coding, the measurement is the human's part: how many
messages, how long they are, how far apart, and whether any of them is code. The transcript is
the record. Two kinds of record hold the human's words: a `user` turn, and a `queue-operation`
enqueue (a message sent while the assistant was still working). Harness notices (usage limits,
continuation summaries, task notifications, `!` shell commands, a subagent's report handed
back, a slash command such as /model, the note that goes with an image) are not the human's
words to the agent and are dropped. Secrets the human pasted (API keys, tokens) are redacted
before anything is printed.

One limit to know: a queued message is recorded twice, minutes apart, so the same words seen
again within 30 minutes are counted once. Someone who really typed "continue" twice in that
time is counted once too.
"""
import argparse
import datetime as dt
import json
import re
import sys
from pathlib import Path

SECRET = re.compile(r"(X-RapidAPI-Key:\s*)\S+|(ukey=)\S+|\bsk-[A-Za-z0-9]{20,}|\b[0-9a-f]{32,}\b")
CODE_LINE = re.compile(r"^\s*(def |class |import |from \S+ import |#!/|<\?php)", re.M)
NOT_HUMAN = ("<system-reminder", "[Request interrupted", "<local-command", "<bash-input>", "<bash-stdout>",
             "[Usage limit", "[Earlier usage-limit", "Your claude.ai usage limit", "This session is being continued",
             "<task-notification>", "<agent-message", "<command-name>", "<command-message>", "[Image:")


def redact(text: str) -> str:
    return SECRET.sub(lambda m: (m.group(1) or m.group(2) or "") + "<redacted>", text)


def human_text(rec: dict) -> str | None:
    """The human's words in one transcript record, or None if the record is not theirs."""
    if rec.get("type") == "queue-operation":
        return rec.get("content") if rec.get("operation") == "enqueue" else None
    if rec.get("type") != "user":
        return None
    content = (rec.get("message") or {}).get("content")
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        if any(c.get("type") == "tool_result" for c in content):
            return None
        return "\n".join(c.get("text", "") for c in content if c.get("type") == "text")
    return None


def user_messages(path: Path) -> list[dict]:
    found, seen = [], {}
    for line in path.read_text(encoding="utf-8").splitlines():
        try:
            rec = json.loads(line)
        except json.JSONDecodeError:
            continue
        text = human_text(rec)
        if not text:
            continue
        text = text.strip()
        if not text or text.startswith(NOT_HUMAN):
            continue
        key = " ".join(text.split())[:200]
        stamp = rec.get("timestamp") or ""
        if key in seen and abs(epoch(stamp) - epoch(seen[key])) < 1800:
            continue                          # a queued message can also appear as a user turn, minutes later
        seen[key] = stamp
        found.append({"time": stamp, "text": redact(text)})
    found.sort(key=lambda m: m.get("time") or "")
    return found


def epoch(stamp: str) -> float:
    try:
        return dt.datetime.fromisoformat(stamp.replace("Z", "+00:00")).timestamp()
    except ValueError:
        return 0.0


def between(messages: list[dict], start: str | None, end: str | None) -> list[dict]:
    i = next((k for k, m in enumerate(messages) if start and start in m["text"]), 0 if not start else None)
    if i is None:
        sys.exit(f"no message contains {start!r}")
    j = next((k for k, m in enumerate(messages) if end and end in m["text"] and k > i), len(messages))
    return messages[i:j]


def local(stamp: str | None, fmt: str) -> str:
    if not stamp:
        return "--:--"
    return dt.datetime.fromisoformat(stamp.replace("Z", "+00:00")).astimezone().strftime(fmt)


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    ap = argparse.ArgumentParser()
    ap.add_argument("transcript", type=Path)
    ap.add_argument("--from", dest="start", help="text of the first message to include")
    ap.add_argument("--to", dest="end", help="text of the first message to exclude, after --from")
    ap.add_argument("--json", type=Path, help="also write the messages here")
    args = ap.parse_args()
    msgs = between(user_messages(args.transcript), args.start, args.end)
    times = [m["time"] for m in msgs if m.get("time")]
    words = sum(len(m["text"].split()) for m in msgs)
    code = sum(1 for m in msgs if CODE_LINE.search(m["text"]))
    print(f"{len(msgs)} messages, {words} words, {code} containing code")
    if times:
        first = dt.datetime.fromisoformat(times[0].replace("Z", "+00:00"))
        last = dt.datetime.fromisoformat(times[-1].replace("Z", "+00:00"))
        print(f"first {local(times[0], '%Y-%m-%d %H:%M')} local, last {local(times[-1], '%H:%M')}, "
              f"span {(last - first).total_seconds() / 3600:.1f} h")
    for k, m in enumerate(msgs, 1):
        one = " ".join(m["text"].split())
        print(f"{k:3d}. {local(m.get('time'), '%H:%M')}  {one[:140]}{'…' if len(one) > 140 else ''}")
    if args.json:
        args.json.parent.mkdir(parents=True, exist_ok=True)
        args.json.write_text(json.dumps({"transcript": "a Claude Code session transcript (.jsonl)", "from": args.start, "to": args.end,
                                         "count": len(msgs), "words": words, "with_code": code,
                                         "messages": msgs}, ensure_ascii=False, indent=1), encoding="utf-8")
        print(f"-> {args.json}")


if __name__ == "__main__":
    main()
