"""parsing for claude code session transcripts.

each session is a jsonl file under ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl.
user-role records are a grab bag: typed prompts, tool results, slash-command
expansions, bash passthroughs, and interrupt markers all share `type: "user"`.
this module separates what a human actually typed from harness noise — and
mines the rest of the record stream (titles, pr links, models, usage, tics)
in the same single pass.
"""

from __future__ import annotations

import json
import os
import re
from collections import Counter
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any


def default_claude_dir() -> Path:
    """where claude code keeps its data: $CLAUDE_CONFIG_DIR, else ~/.claude."""
    return Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")


DEFAULT_CLAUDE_DIR = default_claude_dir()

# substrings that mark a user-role record as harness traffic, not a typed prompt
NOISE_MARKERS = (
    "<command-name>",
    "<command-message>",
    "<bash-input>",
    "<bash-stdout>",
    "<local-command-stdout>",
    "<task-notification>",
    "<system-reminder>",
    "[Request interrupted",
    "Caveat: the messages below",
)

# raw tokenization — no stop words, no curation. true observations only.
WORD_RE = re.compile(r"[a-z']+")


def tokenize(text: str) -> list[str]:
    return WORD_RE.findall(text.lower())


PHRASE_LENGTHS = (2, 3, 4)


def count_phrases(tokens: list[str], into: dict[int, Counter[str]]) -> None:
    """count every 2-, 3-, and 4-word sequence, verbatim."""
    for n in PHRASE_LENGTHS:
        counter = into[n]
        for i in range(len(tokens) - n + 1):
            counter[" ".join(tokens[i : i + n])] += 1


def _phrase_dict() -> dict[int, Counter[str]]:
    return {n: Counter() for n in PHRASE_LENGTHS}


IMAGE_RE = re.compile(r"\[Image #\d+\]")
SLASH_RE = re.compile(r"<command-name>(\S+?)</command-name>")

# gaps longer than this don't count toward active time (you walked away)
IDLE_CAP_SECONDS = 300


@dataclass(slots=True)
class Prompt:
    text: str
    project: str
    session_id: str
    timestamp: datetime | None


@dataclass(slots=True)
class Session:
    path: Path
    session_id: str
    project: str
    title: str = ""
    prompts: list[Prompt] = field(default_factory=list)
    interrupts: int = 0
    tool_calls: Counter[str] = field(default_factory=Counter)
    output_tokens: int = 0
    input_tokens: int = 0
    cache_read_tokens: int = 0
    models: Counter[str] = field(default_factory=Counter)
    your_words: Counter[str] = field(default_factory=Counter)
    claude_words: Counter[str] = field(default_factory=Counter)
    your_phrases: dict[int, Counter[str]] = field(default_factory=_phrase_dict)
    claude_phrases: dict[int, Counter[str]] = field(default_factory=_phrase_dict)
    slash_commands: Counter[str] = field(default_factory=Counter)
    pr_urls: set[str] = field(default_factory=set)
    images: int = 0
    first_ts: datetime | None = None
    last_ts: datetime | None = None
    active_seconds: float = 0.0


def _parse_timestamp(raw: str | None) -> datetime | None:
    if not raw:
        return None
    try:
        return datetime.fromisoformat(raw.replace("Z", "+00:00"))
    except ValueError:
        return None


def _flatten_text(content: Any) -> str | None:
    """collapse message content to text, or None if there is none (e.g. pure tool_result)."""
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        texts = [
            block.get("text", "")
            for block in content
            if isinstance(block, dict) and block.get("type") == "text"
        ]
        if texts:
            return "\n".join(texts)
    return None


def _shorten_home(cwd: str) -> str:
    home = str(Path.home())
    if cwd == home:
        return "~"
    return cwd.removeprefix(home + "/")


def session_files(claude_dir: Path) -> list[Path]:
    """top-level session transcripts. subdirectories hold subagent sidechains."""
    return sorted(claude_dir.glob("projects/*/*.jsonl"))


def parse_session(path: Path, since: datetime | None = None) -> Session:
    session = Session(path=path, session_id=path.stem, project="?")

    with path.open() as f:
        for line in f:
            try:
                record = json.loads(line)
            except json.JSONDecodeError:
                continue
            if record.get("isSidechain"):
                continue

            kind = record.get("type")
            if kind == "ai-title":
                session.title = record.get("aiTitle") or session.title
                continue
            if kind == "pr-link":
                if url := record.get("prUrl"):
                    session.pr_urls.add(url)
                continue

            timestamp = _parse_timestamp(record.get("timestamp"))
            if since and timestamp and timestamp < since:
                continue
            if timestamp:
                if session.first_ts is None:
                    session.first_ts = timestamp
                elif session.last_ts is not None:
                    gap = (timestamp - session.last_ts).total_seconds()
                    if 0 < gap <= IDLE_CAP_SECONDS:
                        session.active_seconds += gap
                session.last_ts = timestamp
            if session.project == "?" and record.get("cwd"):
                session.project = _shorten_home(record["cwd"])

            message = record.get("message") or {}
            if kind == "assistant":
                if model := message.get("model"):
                    if not model.startswith("<"):  # `<synthetic>` placeholder rows
                        session.models[model] += 1
                for block in message.get("content") or []:
                    if not isinstance(block, dict):
                        continue
                    if block.get("type") == "tool_use":
                        session.tool_calls[block.get("name", "?")] += 1
                    elif block.get("type") == "text":
                        tokens = tokenize(block["text"])
                        session.claude_words.update(tokens)
                        count_phrases(tokens, session.claude_phrases)
                usage = message.get("usage") or {}
                session.output_tokens += usage.get("output_tokens", 0)
                session.input_tokens += usage.get("input_tokens", 0)
                session.cache_read_tokens += usage.get("cache_read_input_tokens", 0)
            elif kind == "user":
                text = _flatten_text(message.get("content"))
                if text is None:
                    continue
                if "[Request interrupted" in text:
                    session.interrupts += 1
                    continue
                if match := SLASH_RE.search(text):
                    session.slash_commands[match.group(1)] += 1
                    continue
                if record.get("isMeta") or any(m in text for m in NOISE_MARKERS):
                    continue
                text = text.strip()
                if text:
                    session.images += len(IMAGE_RE.findall(text))
                    tokens = tokenize(text)
                    session.your_words.update(tokens)
                    count_phrases(tokens, session.your_phrases)
                    session.prompts.append(
                        Prompt(
                            text=text,
                            project=session.project,
                            session_id=record.get("sessionId", session.session_id),
                            timestamp=timestamp,
                        )
                    )

    return session


def load_sessions(
    claude_dir: Path = DEFAULT_CLAUDE_DIR, days: int | None = None
) -> list[Session]:
    since = datetime.now(UTC) - timedelta(days=days) if days is not None else None
    sessions = [parse_session(path, since=since) for path in session_files(claude_dir)]
    return [s for s in sessions if s.prompts or s.tool_calls or s.interrupts]
