"""Claude Code JSONL format parser.

Parses ~/.claude/history.jsonl for session indices and individual
session .jsonl files for full conversation data, including subagent
conversations stored in {session-id}/subagents/ directories.

Claude Code stores each conversation event as a separate JSONL line with
a top-level ``type`` field (``"user"`` or ``"assistant"``).  Tool use
follows the Anthropic Messages API convention: tool invocations appear
as ``tool_use`` content blocks inside assistant messages, while their
results come back as ``tool_result`` blocks inside the *next* user
message, linked by ``tool_use_id``.  This two-message pairing requires
a pre-scan to build the result map before constructing ToolCall objects.
"""

import json
import re
from collections import Counter
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, NamedTuple
from uuid import uuid4

from vibelens.ingest.diagnostics import DiagnosticsCollector
from vibelens.ingest.parsers.base import (
    ROLE_TO_SOURCE,
    BaseParser,
    _is_meaningful_prompt,
    mark_error_content,
)
from vibelens.models.enums import ContentType, StepSource
from vibelens.models.trajectories import (
    Agent,
    FinalMetrics,
    Metrics,
    Observation,
    ObservationResult,
    Step,
    ToolCall,
    Trajectory,
    TrajectoryRef,
)
from vibelens.models.trajectories.content import Base64Source, ContentPart
from vibelens.models.trajectories.trajectory import DEFAULT_ATIF_VERSION
from vibelens.utils import coerce_to_string, get_logger, normalize_timestamp

# Sentinel for sorting steps that lack timestamps — placed before all
# real timestamps so they don't disrupt chronological ordering.
_EPOCH_MIN = datetime.min.replace(tzinfo=UTC)

logger = get_logger(__name__)

# Only "user" and "assistant" carry conversation content.
# Other types (e.g. "result", "progress") are internal bookkeeping.
RELEVANT_TYPES = {"user", "assistant"}

# Number of lines to probe for project path extraction
PROJECT_PATH_PROBE_LIMIT = 10

# Tool names that spawn sub-agent JSONL files in subagents/ directory.
# Claude Code renamed "Agent" to "Task" in later versions.
_SUBAGENT_TOOL_NAMES = {"Agent", "Task"}

# Prefix for compaction sub-agents — system-internal context compaction,
# not spawned via Agent/Task tool_use, so parent linkage never exists.
COMPACTION_AGENT_PREFIX = "acompact-"

# Pattern to extract agentId from Task/Agent tool_result content.
# Claude Code embeds "agentId: {hex_hash}" in the tool output text.
_AGENT_ID_PATTERN = re.compile(r"agentId:\s*([a-f0-9]+)")

# Pattern to find the external file path in <persisted-output> blocks.
# Claude Code saves tool results >100KB to external .txt files.
_PERSISTED_PATH_PATTERN = re.compile(r"Full output saved to: (.+?)(?:\n|$)")


def _read_persisted_agent_id(content: str) -> str:
    """Read a persisted tool output file to extract agentId.

    When tool_result content contains a <persisted-output> pointer,
    read the referenced file and return its tail (agentId is near the end).
    Returns original content if the file cannot be read.
    """
    match = _PERSISTED_PATH_PATTERN.search(content)
    if not match:
        return content
    path = Path(match.group(1).strip())
    if not path.is_file():
        return content
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
        # agentId is near the end of Agent tool output
        TAIL_SIZE = 2000
        return text[-TAIL_SIZE:] if len(text) > TAIL_SIZE else text
    except OSError:
        return content

# XML tags injected by the system into user message content.
# Their presence means the "user" entry is actually system-generated.
_SYSTEM_XML_TAGS = frozenset(
    {
        "system-reminder",
        "local-command-caveat",
        "local-command-stdout",
        "command-message",
        "task-notification",
        "user-prompt-submit-hook",
        "command-name",
    }
)

_SYSTEM_TAG_PATTERN = re.compile(
    r"^\s*<(" + "|".join(re.escape(t) for t in _SYSTEM_XML_TAGS) + r")[\s>]"
)

# Plain-text prefixes that indicate system-injected content
_SYSTEM_PREFIXES = ("[Request interrupted", "This session is being continued")

# Skill output injected after a Skill tool_use
_SKILL_PREFIX = "Base directory for this skill:"


class _SessionMeta(NamedTuple):
    """Aggregated metadata from a single pass over raw JSONL content."""

    session_id: str | None
    last_session_id: str | None
    model_name: str | None
    version: str | None
    project_path: str | None
    git_branches: list[str] | None


class ClaudeCodeParser(BaseParser):
    """Parser for Claude Code's native JSONL format.

    Handles both the history index (history.jsonl) and individual
    session files, including subagent conversations.
    """

    AGENT_NAME = "claude-code"
    LOCAL_DATA_DIR: Path | None = Path.home() / ".claude"

    def parse(self, content: str, source_path: str | None = None) -> list[Trajectory]:
        """Parse JSONL session content into Trajectory objects.

        Returns a list containing the main session trajectory and any
        sub-agent trajectories. Sub-agents are separate Trajectory objects
        with parent_trajectory_ref pointing back to the main session.

        Args:
            content: Raw JSONL content string.
            source_path: Original file path for sub-agent file discovery.

        Returns:
            List of Trajectory objects (main + sub-agents).
        """
        collector = DiagnosticsCollector()
        meta = _scan_session_metadata(content)
        project_path = meta.project_path
        git_branches = meta.git_branches
        session_id = meta.session_id
        last_session_id = meta.last_session_id
        model_name = meta.model_name
        version = meta.version

        # Use extracted session_id, fallback to filename stem, then UUID
        if not session_id:
            session_id = Path(source_path).stem if source_path else str(uuid4())

        steps = self._parse_content(content, diagnostics=collector, session_id=session_id)
        if not steps:
            if collector.parsed_lines == 0 and collector.total_lines > 0:
                source = source_path or "unknown"
                raise ValueError(
                    f"No parseable entries in {source}"
                    f" ({collector.total_lines} lines, {collector.skipped_lines} skipped)"
                )
            return []

        agent = self.build_agent(version=version, model=model_name)

        has_diagnostics_issues = (
            collector.skipped_lines > 0
            or collector.orphaned_tool_calls > 0
            or collector.orphaned_tool_results > 0
        )
        extra: dict | None = None
        if has_diagnostics_issues:
            extra = {"diagnostics": collector.to_diagnostics().model_dump()}

        if git_branches:
            extra = extra or {}
            extra["git_branches"] = git_branches

        # Sub-agent trajectories require filesystem access via source_path
        sub_trajectories: list[Trajectory] = []
        if source_path:
            sub_trajectories = self._parse_subagent_trajectories(
                Path(source_path), content, steps, session_id
            )
            if sub_trajectories:
                extra = extra or {}
                extra["sub_agent_count"] = len(sub_trajectories)

        last_trajectory_ref = TrajectoryRef(session_id=last_session_id) if last_session_id else None
        main_trajectory = self.assemble_trajectory(
            session_id=session_id,
            agent=agent,
            steps=steps,
            project_path=project_path,
            last_trajectory_ref=last_trajectory_ref,
            extra=extra,
        )

        if sub_trajectories:
            _validate_subagent_linkage(main_trajectory, sub_trajectories)

        return [main_trajectory, *sub_trajectories]

    def parse_history_index(
        self, claude_dir: Path, since: datetime | None = None, limit: int | None = None
    ) -> list[Trajectory]:
        """Parse history.jsonl to build lightweight skeleton Trajectory objects.

        Groups entries by sessionId, extracts project name, first message,
        timestamp, and step count per session. These are skeleton
        trajectories for listing — full parse happens on get_session().

        Args:
            claude_dir: Path to ~/.claude directory.
            since: Only include sessions with activity at or after this time.
            limit: Maximum number of sessions to return (after sorting).

        Returns:
            List of skeleton Trajectory objects sorted by timestamp descending.
        """
        history_file = claude_dir / "history.jsonl"
        if not history_file.exists():
            logger.warning("history.jsonl not found at %s", history_file)
            return []

        since_ms = int(since.timestamp() * 1000) if since else 0
        sessions = _aggregate_history_lines(history_file, since_ms)

        trajectories = []
        for session_id, data in sessions.items():
            project_path = data["project_path"] or None
            first_message = self.truncate_first_message(data["first_message"]) or None
            timestamp = datetime.fromtimestamp(data["last_timestamp"] / 1000, tz=UTC)

            # Skeleton step so Trajectory validation passes (min_length=1)
            skeleton_step = Step(
                step_id="index-0",
                source=StepSource.USER,
                message=first_message or "",
                timestamp=timestamp,
            )

            # Build trajectory directly — skeleton data should not trigger
            # derived field computation from assemble_trajectory
            trajectories.append(
                Trajectory(
                    schema_version=DEFAULT_ATIF_VERSION,
                    session_id=session_id,
                    project_path=project_path,
                    first_message=first_message,
                    agent=Agent(name=self.AGENT_NAME),
                    steps=[skeleton_step],
                    final_metrics=FinalMetrics(total_steps=data["message_count"]),
                    extra={"is_skeleton": True, "total_entries": data["message_count"]},
                )
            )

        trajectories.sort(key=lambda t: t.steps[0].timestamp or _EPOCH_MIN, reverse=True)
        if limit is not None:
            trajectories = trajectories[:limit]
        return trajectories

    def parse_session_jsonl(self, file_path: Path) -> list[Step]:
        """Parse a session .jsonl file into main-session steps only.

        Args:
            file_path: Path to the session .jsonl file.

        Returns:
            List of Step objects for the main session (no sub-agents).
        """
        try:
            content = file_path.read_text(encoding="utf-8")
        except OSError:
            logger.warning("Cannot read file: %s", file_path)
            return []
        return self._parse_content(content)

    def _parse_subagent_trajectories(
        self, source_path: Path, raw_content: str, parent_steps: list[Step], parent_sid: str
    ) -> list[Trajectory]:
        """Parse sub-agent JSONL files into separate Trajectory objects.

        Each sub-agent trajectory has a parent_trajectory_ref pointing back
        to the parent session, and the parent step's observation is
        updated with a subagent_trajectory_ref.

        Matching uses agentId extracted from raw Task/Agent tool_result
        content, not positional ordering, to ensure correctness even
        when the bounded tool result cache evicts early entries.

        Args:
            source_path: Path to the main session .jsonl file.
            raw_content: Raw JSONL content of the main session.
            parent_steps: Parsed steps from the main session.
            parent_sid: Session ID of the parent.

        Returns:
            List of sub-agent Trajectory objects.
        """
        subagent_dir = source_path.parent / source_path.stem / "subagents"
        if not subagent_dir.is_dir():
            return []

        agent_files = sorted(subagent_dir.glob("agent-*.jsonl"))
        if not agent_files:
            return []

        # Build agentId → (step_id, tool_call_id) from raw JSONL
        spawn_map = _build_agent_spawn_map(raw_content, parent_steps)

        sub_trajectories: list[Trajectory] = []
        for agent_file in agent_files:
            agent_id = agent_file.stem.removeprefix("agent-")
            spawn_info = spawn_map.get(agent_id)
            spawn_step_id = spawn_info[0] if spawn_info else None
            spawn_tool_call_id = spawn_info[1] if spawn_info else None

            sub_traj = self._build_subagent_trajectory(
                agent_file, parent_sid, source_path, spawn_step_id, spawn_tool_call_id
            )
            if sub_traj:
                sub_trajectories.append(sub_traj)
                if spawn_tool_call_id:
                    _link_subagent_to_parent(parent_steps, spawn_tool_call_id, agent_file.stem)

        return sub_trajectories

    def _build_subagent_trajectory(
        self,
        agent_file: Path,
        parent_sid: str,
        source_path: Path,
        spawn_step_id: str | None,
        spawn_tool_call_id: str | None,
    ) -> Trajectory | None:
        """Parse a single sub-agent file and assemble its Trajectory.

        Args:
            agent_file: Path to the sub-agent .jsonl file.
            parent_sid: Session ID of the parent.
            source_path: Path to the parent session file.
            spawn_step_id: Step ID in parent that spawned this agent.
            spawn_tool_call_id: Tool call ID that spawned this agent.

        Returns:
            Trajectory for the sub-agent, or None if parsing fails.
        """
        try:
            sub_content = agent_file.read_text(encoding="utf-8")
        except OSError:
            logger.warning("Cannot read sub-agent file: %s", agent_file)
            return None

        sub_steps = self._parse_content(sub_content)
        if not sub_steps:
            return None

        sub_meta = _scan_session_metadata(sub_content)

        return self.assemble_trajectory(
            session_id=agent_file.stem,
            agent=self.build_agent(version=sub_meta.version, model=sub_meta.model_name),
            steps=sub_steps,
            project_path=sub_meta.project_path,
            parent_trajectory_ref=TrajectoryRef(
                session_id=parent_sid,
                step_id=spawn_step_id,
                tool_call_id=spawn_tool_call_id,
                trajectory_path=str(source_path),
            ),
        )

    @staticmethod
    def discover_subagent_only_sessions(project_dir: Path) -> list[Path]:
        """Find session dirs that have only subagent files and no root JSONL.

        Args:
            project_dir: Directory containing session subdirectories.

        Returns:
            List of subagent directory paths without a root JSONL file.
        """
        orphaned = []
        try:
            for subagent_dir in project_dir.glob("*/subagents"):
                if not subagent_dir.is_dir():
                    continue
                session_dir = subagent_dir.parent
                root_jsonl = session_dir.parent / f"{session_dir.name}.jsonl"
                if not root_jsonl.exists() and list(subagent_dir.glob("agent-*.jsonl")):
                    orphaned.append(subagent_dir)
        except OSError:
            pass
        return orphaned

    def _parse_content(
        self,
        content: str,
        diagnostics: DiagnosticsCollector | None = None,
        session_id: str | None = None,
    ) -> list[Step]:
        """Parse JSONL content string into Step objects.

        Merges consecutive assistant entries sharing the same message.id
        into a single step (streaming chunk consolidation), then converts
        each merged entry into an ATIF Step.

        Args:
            content: Raw JSONL content string.
            diagnostics: Optional collector for parse quality metrics.
            session_id: Main session ID for detecting copied context steps.

        Returns:
            List of Step objects.
        """
        raw_entries = _parse_jsonl_content(content, diagnostics)

        # Two-pass: first scan user messages for tool results,
        # then construct Steps with results already paired
        tool_results = _collect_tool_results(raw_entries)
        tool_use_ids: set[str] = set()

        # Merge assistant entries with the same message.id into single steps
        step_groups = _group_entries_by_step(raw_entries)

        steps = []
        for group in step_groups:
            entry = _merge_entry_group(group)
            msg = entry.get("message", {})
            timestamp = normalize_timestamp(entry.get("timestamp"))

            role = msg.get("role", entry.get("type", ""))
            source = ROLE_TO_SOURCE.get(role, StepSource.USER)
            model_name = msg.get("model") or None
            raw_content = msg.get("content", "")

            # Use message.id as step_id for assistant entries (stable across
            # streaming chunks); fall back to entry uuid for user entries.
            # Only assistant entries get message.id — user entries sharing the
            # same message.id (tool-relay pairs) must use their own uuid to
            # avoid duplicate step IDs.
            if source == StepSource.AGENT:
                step_id = msg.get("id") or entry.get("uuid", str(uuid4()))
            else:
                step_id = entry.get("uuid", str(uuid4()))

            metrics = _parse_metrics(msg.get("usage"))

            # Decompose Anthropic Messages API content blocks into
            # separated ATIF Step fields with pre-scanned tool results
            message, reasoning_content, tool_calls, observation = _decompose_raw_content(
                raw_content, tool_results
            )

            # Skip "tool-relay" user messages — entries containing ONLY
            # tool_result blocks with no human-authored text. Their content
            # is already injected into the preceding assistant step via the
            # pre-scan tool_results map (tool_use_id linkage).
            if source == StepSource.USER and not message and not tool_calls and observation is None:
                continue

            for tc in tool_calls:
                if tc.tool_call_id:
                    tool_use_ids.add(tc.tool_call_id)
                    if diagnostics:
                        diagnostics.record_tool_call()

            # Reclassify user messages that contain system-injected or
            # skill content so they get the correct source label and metadata.
            # Only classify string messages; multimodal (list) messages are
            # always genuine user content (e.g. pasted screenshots).
            extra_classify: dict[str, Any] | None = None
            if source == StepSource.USER and message and isinstance(message, str):
                source, extra_classify = classify_user_message(message, entry)

            # Detect steps copied from a previous session for context
            entry_session_id = entry.get("sessionId", "")
            is_copied = (
                session_id is not None and entry_session_id and entry_session_id != session_id
            )

            extra = _build_step_extra(entry)
            if extra_classify:
                extra = {**(extra or {}), **extra_classify}

            steps.append(
                Step(
                    step_id=step_id,
                    source=source,
                    message=message,
                    reasoning_content=reasoning_content,
                    model_name=model_name,
                    timestamp=timestamp,
                    metrics=metrics,
                    tool_calls=tool_calls,
                    observation=observation,
                    is_copied_context=True if is_copied else None,
                    extra=extra or None,
                )
            )

        if diagnostics:
            _detect_orphans(tool_use_ids, tool_results, diagnostics)

        return steps


def classify_user_message(
    text: str, entry: dict[str, Any]
) -> tuple[StepSource, dict[str, Any] | None]:
    """Classify a user-role message as real user, system, or skill content.

    Claude Code injects system content (XML tags, continuation notices) and
    skill output (after Skill tool_use) into user-type entries. This function
    detects those patterns and returns the correct source classification.

    Args:
        text: The text content of a user-type message.
        entry: Raw JSONL entry dict, used to extract sourceToolUseID for skills.

    Returns:
        Tuple of (source, extra_metadata). Extra is None for system/plain user,
        or {"is_skill_output": True, "sourceToolUseID": ...} for skill content.
    """
    if not text:
        return StepSource.USER, None

    stripped = text.lstrip()
    if stripped.startswith(_SKILL_PREFIX):
        extra: dict[str, Any] = {"is_skill_output": True}
        source_tool_id = entry.get("sourceToolUseID")
        if source_tool_id:
            extra["sourceToolUseID"] = source_tool_id
        return StepSource.USER, extra

    if _SYSTEM_TAG_PATTERN.match(stripped):
        return StepSource.SYSTEM, None

    for prefix in _SYSTEM_PREFIXES:
        if stripped.startswith(prefix):
            return StepSource.SYSTEM, None
    return StepSource.USER, None


def _group_entries_by_step(entries: list[dict]) -> list[list[dict]]:
    """Group entries by logical step using message.id for assistant entries.

    Assistant entries sharing a message.id are collected into a single group
    even when separated by user tool-relay entries (which become their own
    singleton groups). Groups are ordered by first appearance.

    Args:
        entries: Flat list of parsed JSONL entries.

    Returns:
        List of entry groups; each group becomes one Step.
    """
    groups: list[list[dict]] = []
    msg_id_to_group: dict[str, list[dict]] = {}

    for entry in entries:
        entry_type = entry.get("type")
        msg_id = entry.get("message", {}).get("id", "")

        if entry_type == "assistant" and msg_id:
            if msg_id in msg_id_to_group:
                msg_id_to_group[msg_id].append(entry)
            else:
                group: list[dict] = [entry]
                groups.append(group)
                msg_id_to_group[msg_id] = group
        else:
            groups.append([entry])

    return groups


def _merge_entry_group(group: list[dict]) -> dict:
    """Merge a group of streaming chunks into a single pseudo-entry.

    Concatenates message.content lists from all entries in the group.
    Uses the first entry's uuid, timestamp, and metadata as the base.

    Args:
        group: One or more JSONL entries sharing the same message.id.

    Returns:
        Single merged entry dict.
    """
    if len(group) == 1:
        return group[0]

    merged = dict(group[0])
    merged_msg = dict(merged.get("message", {}))

    # Concatenate content lists from all chunks
    all_content: list = []
    for entry in group:
        chunk_content = entry.get("message", {}).get("content", "")
        if isinstance(chunk_content, list):
            all_content.extend(chunk_content)
        elif chunk_content:
            all_content.append({"type": "text", "text": str(chunk_content)})

    merged_msg["content"] = all_content
    merged["message"] = merged_msg
    return merged


def _parse_jsonl_content(
    content: str, diagnostics: DiagnosticsCollector | None = None
) -> list[dict]:
    """Parse JSONL content string into relevant entry dicts.

    Filters for RELEVANT_TYPES, handles queue-operation events, and
    tracks parse quality via diagnostics.

    Queue-operation handling: when a user types while the assistant is
    still processing, Claude Code queues the message as an ``enqueue``
    event. If it's later delivered normally, a ``dequeue`` event appears
    followed by a regular ``type: "user"`` message. If instead it's
    injected as a system-reminder and removed, only ``enqueue`` +
    ``remove`` exist — no standalone user message is emitted. We create
    synthetic user entries for enqueue+remove pairs to preserve that
    user intent.

    Args:
        content: Raw JSONL content.
        diagnostics: Optional collector for tracking skipped lines.

    Returns:
        List of parsed dicts with relevant types only.
    """
    all_parsed: list[dict] = []
    for line in content.split("\n"):
        stripped = line.strip()
        if not stripped:
            continue
        if diagnostics:
            diagnostics.total_lines += 1
        try:
            entry = json.loads(stripped)
            if diagnostics:
                diagnostics.parsed_lines += 1
        except json.JSONDecodeError:
            if diagnostics:
                diagnostics.record_skip("invalid JSON")
            continue
        all_parsed.append(entry)

    # Pre-scan: identify enqueue timestamps that were later dequeued
    # (delivered as normal user messages). Only enqueue+remove pairs
    # need synthetic user entries.
    dequeued_timestamps = _collect_dequeued_timestamps(all_parsed)

    raw_entries: list[dict] = []
    for entry in all_parsed:
        entry_type = entry.get("type")
        if entry_type in RELEVANT_TYPES:
            raw_entries.append(entry)
            continue
        if (
            entry_type == "queue-operation"
            and entry.get("operation") == "enqueue"
            and entry.get("content")
            and entry.get("timestamp", "") not in dequeued_timestamps
        ):
            raw_entries.append(_make_enqueue_user_entry(entry))
    return raw_entries


def _collect_dequeued_timestamps(all_parsed: list[dict]) -> set[str]:
    """Collect timestamps of enqueue events that were later dequeued.

    Dequeued messages are delivered as normal ``type: "user"`` entries,
    so creating a synthetic user entry would duplicate them.

    Args:
        all_parsed: All parsed JSONL entries (unfiltered).

    Returns:
        Set of timestamp strings for enqueue events followed by dequeue.
    """
    dequeued: set[str] = set()
    for entry in all_parsed:
        if entry.get("type") == "queue-operation" and entry.get("operation") == "dequeue":
            ts = entry.get("timestamp", "")
            if ts:
                dequeued.add(ts)
    return dequeued


def _make_enqueue_user_entry(entry: dict) -> dict:
    """Transform a queue-operation enqueue event into a synthetic user entry.

    When a user types a message while the assistant is still processing,
    Claude Code queues it as an enqueue event. If the message is later
    removed (not dequeued), no standalone user message exists — the
    enqueue is the only record of the user's input.

    Args:
        entry: Raw queue-operation JSONL entry with operation="enqueue".

    Returns:
        Synthetic user entry compatible with _parse_content() processing.
    """
    ts = entry.get("timestamp", "")
    unique_id = f"enqueue-{ts}-{uuid4().hex[:8]}" if ts else f"enqueue-{uuid4()}"
    return {
        "type": "user",
        "uuid": unique_id,
        "sessionId": entry.get("sessionId", ""),
        "timestamp": entry.get("timestamp"),
        "message": {"role": "user", "content": entry["content"]},
        "_queue_operation": "enqueue",
    }


def _build_step_extra(entry: dict) -> dict[str, Any] | None:
    """Build step-level extra dict from Claude Code entry fields.

    Extracts format-specific metadata mirroring Harbor's convention:
    is_sidechain, stop_reason, stop_sequence, cwd, request_id,
    service_tier, and user_type.

    Args:
        entry: Raw JSONL entry dict.

    Returns:
        Extra dict with format-specific fields, or None if empty.
    """
    extra: dict[str, Any] = {}
    msg = entry.get("message", {})
    if not isinstance(msg, dict):
        msg = {}

    if entry.get("_queue_operation"):
        extra["is_queued_prompt"] = True

    if entry.get("isSidechain", False):
        extra["is_sidechain"] = True

    if msg.get("stop_reason") is not None:
        extra["stop_reason"] = msg["stop_reason"]

    if msg.get("stop_sequence") is not None:
        extra["stop_sequence"] = msg["stop_sequence"]

    if entry.get("cwd"):
        extra["cwd"] = entry["cwd"]

    if msg.get("requestId"):
        extra["request_id"] = msg["requestId"]

    if msg.get("service_tier"):
        extra["service_tier"] = msg["service_tier"]

    if entry.get("userType") and entry["userType"] != "external":
        extra["user_type"] = entry["userType"]

    return extra or None


def _scan_session_metadata(content: str) -> _SessionMeta:
    """Extract all session-level metadata in a single JSONL pass.

    Collects sessionId, model, version, cwd (project path), and
    gitBranch from raw JSONL entries. Project path probing respects
    PROJECT_PATH_PROBE_LIMIT for consistency with the original behavior.

    Args:
        content: Raw JSONL content string.

    Returns:
        _SessionMeta with all extracted fields.
    """
    session_counter: Counter[str] = Counter()
    model_counter: Counter[str] = Counter()
    version: str | None = None
    cwd_values: list[str] = []
    branches: set[str] = set()

    for line in content.split("\n"):
        stripped = line.strip()
        if not stripped:
            continue
        try:
            entry = json.loads(stripped)
        except json.JSONDecodeError:
            continue

        sid = entry.get("sessionId", "")
        if sid:
            session_counter[sid] += 1

        if version is None:
            raw_version = entry.get("version", "")
            if raw_version:
                version = str(raw_version)

        # Project path: only probe first N entries that have cwd
        if len(cwd_values) < PROJECT_PATH_PROBE_LIMIT:
            cwd = entry.get("cwd", "")
            if cwd:
                cwd_values.append(cwd)

        branch = entry.get("gitBranch", "")
        if branch:
            branches.add(branch)

        msg = entry.get("message", {})
        if isinstance(msg, dict):
            model = msg.get("model", "")
            if model and not model.startswith("<"):
                model_counter[model] += 1

    session_id = session_counter.most_common(1)[0][0] if session_counter else None
    last_session_id = None
    for sid in session_counter:
        if sid != session_id:
            last_session_id = sid
            break

    model_name = model_counter.most_common(1)[0][0] if model_counter else None
    project_path = Counter(cwd_values).most_common(1)[0][0] if cwd_values else None
    git_branches = sorted(branches) if branches else None

    return _SessionMeta(
        session_id=session_id,
        last_session_id=last_session_id,
        model_name=model_name,
        version=version,
        project_path=project_path,
        git_branches=git_branches,
    )


def _extract_git_branches(content: str) -> list[str] | None:
    """Extract unique git branch names from JSONL entries.

    Thin wrapper around _scan_session_metadata for backward compatibility
    with tests that import this function directly.

    Args:
        content: Raw JSONL content string.

    Returns:
        Sorted list of branch names, or None if none found.
    """
    return _scan_session_metadata(content).git_branches


def _decompose_raw_content(
    raw_content: str | list, tool_results: dict[str, dict] | None = None
) -> tuple[str, str | None, list[ToolCall], Observation | None]:
    """Decompose Anthropic Messages API content into separated Step fields.

    Converts the polymorphic content block array from Claude Code API
    responses into separated ATIF Step fields: message (text),
    reasoning_content (thinking), tool_calls, and observation.

    When tool_results is provided, injects matching tool results from
    the pre-scan map to produce proper Observation objects.

    Args:
        raw_content: Raw content from JSONL entry (str or list of dicts).
        tool_results: Optional pre-scanned tool_use_id -> result mapping.

    Returns:
        Tuple of (message, reasoning_content, tool_calls, observation).
    """
    if isinstance(raw_content, str):
        stripped = raw_content.strip()
        return (stripped, None, [], None) if stripped else ("", None, [], None)

    if not isinstance(raw_content, list):
        return ("", None, [], None)

    text_parts: list[str] = []
    image_parts: list[ContentPart] = []
    thinking_parts: list[str] = []
    tool_calls: list[ToolCall] = []
    obs_results: list[ObservationResult] = []
    tool_results = tool_results or {}

    for block in raw_content:
        if not isinstance(block, dict):
            continue
        block_type = block.get("type", "text")

        if block_type == "text":
            text = block.get("text", "")
            if text:
                text_parts.append(text)

        elif block_type == "image":
            source = block.get("source", {})
            if isinstance(source, dict) and source.get("type") == "base64":
                image_parts.append(
                    ContentPart(
                        type=ContentType.IMAGE,
                        source=Base64Source(
                            media_type=source.get("media_type", "image/png"),
                            base64=source.get("data", ""),
                        ),
                    )
                )

        elif block_type == "thinking":
            thinking = block.get("thinking", "")
            if thinking:
                thinking_parts.append(thinking)

        elif block_type == "tool_use":
            tool_call_id = block.get("id", "")
            tool_calls.append(
                ToolCall(
                    tool_call_id=tool_call_id,
                    function_name=block.get("name", ""),
                    arguments=block.get("input"),
                )
            )
            # Inject pre-scanned tool result if available
            result = tool_results.get(tool_call_id) if tool_call_id else None
            if result:
                raw_output = result.get("output")
                obs_content = _extract_tool_result_content(raw_output)
                if result.get("is_error", False) and isinstance(obs_content, str):
                    obs_content = mark_error_content(obs_content)
                obs_results.append(
                    ObservationResult(
                        source_call_id=tool_call_id,
                        content=obs_content,
                        extra=_extract_tool_result_metadata(result),
                    )
                )

        elif block_type == "tool_result":
            # When pre-scan results are available, tool_result blocks are
            # already captured via the tool_use branch above. Skip direct
            # processing to prevent duplicate ObservationResults.
            if tool_results:
                continue
            result_content = _extract_tool_result_content(block.get("content"))
            if block.get("is_error"):
                result_content = mark_error_content(result_content)
            obs_results.append(
                ObservationResult(
                    source_call_id=block.get("tool_use_id", ""),
                    content=result_content,
                )
            )

    # Build message: use list[ContentPart] when images are present
    if image_parts:
        content_parts: list[ContentPart] = []
        joined_text = "\n\n".join(text_parts).strip()
        if joined_text:
            content_parts.append(ContentPart(type=ContentType.TEXT, text=joined_text))
        content_parts.extend(image_parts)
        message: str | list[ContentPart] = content_parts
    else:
        message = "\n\n".join(text_parts).strip() if text_parts else ""

    reasoning_content = "\n\n".join(thinking_parts).strip() if thinking_parts else None
    observation = Observation(results=obs_results) if obs_results else None

    return message, reasoning_content, tool_calls, observation


def _extract_tool_result_content(
    content: str | list | None,
) -> str | list[ContentPart] | None:
    """Extract text (and optionally images) from a tool_result content field.

    When the content list contains image blocks, returns a list[ContentPart]
    combining text and image parts. Otherwise returns plain text string.

    Args:
        content: Raw content from a tool_result block (str, list, or None).

    Returns:
        Extracted content as str, list[ContentPart], or None if empty.
    """
    if content is None:
        return None
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        text_parts: list[str] = []
        image_parts: list[ContentPart] = []
        for item in content:
            if isinstance(item, str):
                text_parts.append(item)
            elif isinstance(item, dict):
                if item.get("type") == "image":
                    source = item.get("source", {})
                    if isinstance(source, dict) and source.get("type") == "base64":
                        image_parts.append(
                            ContentPart(
                                type=ContentType.IMAGE,
                                source=Base64Source(
                                    media_type=source.get("media_type", "image/png"),
                                    base64=source.get("data", ""),
                                ),
                            )
                        )
                elif "text" in item:
                    text_parts.append(str(item["text"]))
        if image_parts:
            parts: list[ContentPart] = []
            joined = "\n".join(text_parts)
            if joined:
                parts.append(ContentPart(type=ContentType.TEXT, text=joined))
            parts.extend(image_parts)
            return parts
        return "\n".join(text_parts) if text_parts else None
    return str(content)


def _extract_tool_result_metadata(result: dict) -> dict[str, Any] | None:
    """Extract structured execution metadata from a cached tool result.

    When the tool result cache contains a ``tool_use_result`` dict (captured
    from the event-level ``toolUseResult`` field), extracts salient fields:
    exit_code, stdout, stderr, and interrupted.

    Args:
        result: A single entry from the tool_results cache dict.

    Returns:
        Metadata dict, or None if no structured metadata is available.
    """
    tur = result.get("tool_use_result")
    if not isinstance(tur, dict):
        return None

    meta: dict[str, Any] = {}
    exit_code = tur.get("exitCode")
    if exit_code is None:
        exit_code = tur.get("exit_code")
    if exit_code is not None:
        meta["exit_code"] = exit_code

    stdout = tur.get("stdout")
    if isinstance(stdout, str):
        meta["stdout"] = stdout

    stderr = tur.get("stderr")
    if isinstance(stderr, str):
        meta["stderr"] = stderr

    if tur.get("interrupted"):
        meta["interrupted"] = True

    return meta or None


def count_history_entries(claude_dir: Path) -> int:
    """Count lines in history.jsonl with O(1) memory via buffered byte reads.

    Args:
        claude_dir: Path to ~/.claude directory.

    Returns:
        Number of non-empty lines in history.jsonl, or 0 if missing.
    """
    history_file = claude_dir / "history.jsonl"
    if not history_file.exists():
        return 0

    count = 0
    buf_size = 65536
    try:
        with open(history_file, "rb") as f:
            while True:
                buf = f.read(buf_size)
                if not buf:
                    break
                count += buf.count(b"\n")
    except OSError:
        return 0
    return count


def _aggregate_history_lines(history_file: Path, since_ms: int) -> dict[str, dict]:
    """Read history.jsonl and group entries by session.

    Skips entries whose timestamp falls before ``since_ms`` for early
    filtering when callers only need recent sessions.

    Args:
        history_file: Path to the history.jsonl file.
        since_ms: Minimum timestamp in milliseconds (0 to include all).

    Returns:
        Dict mapping session_id -> aggregated session data.
    """
    sessions: dict[str, dict] = {}
    with open(history_file, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
            except json.JSONDecodeError:
                continue

            session_id = entry.get("sessionId", "")
            if not session_id:
                continue

            timestamp_ms = entry.get("timestamp", 0)
            if since_ms and timestamp_ms < since_ms:
                continue

            display = entry.get("display", "")
            project_path = entry.get("project", "")

            if session_id not in sessions:
                sessions[session_id] = {
                    "first_timestamp": timestamp_ms,
                    "last_timestamp": timestamp_ms,
                    "first_message": display if _is_meaningful_prompt(display) else "",
                    "project_path": project_path,
                    "message_count": 1,
                }
            else:
                sess = sessions[session_id]
                sess["message_count"] += 1
                if not sess["first_message"] and _is_meaningful_prompt(display):
                    sess["first_message"] = display
                if timestamp_ms < sess["first_timestamp"]:
                    sess["first_timestamp"] = timestamp_ms
                    if _is_meaningful_prompt(display):
                        sess["first_message"] = display
                if timestamp_ms > sess["last_timestamp"]:
                    sess["last_timestamp"] = timestamp_ms
    return sessions


def _detect_orphans(
    tool_use_ids: set[str], tool_results: dict[str, dict], diagnostics: DiagnosticsCollector
) -> None:
    """Detect orphaned tool calls and results and record in diagnostics.

    Args:
        tool_use_ids: Set of tool_use IDs found in agent steps.
        tool_results: Mapping of tool_use_id -> result from user steps.
        diagnostics: Collector to record orphans into.
    """
    result_ids = set(tool_results.keys())
    for tc_id in tool_use_ids:
        diagnostics.record_tool_result()
        if tc_id not in result_ids:
            diagnostics.record_orphaned_call(tc_id)
    for result_id in result_ids:
        diagnostics.record_tool_result()
        if result_id not in tool_use_ids:
            diagnostics.record_orphaned_result(result_id)


def _build_agent_spawn_map(
    raw_content: str, parent_steps: list[Step]
) -> dict[str, tuple[str, str]]:
    """Build agentId → (step_id, tool_call_id) map from raw JSONL.

    Scans raw JSONL directly (not parsed steps) to avoid missing
    entries evicted from the bounded tool result cache. For each
    Task/Agent tool_use, finds the corresponding tool_result and
    extracts the 'agentId: {hash}' embedded in the output text.

    Args:
        raw_content: Raw JSONL content of the main session.
        parent_steps: Parsed steps for resolving tool_call_id → step_id.

    Returns:
        Dict mapping agentId to (step_id, tool_call_id).
    """
    # Single pass: collect spawn tool_use_ids from assistant messages
    # and tool_result candidates from user messages simultaneously
    spawn_tc_ids: set[str] = set()
    result_candidates: list[tuple[str, str]] = []  # (tool_use_id, text_content)

    for line in raw_content.split("\n"):
        stripped = line.strip()
        if not stripped:
            continue
        try:
            entry = json.loads(stripped)
        except json.JSONDecodeError:
            continue

        entry_type = entry.get("type")
        msg = entry.get("message", {})
        if not isinstance(msg, dict):
            continue

        if entry_type == "assistant":
            for block in msg.get("content", []):
                if not isinstance(block, dict):
                    continue
                if block.get("type") == "tool_use" and block.get("name") in _SUBAGENT_TOOL_NAMES:
                    tc_id = block.get("id", "")
                    if tc_id:
                        spawn_tc_ids.add(tc_id)

        elif entry_type == "user":
            for block in msg.get("content", []):
                if not isinstance(block, dict) or block.get("type") != "tool_result":
                    continue
                tc_id = block.get("tool_use_id", "")
                if not tc_id:
                    continue
                result_content = block.get("content", "")
                if isinstance(result_content, list):
                    result_content = " ".join(
                        b.get("text", "") for b in result_content if isinstance(b, dict)
                    )
                result_content = str(result_content)
                if "<persisted-output>" in result_content:
                    result_content = _read_persisted_agent_id(result_content)
                result_candidates.append((tc_id, result_content))

    if not spawn_tc_ids:
        return {}

    # Filter candidates against spawn_tc_ids and extract agentId
    agent_to_tc: dict[str, str] = {}
    for tc_id, text in result_candidates:
        if tc_id not in spawn_tc_ids:
            continue
        match = _AGENT_ID_PATTERN.search(text)
        if match:
            agent_to_tc[match.group(1)] = tc_id

    # Resolve tool_call_id → step_id via parsed steps
    tc_to_step_id: dict[str, str] = {}
    for step in parent_steps:
        for tc in step.tool_calls:
            if tc.tool_call_id in spawn_tc_ids:
                tc_to_step_id[tc.tool_call_id] = step.step_id

    return {
        agent_id: (tc_to_step_id.get(tc_id, ""), tc_id) for agent_id, tc_id in agent_to_tc.items()
    }


def _collect_tool_results(raw_entries: list[dict]) -> dict[str, dict]:
    """Build a mapping of tool_use_id -> result from user messages.

    Uses a plain dict (unbounded) so that long sessions with many tool
    calls do not lose early results to eviction. Also captures the
    event-level ``toolUseResult`` field (structured metadata like
    exit_code, stdout, stderr) for downstream extraction.
    """
    tool_results: dict[str, dict] = {}
    for entry in raw_entries:
        if entry.get("type") != "user":
            continue
        msg = entry.get("message", {})
        content = msg.get("content", "")
        if not isinstance(content, list):
            continue
        # Event-level toolUseResult carries structured execution metadata
        tool_use_result = entry.get("toolUseResult")
        for block in content:
            if block.get("type") == "tool_result":
                tool_use_id = block.get("tool_use_id", "")
                if tool_use_id:
                    result_content = block.get("content", "")
                    is_error = block.get("is_error", False)
                    # Preserve raw content (may contain image blocks)
                    has_images = isinstance(result_content, list) and any(
                        isinstance(b, dict) and b.get("type") == "image" for b in result_content
                    )
                    output: str | list = (
                        result_content if has_images else coerce_to_string(result_content)
                    )
                    result_entry: dict = {
                        "output": output,
                        "is_error": bool(is_error),
                    }
                    if tool_use_result and isinstance(tool_use_result, dict):
                        result_entry["tool_use_result"] = tool_use_result
                    tool_results[tool_use_id] = result_entry
    return tool_results


def _parse_metrics(usage_data: dict | None) -> Metrics | None:
    """Parse Anthropic usage dict into VibeLens Metrics model.

    Token field mapping (aligned with Harbor convention):
    - ``prompt_tokens`` = total context window
      (Anthropic ``input_tokens + cache_read_input_tokens``).
    - ``cached_tokens`` = cache-read tokens (Anthropic ``cache_read_input_tokens``).
    - ``cache_creation_tokens`` = tokens written to cache
      (Anthropic ``cache_creation_input_tokens``).
    """
    if not usage_data:
        return None

    input_tok = usage_data.get("input_tokens") or 0
    cache_read = usage_data.get("cache_read_input_tokens") or 0
    output_tok = usage_data.get("output_tokens") or 0
    cache_write = usage_data.get("cache_creation_input_tokens") or 0

    return Metrics(
        prompt_tokens=input_tok + cache_read,
        completion_tokens=output_tok,
        cache_creation_tokens=cache_write,
        cached_tokens=cache_read,
    )


def _link_subagent_to_parent(
    parent_steps: list[Step], spawn_tool_call_id: str, sub_agent_id: str
) -> None:
    """Add a subagent_trajectory_ref to the parent step's observation.

    Args:
        parent_steps: Steps from the parent session.
        spawn_tool_call_id: Tool call ID that spawned the sub-agent.
        sub_agent_id: Session ID of the sub-agent trajectory.
    """
    for step in parent_steps:
        if not step.observation:
            continue
        for result in step.observation.results:
            if result.source_call_id == spawn_tool_call_id:
                ref = TrajectoryRef(session_id=sub_agent_id)
                if result.subagent_trajectory_ref is None:
                    result.subagent_trajectory_ref = [ref]
                else:
                    result.subagent_trajectory_ref.append(ref)
                return


def _validate_subagent_linkage(
    main_trajectory: Trajectory, sub_trajectories: list[Trajectory]
) -> None:
    """Warn about broken subagent references between parent and children.

    Checks two invariants:
    - Every subagent_trajectory_ref.session_id in the parent points to
      an existing sub-trajectory.
    - Every sub-trajectory is referenced by at least one parent step.

    Uses logger.warning (not errors) since subagent files may be missing
    or the session may have been interrupted before completion.

    Args:
        main_trajectory: The parent session trajectory.
        sub_trajectories: List of parsed sub-agent trajectories.
    """
    sub_ids = {t.session_id for t in sub_trajectories}

    # Collect all subagent refs from parent observation results
    parent_refs: set[str] = set()
    for step in main_trajectory.steps:
        if not step.observation:
            continue
        for result in step.observation.results:
            if result.subagent_trajectory_ref:
                for ref in result.subagent_trajectory_ref:
                    parent_refs.add(ref.session_id)

    # Parent refs pointing to missing sub-trajectories
    dangling_refs = parent_refs - sub_ids
    if dangling_refs:
        logger.warning(
            "Session %s: parent references missing sub-trajectories: %s",
            main_trajectory.session_id,
            dangling_refs,
        )

    # Sub-trajectories not referenced by any parent step — expected for
    # compaction agents (system-internal) and persisted-output edge cases.
    unreferenced = sub_ids - parent_refs
    if unreferenced:
        compaction = {sid for sid in unreferenced if COMPACTION_AGENT_PREFIX in sid}
        regular = unreferenced - compaction
        if compaction:
            logger.debug(
                "Session %s: %d compaction sub-agent(s) (system-internal, no parent linkage)",
                main_trajectory.session_id,
                len(compaction),
            )
        if regular:
            logger.debug(
                "Session %s: sub-trajectories not referenced by parent: %s",
                main_trajectory.session_id,
                regular,
            )
