"""Unit tests for session log preprocessing.

Tests all functions in preprocess_session.py with real session data fixtures.
"""

import json
from pathlib import Path

import pytest
from click.testing import CliRunner

from erk.cli.commands.exec.scripts.preprocess_session import (
    deduplicate_assistant_messages,
    deduplicate_documentation_blocks,
    discover_agent_logs,
    discover_planning_agent_logs,
    escape_xml,
    estimate_tokens,
    generate_compressed_xml,
    is_empty_session,
    is_log_discovery_operation,
    is_warmup_session,
    preprocess_session,
    process_log_file,
    prune_tool_result_content,
    split_entries_to_chunks,
    truncate_parameter_value,
    truncate_tool_parameters,
)

from . import fixtures

# ============================================================================
# 1. XML Escaping Tests (4 tests)
# ============================================================================


def test_escape_xml_basic() -> None:
    """Test escaping of basic special characters."""
    assert escape_xml("a < b") == "a &lt; b"
    assert escape_xml("a > b") == "a &gt; b"
    assert escape_xml("a & b") == "a &amp; b"


def test_escape_xml_all_special_chars() -> None:
    """Test escaping all special characters together."""
    assert escape_xml("<tag>&content</tag>") == "&lt;tag&gt;&amp;content&lt;/tag&gt;"


def test_escape_xml_no_special_chars() -> None:
    """Test that normal text passes through unchanged."""
    assert escape_xml("hello world") == "hello world"
    assert escape_xml("foo-bar_baz123") == "foo-bar_baz123"


def test_escape_xml_empty_string() -> None:
    """Test that empty string returns empty string."""
    assert escape_xml("") == ""


# ============================================================================
# 2. Assistant Message Deduplication Tests (5 tests)
# ============================================================================


def test_deduplicate_removes_duplicate_text_with_tool_use() -> None:
    """Test that duplicate assistant text is removed when tool_use present."""
    # Setup: Two assistant messages with same text, second has tool_use
    entries = [
        {"type": "assistant", "message": {"content": [{"type": "text", "text": "I'll help"}]}},
        {
            "type": "assistant",
            "message": {
                "content": [
                    {"type": "text", "text": "I'll help"},
                    {"type": "tool_use", "id": "toolu_123", "name": "Read"},
                ]
            },
        },
    ]
    result = deduplicate_assistant_messages(entries)

    # First message unchanged, second message should only have tool_use
    assert len(result[1]["message"]["content"]) == 1
    assert result[1]["message"]["content"][0]["type"] == "tool_use"


def test_deduplicate_preserves_text_without_tool_use() -> None:
    """Test that text is preserved when no tool_use present."""
    entries = [
        {"type": "assistant", "message": {"content": [{"type": "text", "text": "First"}]}},
        {"type": "assistant", "message": {"content": [{"type": "text", "text": "Second"}]}},
    ]
    result = deduplicate_assistant_messages(entries)

    # Both messages should keep their text
    assert result[0]["message"]["content"][0]["text"] == "First"
    assert result[1]["message"]["content"][0]["text"] == "Second"


def test_deduplicate_preserves_first_assistant_text() -> None:
    """Test that first assistant message is never deduplicated."""
    entries = [{"type": "assistant", "message": {"content": [{"type": "text", "text": "Hello"}]}}]
    result = deduplicate_assistant_messages(entries)
    assert result[0]["message"]["content"][0]["text"] == "Hello"


def test_deduplicate_handles_empty_content() -> None:
    """Test handling of assistant messages with empty content."""
    entries = [{"type": "assistant", "message": {"content": []}}]
    result = deduplicate_assistant_messages(entries)
    assert result == entries


def test_deduplicate_handles_no_assistant_messages() -> None:
    """Test handling of entries with no assistant messages."""
    entries = [{"type": "user", "message": {"content": "Hello"}}]
    result = deduplicate_assistant_messages(entries)
    assert result == entries


# ============================================================================
# 3. XML Generation Tests (8 tests)
# ============================================================================


def test_generate_xml_user_message_string_content() -> None:
    """Test XML generation for user message with string content."""
    entries = [json.loads(fixtures.JSONL_USER_MESSAGE_STRING)]
    xml = generate_compressed_xml(entries)
    assert fixtures.EXPECTED_XML_USER_STRING in xml
    assert "<session>" in xml
    assert "</session>" in xml


def test_generate_xml_user_message_structured_content() -> None:
    """Test XML generation for user message with structured content."""
    entries = [json.loads(fixtures.JSONL_USER_MESSAGE_STRUCTURED)]
    xml = generate_compressed_xml(entries)
    assert fixtures.EXPECTED_XML_USER_STRUCTURED in xml


def test_generate_xml_assistant_text() -> None:
    """Test XML generation for assistant text."""
    entries = [json.loads(fixtures.JSONL_ASSISTANT_TEXT)]
    xml = generate_compressed_xml(entries)
    assert fixtures.EXPECTED_XML_ASSISTANT_TEXT in xml


def test_generate_xml_assistant_tool_use() -> None:
    """Test XML generation for assistant with tool_use."""
    entries = [json.loads(fixtures.JSONL_ASSISTANT_TOOL_USE)]
    xml = generate_compressed_xml(entries)
    assert '<tool_use name="Read" id="toolu_abc123">' in xml
    assert '<param name="file_path">/test/file.py</param>' in xml


def test_generate_xml_tool_result() -> None:
    """Test XML generation for tool results (preserves verbosity)."""
    # Note: The fixture has nested structure with "content" field, but the implementation
    # looks for "text" field. Need to adapt the entry to match what the code expects.
    entry_data = json.loads(fixtures.JSONL_TOOL_RESULT)

    # Extract the content string from the nested structure
    content_block = entry_data["message"]["content"][0]
    content_text = content_block["content"]  # This is the actual content string

    # Restructure to what the code expects: content blocks with "text" field
    entry_data["message"]["content"] = [{"type": "text", "text": content_text}]

    entries = [entry_data]
    xml = generate_compressed_xml(entries)
    assert '<tool_result tool="toolu_abc123">' in xml
    assert "File contents:" in xml
    assert "def hello():" in xml  # Preserves formatting


def test_generate_xml_extracts_git_branch_metadata() -> None:
    """Test that git branch is extracted to metadata."""
    entries = [{"type": "user", "message": {"content": "test"}, "gitBranch": "test-branch"}]
    xml = generate_compressed_xml(entries)
    assert '<meta branch="test-branch" />' in xml


def test_generate_xml_includes_source_label() -> None:
    """Test that source label is included for agent logs."""
    entries = [{"type": "user", "message": {"content": "test"}}]
    xml = generate_compressed_xml(entries, source_label="agent-123")
    assert '<meta source="agent-123" />' in xml


def test_generate_xml_empty_entries() -> None:
    """Test handling of empty entries list."""
    xml = generate_compressed_xml([])
    assert xml == "<session>\n</session>"


def test_generate_xml_tool_result_embedded_in_user_message() -> None:
    """Regression test: tool_results embedded in user messages are extracted.

    Bug: In Claude Code's JSONL format, tool_results are NOT top-level entries.
    Instead, they're embedded inside user-type entries as content[].type = "tool_result".
    The preprocessor was only handling top-level tool_result entries (which don't exist
    in this format) and type: text blocks within user messages, causing all tool_results
    to be silently dropped.

    Fix: The user message handler now detects type: tool_result blocks in content[]
    and outputs them as separate <tool_result> elements.
    """
    # This is the ACTUAL format Claude Code uses for tool results
    entry = {
        "type": "user",  # Note: NOT "tool_result" at top level
        "message": {
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": "toolu_abc123",
                    "content": "File contents:\n     1→def hello():\n     2→    print('Hello')",
                }
            ],
        },
    }

    xml = generate_compressed_xml([entry])

    # Tool result should be extracted and output as <tool_result> element
    assert '<tool_result tool="toolu_abc123">' in xml
    assert "File contents:" in xml
    assert "def hello():" in xml
    # Should NOT output an empty <user> tag since there's no text content
    assert "<user></user>" not in xml


def test_generate_xml_user_with_mixed_text_and_tool_results() -> None:
    """Test user message containing both text and tool_result blocks.

    Claude Code sometimes includes both text and tool_result in the same user message.
    Both should be extracted and output separately.
    """
    entry = {
        "type": "user",
        "message": {
            "role": "user",
            "content": [
                {"type": "text", "text": "Here are the results:"},
                {
                    "type": "tool_result",
                    "tool_use_id": "toolu_read_001",
                    "content": "Content of file A",
                },
                {
                    "type": "tool_result",
                    "tool_use_id": "toolu_read_002",
                    "content": "Content of file B",
                },
            ],
        },
    }

    xml = generate_compressed_xml([entry])

    # Text should be in <user> element
    assert "<user>Here are the results:</user>" in xml

    # Both tool results should be extracted
    assert '<tool_result tool="toolu_read_001">' in xml
    assert "Content of file A" in xml
    assert '<tool_result tool="toolu_read_002">' in xml
    assert "Content of file B" in xml


def test_generate_xml_tool_result_with_pruning() -> None:
    """Test that embedded tool_results are pruned when enable_pruning=True."""
    # Create a long tool result that exceeds 30 lines
    long_content = "\n".join([f"Line {i}" for i in range(50)])

    entry = {
        "type": "user",
        "message": {
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": "toolu_long",
                    "content": long_content,
                }
            ],
        },
    }

    xml = generate_compressed_xml([entry], enable_pruning=True)

    # Should contain pruning marker
    assert "omitted" in xml
    # First lines should be present
    assert "Line 0" in xml
    assert "Line 29" in xml
    # Lines beyond 30 should be omitted (unless they contain errors)
    assert "Line 49" not in xml


def test_generate_xml_tool_result_with_list_of_text_blocks() -> None:
    """Test tool_result with content as list of typed text blocks.

    Claude Code sometimes returns tool_result content as a list of
    structured blocks with type="text".
    """
    entry = {
        "type": "user",
        "message": {
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": "toolu_list_text",
                    "content": [
                        {"type": "text", "text": "First line of output"},
                        {"type": "text", "text": "Second line of output"},
                    ],
                }
            ],
        },
    }

    xml = generate_compressed_xml([entry])

    assert '<tool_result tool="toolu_list_text">' in xml
    assert "First line of output" in xml
    assert "Second line of output" in xml


def test_generate_xml_tool_result_with_list_of_strings() -> None:
    """Test tool_result with content as list of plain strings.

    Some tool results return content as a simple list of strings.
    """
    entry = {
        "type": "user",
        "message": {
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": "toolu_list_str",
                    "content": ["Line one", "Line two", "Line three"],
                }
            ],
        },
    }

    xml = generate_compressed_xml([entry])

    assert '<tool_result tool="toolu_list_str">' in xml
    assert "Line one" in xml
    assert "Line two" in xml
    assert "Line three" in xml


def test_generate_xml_tool_result_with_mixed_content_list() -> None:
    """Test tool_result with content as list of mixed types.

    Content list may contain both typed text blocks and plain strings.
    """
    entry = {
        "type": "user",
        "message": {
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": "toolu_mixed",
                    "content": [
                        {"type": "text", "text": "Typed text block"},
                        "Plain string item",
                    ],
                }
            ],
        },
    }

    xml = generate_compressed_xml([entry])

    assert '<tool_result tool="toolu_mixed">' in xml
    assert "Typed text block" in xml
    assert "Plain string item" in xml


# ============================================================================
# 4. Log File Processing Tests (6 tests)
# ============================================================================


def test_process_log_file_filters_file_history_snapshot(tmp_path: Path) -> None:
    """Test that file-history-snapshot entries are filtered out."""
    log_file = tmp_path / "test.jsonl"
    # Parse and re-serialize to ensure valid JSON
    snapshot_json = json.dumps(json.loads(fixtures.JSONL_FILE_HISTORY_SNAPSHOT))
    user_json = json.dumps(json.loads(fixtures.JSONL_USER_MESSAGE_STRING))
    log_file.write_text(
        f"{snapshot_json}\n{user_json}",
        encoding="utf-8",
    )

    entries, _total, _skipped = process_log_file(log_file)
    assert len(entries) == 1  # Only user message, snapshot filtered
    assert entries[0]["type"] == "user"


def test_process_log_file_strips_metadata(tmp_path: Path) -> None:
    """Test that metadata fields are stripped."""
    log_file = tmp_path / "test.jsonl"
    user_json = json.dumps(json.loads(fixtures.JSONL_USER_MESSAGE_STRING))
    log_file.write_text(user_json, encoding="utf-8")

    entries, _total, _skipped = process_log_file(log_file)
    # Should NOT have metadata fields
    assert "parentUuid" not in entries[0]
    assert "sessionId" not in entries[0]
    assert "cwd" not in entries[0]
    assert "timestamp" not in entries[0]
    assert "userType" not in entries[0]
    assert "isSidechain" not in entries[0]


def test_process_log_file_removes_usage_field(tmp_path: Path) -> None:
    """Test that usage metadata is removed from assistant messages."""
    log_file = tmp_path / "test.jsonl"
    log_file.write_text(json.dumps(json.loads(fixtures.JSONL_ASSISTANT_TEXT)), encoding="utf-8")

    entries, _total, _skipped = process_log_file(log_file)
    assert "usage" not in entries[0]["message"]


def test_process_log_file_preserves_git_branch(tmp_path: Path) -> None:
    """Test that gitBranch is preserved for metadata extraction."""
    log_file = tmp_path / "test.jsonl"
    user_json = json.dumps(json.loads(fixtures.JSONL_USER_MESSAGE_STRING))
    log_file.write_text(user_json, encoding="utf-8")

    entries, _total, _skipped = process_log_file(log_file)
    assert entries[0]["gitBranch"] == "test-branch"


def test_process_log_file_handles_empty_file(tmp_path: Path) -> None:
    """Test handling of empty log file."""
    log_file = tmp_path / "empty.jsonl"
    log_file.write_text("", encoding="utf-8")

    entries, _total, _skipped = process_log_file(log_file)
    assert entries == []


def test_process_log_file_handles_malformed_json(tmp_path: Path) -> None:
    """Test handling of malformed JSON lines."""
    log_file = tmp_path / "malformed.jsonl"
    log_file.write_text("{invalid json}", encoding="utf-8")

    # Should raise JSONDecodeError
    with pytest.raises(json.JSONDecodeError):
        process_log_file(log_file)


# ============================================================================
# 5. Agent Log Discovery Tests (7 tests)
# ============================================================================


def test_discover_agent_logs_returns_sorted(tmp_path: Path) -> None:
    """Test that agent logs are returned in sorted order."""
    session_log = tmp_path / "session-123.jsonl"
    session_log.write_text("{}", encoding="utf-8")
    session_id = "session-123"

    agent_z = tmp_path / "agent-zzz.jsonl"
    agent_a = tmp_path / "agent-aaa.jsonl"
    agent_z.write_text(
        json.dumps({"sessionId": session_id, "type": "user", "message": {"content": "z"}}),
        encoding="utf-8",
    )
    agent_a.write_text(
        json.dumps({"sessionId": session_id, "type": "user", "message": {"content": "a"}}),
        encoding="utf-8",
    )

    agents = discover_agent_logs(session_log, session_id)
    assert agents[0].name == "agent-aaa.jsonl"
    assert agents[1].name == "agent-zzz.jsonl"


def test_discover_agent_logs_ignores_other_files(tmp_path: Path) -> None:
    """Test that non-agent files are ignored."""
    session_log = tmp_path / "session-123.jsonl"
    session_log.write_text("{}", encoding="utf-8")
    session_id = "session-123"

    agent = tmp_path / "agent-abc.jsonl"
    other = tmp_path / "other-file.jsonl"
    agent.write_text(
        json.dumps({"sessionId": session_id, "type": "user", "message": {"content": "test"}}),
        encoding="utf-8",
    )
    other.write_text("{}", encoding="utf-8")

    agents = discover_agent_logs(session_log, session_id)
    assert len(agents) == 1
    assert agents[0] == agent


def test_discover_agent_logs_empty_directory(tmp_path: Path) -> None:
    """Test handling of directory with no agent logs."""
    session_log = tmp_path / "session-123.jsonl"
    session_log.write_text("{}", encoding="utf-8")

    agents = discover_agent_logs(session_log, "session-123")
    assert agents == []


def test_discover_agent_logs_filters_by_session_id(tmp_path: Path) -> None:
    """Test that agent logs are filtered by session ID."""
    session_log = tmp_path / "session-123.jsonl"
    session_log.write_text("{}", encoding="utf-8")

    # Agent belonging to session-123
    agent_match = tmp_path / "agent-abc.jsonl"
    agent_match.write_text(
        json.dumps({"sessionId": "session-123", "type": "user", "message": {"content": "test"}}),
        encoding="utf-8",
    )

    # Agent belonging to different session
    agent_no_match = tmp_path / "agent-def.jsonl"
    agent_no_match.write_text(
        json.dumps({"sessionId": "session-456", "type": "user", "message": {"content": "test"}}),
        encoding="utf-8",
    )

    agents = discover_agent_logs(session_log, "session-123")
    assert len(agents) == 1
    assert agent_match in agents
    assert agent_no_match not in agents


def test_discover_agent_logs_filters_multiple_agents(tmp_path: Path) -> None:
    """Test filtering when multiple agent logs match session ID."""
    session_log = tmp_path / "session-xyz.jsonl"
    session_log.write_text("{}", encoding="utf-8")

    # Two agents belonging to session-xyz
    agent1 = tmp_path / "agent-aaa.jsonl"
    agent1.write_text(
        json.dumps({"sessionId": "session-xyz", "type": "user", "message": {"content": "test"}}),
        encoding="utf-8",
    )
    agent2 = tmp_path / "agent-bbb.jsonl"
    agent2.write_text(
        json.dumps({"sessionId": "session-xyz", "type": "user", "message": {"content": "test"}}),
        encoding="utf-8",
    )

    # Agent belonging to different session
    agent_other = tmp_path / "agent-ccc.jsonl"
    agent_other.write_text(
        json.dumps({"sessionId": "other-session", "type": "user", "message": {"content": "test"}}),
        encoding="utf-8",
    )

    agents = discover_agent_logs(session_log, "session-xyz")
    assert len(agents) == 2
    assert agent1 in agents
    assert agent2 in agents
    assert agent_other not in agents


def test_discover_agent_logs_skips_empty_first_line(tmp_path: Path) -> None:
    """Test that agent logs with empty first line are skipped."""
    session_log = tmp_path / "session-123.jsonl"
    session_log.write_text("{}", encoding="utf-8")

    # Agent with empty first line should be skipped
    agent_empty = tmp_path / "agent-empty.jsonl"
    agent_empty.write_text("\n", encoding="utf-8")

    # Agent with valid content should be returned
    agent_valid = tmp_path / "agent-valid.jsonl"
    agent_valid.write_text(
        json.dumps({"sessionId": "session-123", "type": "user", "message": {"content": "test"}}),
        encoding="utf-8",
    )

    agents = discover_agent_logs(session_log, "session-123")
    assert len(agents) == 1
    assert agent_valid in agents


def test_discover_agent_logs_returns_empty_when_no_match(tmp_path: Path) -> None:
    """Test that empty list is returned when no agents match session ID."""
    session_log = tmp_path / "session-target.jsonl"
    session_log.write_text("{}", encoding="utf-8")

    # Agent belonging to different session
    agent = tmp_path / "agent-abc.jsonl"
    agent.write_text(
        json.dumps({"sessionId": "other-session", "type": "user", "message": {"content": "test"}}),
        encoding="utf-8",
    )

    agents = discover_agent_logs(session_log, "session-target")
    assert agents == []


# ============================================================================
# 5b. Planning Agent Discovery Tests (4 tests)
# ============================================================================


def test_discover_planning_agent_logs_finds_plan_subagents(tmp_path: Path) -> None:
    """Test that Plan subagents are correctly identified."""
    session_log = tmp_path / "session-123.jsonl"

    # Create session log with Plan Task tool invocation
    session_entries = [
        json.dumps(
            {
                "type": "assistant",
                "message": {
                    "content": [
                        {
                            "type": "tool_use",
                            "name": "Task",
                            "input": {"subagent_type": "Plan", "prompt": "Create plan"},
                        }
                    ],
                    "timestamp": 1000.0,
                },
            }
        )
    ]
    session_log.write_text("\n".join(session_entries), encoding="utf-8")

    # Create matching agent log
    agent1 = tmp_path / "agent-abc.jsonl"
    agent1_entry = json.dumps(
        {
            "sessionId": "session-123",
            "message": {"timestamp": 1000.5},  # Within 1 second of Task
        }
    )
    agent1.write_text(agent1_entry, encoding="utf-8")

    # Create non-matching agent log
    agent2 = tmp_path / "agent-def.jsonl"
    agent2_entry = json.dumps(
        {
            "sessionId": "other-session",
            "message": {"timestamp": 1000.5},
        }
    )
    agent2.write_text(agent2_entry, encoding="utf-8")

    agents = discover_planning_agent_logs(session_log, "session-123")
    assert len(agents) == 1
    assert agent1 in agents
    assert agent2 not in agents


def test_discover_planning_agent_logs_filters_non_plan(tmp_path: Path) -> None:
    """Test that Explore/devrun subagents are filtered out."""
    session_log = tmp_path / "session-123.jsonl"

    # Create session with mixed subagent types
    session_entries = [
        json.dumps(
            {
                "type": "assistant",
                "message": {
                    "content": [
                        {
                            "type": "tool_use",
                            "name": "Task",
                            "input": {"subagent_type": "Plan", "prompt": "Create plan"},
                        }
                    ],
                    "timestamp": 1000.0,
                },
            }
        ),
        json.dumps(
            {
                "type": "assistant",
                "message": {
                    "content": [
                        {
                            "type": "tool_use",
                            "name": "Task",
                            "input": {"subagent_type": "Explore", "prompt": "Explore code"},
                        }
                    ],
                    "timestamp": 2000.0,
                },
            }
        ),
    ]
    session_log.write_text("\n".join(session_entries), encoding="utf-8")

    # Create agent logs matching both
    agent_plan = tmp_path / "agent-plan.jsonl"
    agent_plan.write_text(
        json.dumps(
            {
                "sessionId": "session-123",
                "message": {"timestamp": 1000.5},
            }
        ),
        encoding="utf-8",
    )

    agent_explore = tmp_path / "agent-explore.jsonl"
    agent_explore.write_text(
        json.dumps(
            {
                "sessionId": "session-123",
                "message": {"timestamp": 2000.5},
            }
        ),
        encoding="utf-8",
    )

    agents = discover_planning_agent_logs(session_log, "session-123")

    # Only Plan agent should be returned
    assert len(agents) == 1
    assert agent_plan in agents
    assert agent_explore not in agents


def test_discover_planning_agent_logs_empty_when_none(tmp_path: Path) -> None:
    """Test that empty list returned when no Plan subagents."""
    session_log = tmp_path / "session-123.jsonl"

    # Create session with no Task invocations
    session_entries = [
        json.dumps({"type": "user", "message": {"content": "Hello"}}),
        json.dumps({"type": "assistant", "message": {"content": [{"type": "text", "text": "Hi"}]}}),
    ]
    session_log.write_text("\n".join(session_entries), encoding="utf-8")

    # Create some agent logs (should not be returned)
    agent = tmp_path / "agent-abc.jsonl"
    agent.write_text(
        json.dumps(
            {
                "sessionId": "session-123",
                "message": {"timestamp": 1000.0},
            }
        ),
        encoding="utf-8",
    )

    agents = discover_planning_agent_logs(session_log, "session-123")
    assert agents == []


def test_discover_planning_agent_logs_matches_agent_ids(tmp_path: Path) -> None:
    """Test that agent IDs are correctly extracted and matched."""
    session_log = tmp_path / "session-123.jsonl"

    # Create session with Plan Tasks
    session_entries = [
        json.dumps(
            {
                "type": "assistant",
                "message": {
                    "content": [
                        {
                            "type": "tool_use",
                            "name": "Task",
                            "input": {"subagent_type": "Plan", "prompt": "First plan"},
                        }
                    ],
                    "timestamp": 1000.0,
                },
            }
        ),
    ]
    session_log.write_text("\n".join(session_entries), encoding="utf-8")

    # Create agent logs with different sessionIds and timestamps
    # This one matches: correct sessionId and within 1 second
    agent_match = tmp_path / "agent-match.jsonl"
    agent_match.write_text(
        json.dumps(
            {
                "sessionId": "session-123",
                "message": {"timestamp": 1000.8},  # Within 1 second
            }
        ),
        encoding="utf-8",
    )

    # This one doesn't match: wrong sessionId
    agent_wrong_session = tmp_path / "agent-wrong.jsonl"
    agent_wrong_session.write_text(
        json.dumps(
            {
                "sessionId": "other-session",
                "message": {"timestamp": 1000.5},
            }
        ),
        encoding="utf-8",
    )

    # This one doesn't match: timestamp too far
    agent_wrong_time = tmp_path / "agent-late.jsonl"
    agent_wrong_time.write_text(
        json.dumps(
            {
                "sessionId": "session-123",
                "message": {"timestamp": 1005.0},  # More than 1 second away
            }
        ),
        encoding="utf-8",
    )

    agents = discover_planning_agent_logs(session_log, "session-123")

    # Only the matching agent should be returned
    assert len(agents) == 1
    assert agent_match in agents


# ============================================================================
# 6. CLI Command Tests (6 tests)
# ============================================================================


def test_preprocess_session_creates_temp_file(tmp_path: Path) -> None:
    """Test that command creates temp file."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        log_file = Path("session-123.jsonl")
        user_json = json.dumps(json.loads(fixtures.JSONL_USER_MESSAGE_STRING))
        log_file.write_text(user_json, encoding="utf-8")

        result = runner.invoke(preprocess_session, [str(log_file), "--no-filtering"])
        assert result.exit_code == 0

        # Extract temp file path from output
        temp_path = Path(result.output.strip())
        assert temp_path.exists()
        # Check filename pattern (now includes random suffix for uniqueness)
        assert temp_path.name.startswith("session-session-123-")
        assert temp_path.name.endswith("-compressed.xml")


def test_preprocess_session_outputs_path(tmp_path: Path) -> None:
    """Test that command outputs temp file path to stdout."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        log_file = Path("session-123.jsonl")
        user_json = json.dumps(json.loads(fixtures.JSONL_USER_MESSAGE_STRING))
        log_file.write_text(user_json, encoding="utf-8")

        result = runner.invoke(preprocess_session, [str(log_file), "--no-filtering"])
        # Output should contain temp file path with correct filename pattern
        assert "session-session-123-" in result.output
        assert "-compressed.xml" in result.output


def test_preprocess_session_includes_agents_by_default(tmp_path: Path) -> None:
    """Test that agent logs are included by default."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        # Create entries with matching session ID
        session_id = "session-123"
        entry = json.dumps(
            {"sessionId": session_id, "type": "user", "message": {"content": "test"}}
        )

        log_file = Path(f"{session_id}.jsonl")
        log_file.write_text(entry, encoding="utf-8")

        agent_file = Path("agent-abc.jsonl")
        agent_file.write_text(entry, encoding="utf-8")

        result = runner.invoke(preprocess_session, [str(log_file), "--no-filtering"])
        assert result.exit_code == 0

        # Check temp file contains multiple sessions
        temp_path = Path(result.output.strip())
        content = temp_path.read_text(encoding="utf-8")
        assert content.count("<session>") == 2  # Main + agent


def test_preprocess_session_no_include_agents_flag(tmp_path: Path) -> None:
    """Test --no-include-agents flag excludes agent logs."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        # Create entries with matching session ID
        session_id = "session-123"
        entry = json.dumps(
            {"sessionId": session_id, "type": "user", "message": {"content": "test"}}
        )

        log_file = Path(f"{session_id}.jsonl")
        log_file.write_text(entry, encoding="utf-8")

        agent_file = Path("agent-abc.jsonl")
        agent_file.write_text(entry, encoding="utf-8")

        result = runner.invoke(
            preprocess_session, [str(log_file), "--no-include-agents", "--no-filtering"]
        )
        assert result.exit_code == 0

        # Check temp file contains only main session
        temp_path = Path(result.output.strip())
        content = temp_path.read_text(encoding="utf-8")
        assert content.count("<session>") == 1  # Only main


def test_preprocess_session_nonexistent_file() -> None:
    """Test handling of nonexistent log file."""
    runner = CliRunner()
    result = runner.invoke(preprocess_session, ["/nonexistent/file.jsonl"])
    assert result.exit_code != 0  # Should fail


def test_preprocess_session_agent_logs_with_source_labels(tmp_path: Path) -> None:
    """Test that agent logs include source labels."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        # Create entries with matching session ID
        session_id = "session-123"
        entry = json.dumps(
            {"sessionId": session_id, "type": "user", "message": {"content": "test"}}
        )

        log_file = Path(f"{session_id}.jsonl")
        log_file.write_text(entry, encoding="utf-8")

        agent_file = Path("agent-xyz.jsonl")
        agent_file.write_text(entry, encoding="utf-8")

        result = runner.invoke(preprocess_session, [str(log_file), "--no-filtering"])
        assert result.exit_code == 0

        # Check temp file has source labels
        temp_path = Path(result.output.strip())
        content = temp_path.read_text(encoding="utf-8")
        assert '<meta source="agent-xyz" />' in content


# ============================================================================
# 7. New Filtering Functions Tests
# ============================================================================


def test_is_empty_session_with_few_entries() -> None:
    """Test that sessions with <3 entries are considered empty."""
    entries = [{"type": "user", "message": {"content": "Hi"}}]
    assert is_empty_session(entries) is True


def test_is_empty_session_with_no_meaningful_content() -> None:
    """Test that sessions without meaningful interaction are empty."""
    entries = [
        {"type": "user", "message": {"content": ""}},
        {"type": "assistant", "message": {"content": []}},
        {"type": "user", "message": {"content": "   "}},
    ]
    assert is_empty_session(entries) is True


def test_is_empty_session_with_meaningful_content() -> None:
    """Test that sessions with meaningful content are not empty."""
    entries = [
        {"type": "user", "message": {"content": "Hello"}},
        {"type": "assistant", "message": {"content": [{"type": "text", "text": "Hi there"}]}},
        {"type": "user", "message": {"content": "How are you?"}},
    ]
    assert is_empty_session(entries) is False


def test_is_warmup_session_detects_warmup() -> None:
    """Test that warmup sessions are detected."""
    entries = [{"type": "user", "message": {"content": "warmup"}}]
    assert is_warmup_session(entries) is True


def test_is_warmup_session_with_normal_content() -> None:
    """Test that normal sessions are not detected as warmup."""
    entries = [{"type": "user", "message": {"content": "Please help me with this task"}}]
    assert is_warmup_session(entries) is False


def test_deduplicate_documentation_blocks_keeps_first() -> None:
    """Test that first documentation block is kept."""
    long_doc = "command-message>" + ("x" * 600)
    entries = [{"type": "user", "message": {"content": long_doc}}]
    result = deduplicate_documentation_blocks(entries)
    assert len(result) == 1
    assert long_doc in str(result[0])


def test_deduplicate_documentation_blocks_replaces_duplicate() -> None:
    """Test that duplicate documentation blocks are replaced with markers."""
    long_doc = "/erk:plan-save-issue" + ("x" * 600)
    entries = [
        {"type": "user", "message": {"content": long_doc}},
        {"type": "user", "message": {"content": long_doc}},
    ]
    result = deduplicate_documentation_blocks(entries)
    assert len(result) == 2
    # Second entry should have marker
    assert "[Duplicate command documentation block omitted" in str(result[1])


def test_truncate_parameter_value_preserves_short() -> None:
    """Test that short values are not truncated."""
    value = "short text"
    assert truncate_parameter_value(value) == value


def test_truncate_parameter_value_truncates_long() -> None:
    """Test that long values are truncated."""
    value = "x" * 300
    result = truncate_parameter_value(value)
    assert len(result) < len(value)
    assert "truncated" in result


def test_truncate_parameter_value_preserves_file_paths() -> None:
    """Test that file paths preserve structure."""
    value = "/very/long/path/to/some/file/deep/in/directory/structure/file.py"
    result = truncate_parameter_value(value, max_length=30)
    assert result.startswith("/very")
    assert result.endswith("file.py")
    assert "..." in result


def test_truncate_tool_parameters_modifies_long_params() -> None:
    """Test that tool parameters are truncated."""
    entries = [
        {
            "type": "assistant",
            "message": {
                "content": [
                    {
                        "type": "tool_use",
                        "name": "Read",
                        "input": {"file_path": "/short", "prompt": "x" * 300},
                    }
                ]
            },
        }
    ]
    result = truncate_tool_parameters(entries)
    # Long prompt should be truncated
    prompt = result[0]["message"]["content"][0]["input"]["prompt"]
    assert len(prompt) < 300


def test_prune_tool_result_content_preserves_short() -> None:
    """Test that short results are not pruned."""
    result = "Line 1\nLine 2\nLine 3"
    assert prune_tool_result_content(result) == result


def test_prune_tool_result_content_prunes_long() -> None:
    """Test that long results are pruned to 30 lines."""
    lines = [f"Line {i}" for i in range(100)]
    result_text = "\n".join(lines)
    pruned = prune_tool_result_content(result_text)
    assert "omitted" in pruned
    assert len(pruned.split("\n")) < 100


def test_prune_tool_result_content_preserves_errors() -> None:
    """Test that error lines are preserved even after 30 lines."""
    lines = [f"Line {i}" for i in range(100)]
    lines[50] = "ERROR: Something went wrong"
    result_text = "\n".join(lines)
    pruned = prune_tool_result_content(result_text)
    assert "ERROR: Something went wrong" in pruned


def test_is_log_discovery_operation_detects_pwd() -> None:
    """Test that pwd commands are detected as log discovery."""
    entry = {
        "type": "assistant",
        "message": {"content": [{"type": "tool_use", "name": "Bash", "input": {"command": "pwd"}}]},
    }
    assert is_log_discovery_operation(entry) is True


def test_is_log_discovery_operation_detects_ls_claude() -> None:
    """Test that ls ~/.claude commands are detected."""
    entry = {
        "type": "assistant",
        "message": {
            "content": [
                {"type": "tool_use", "name": "Bash", "input": {"command": "ls ~/.claude/projects/"}}
            ]
        },
    }
    assert is_log_discovery_operation(entry) is True


def test_is_log_discovery_operation_ignores_normal_commands() -> None:
    """Test that normal commands are not detected as log discovery."""
    entry = {
        "type": "assistant",
        "message": {
            "content": [{"type": "tool_use", "name": "Bash", "input": {"command": "git status"}}]
        },
    }
    assert is_log_discovery_operation(entry) is False


# ============================================================================
# 8. Full Workflow Integration Tests (3 tests)
# ============================================================================


def test_full_workflow_compression_ratio(tmp_path: Path) -> None:
    """Test that full workflow achieves expected compression ratio."""
    # Create log file with realistic content (multiple entries with metadata)
    session_id = "session-123"

    # Adapt fixtures to use matching session ID
    def with_session_id(fixture_json: str) -> dict:
        data = json.loads(fixture_json)
        data["sessionId"] = session_id
        return data

    # Adapt tool_result fixture
    tool_result_data = with_session_id(fixtures.JSONL_TOOL_RESULT)
    content_block = tool_result_data["message"]["content"][0]
    content_text = content_block["content"]
    tool_result_data["message"]["content"] = [{"type": "text", "text": content_text}]

    log_entries = [
        json.dumps(with_session_id(fixtures.JSONL_USER_MESSAGE_STRING)),
        json.dumps(with_session_id(fixtures.JSONL_ASSISTANT_TEXT)),
        json.dumps(with_session_id(fixtures.JSONL_ASSISTANT_TOOL_USE)),
        json.dumps(tool_result_data),
        json.dumps(with_session_id(fixtures.JSONL_FILE_HISTORY_SNAPSHOT)),  # Should be filtered
    ]

    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        log_file = Path(f"{session_id}.jsonl")
        log_file.write_text("\n".join(log_entries), encoding="utf-8")

        original_size = log_file.stat().st_size

        result = runner.invoke(preprocess_session, [str(log_file), "--no-filtering"])
        assert result.exit_code == 0

        temp_path = Path(result.output.strip())
        compressed_size = temp_path.stat().st_size

        compression_ratio = (1 - compressed_size / original_size) * 100
        assert compression_ratio >= 50  # Should achieve at least 50% compression


def test_full_workflow_preserves_tool_results(tmp_path: Path) -> None:
    """Test that tool results are preserved verbatim in full workflow."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"
        log_file = Path(f"{session_id}.jsonl")

        # Adapt fixture to match what the code expects
        entry_data = json.loads(fixtures.JSONL_TOOL_RESULT)
        entry_data["sessionId"] = session_id
        content_block = entry_data["message"]["content"][0]
        content_text = content_block["content"]
        entry_data["message"]["content"] = [{"type": "text", "text": content_text}]

        log_file.write_text(json.dumps(entry_data), encoding="utf-8")

        result = runner.invoke(preprocess_session, [str(log_file), "--no-filtering"])
        assert result.exit_code == 0

        temp_path = Path(result.output.strip())
        content = temp_path.read_text(encoding="utf-8")

        # Verify tool result content preserved with formatting
        assert "File contents:" in content
        assert "def hello():" in content
        assert "print('Hello')" in content


def test_full_workflow_deduplicates_correctly(tmp_path: Path) -> None:
    """Test that deduplication works correctly in full workflow."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"
        log_file = Path(f"{session_id}.jsonl")

        # Update session IDs to match filename
        dup_text_data = json.loads(fixtures.JSONL_DUPLICATE_ASSISTANT_TEXT)
        dup_text_data["sessionId"] = session_id

        dup_tool_data = json.loads(fixtures.JSONL_DUPLICATE_ASSISTANT_WITH_TOOL)
        dup_tool_data["sessionId"] = session_id

        log_file.write_text(
            f"{json.dumps(dup_text_data)}\n{json.dumps(dup_tool_data)}",
            encoding="utf-8",
        )

        result = runner.invoke(preprocess_session, [str(log_file), "--no-filtering"])
        assert result.exit_code == 0

        temp_path = Path(result.output.strip())
        content = temp_path.read_text(encoding="utf-8")

        # First assistant should have text
        # Second assistant should only have tool_use (text deduplicated)
        assert content.count("I'll help you with that.") == 1  # Only once
        assert '<tool_use name="Edit"' in content  # Tool preserved


def test_compression_metric_includes_agent_logs(tmp_path: Path) -> None:
    """Regression test: Token reduction metric must include agent log sizes.

    Bug: The compression metric was only measuring the main session log size,
    missing agent logs that were included in the output. This caused inaccurate
    compression ratios when agent logs were included.

    Fix: Track combined size of all included logs (main session + agent logs)
    before compression, providing an accurate compression ratio.
    """
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"

        # Helper to update session ID in fixtures
        def with_session_id(fixture_json: str) -> dict:
            data = json.loads(fixture_json)
            data["sessionId"] = session_id
            return data

        # Create main session log with 3+ entries (required to pass empty session check)
        main_entries = [
            json.dumps(with_session_id(fixtures.JSONL_USER_MESSAGE_STRING)),
            json.dumps(with_session_id(fixtures.JSONL_ASSISTANT_TEXT)),
            json.dumps(with_session_id(fixtures.JSONL_USER_MESSAGE_STRING)),
        ]
        main_log = Path(f"{session_id}.jsonl")
        main_log.write_text("\n".join(main_entries), encoding="utf-8")
        main_size = main_log.stat().st_size

        # Create agent log with same content (and matching session ID)
        agent_log = Path("agent-abc.jsonl")
        agent_log.write_text("\n".join(main_entries), encoding="utf-8")
        agent_size = agent_log.stat().st_size

        # Combined size is what should be reported as "original"
        combined_size = main_size + agent_size

        # Run with filtering enabled (to trigger compression metrics output)
        # Click mixes stderr into result.output by default
        result = runner.invoke(
            preprocess_session,
            [str(main_log), "--stdout"],
            catch_exceptions=False,
        )
        assert result.exit_code == 0

        # Verify compression metric uses combined size (main + agent logs)
        # The output should contain: "({combined_size:,} → {compressed_size:,} chars)"
        assert f"({combined_size:,} →" in result.output, (
            f"Expected original size {combined_size:,} (main={main_size} + agent={agent_size}) "
            f"in compression stats, but got: {result.output}"
        )


# ============================================================================
# 9. Stdout Output Mode Tests
# ============================================================================


def test_preprocess_session_stdout_outputs_xml(tmp_path: Path) -> None:
    """Test that --stdout flag outputs XML to stdout."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"
        log_file = Path(f"{session_id}.jsonl")

        # Create entry with matching session ID
        entry_data = json.loads(fixtures.JSONL_USER_MESSAGE_STRING)
        entry_data["sessionId"] = session_id
        log_file.write_text(json.dumps(entry_data), encoding="utf-8")

        result = runner.invoke(preprocess_session, [str(log_file), "--stdout", "--no-filtering"])
        assert result.exit_code == 0

        # Output should contain XML directly
        assert "<session>" in result.output
        assert "</session>" in result.output
        assert "<user>" in result.output


def test_preprocess_session_stdout_no_temp_file(tmp_path: Path) -> None:
    """Test that --stdout flag does not create temp file."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"
        log_file = Path(f"{session_id}.jsonl")

        # Create entry with matching session ID
        entry_data = json.loads(fixtures.JSONL_USER_MESSAGE_STRING)
        entry_data["sessionId"] = session_id
        log_file.write_text(json.dumps(entry_data), encoding="utf-8")

        result = runner.invoke(preprocess_session, [str(log_file), "--stdout", "--no-filtering"])
        assert result.exit_code == 0

        # Output should NOT contain temp file path
        assert "session-session-123-" not in result.output or "<session>" in result.output


def test_preprocess_session_stdout_stats_to_stderr(tmp_path: Path) -> None:
    """Test that stats go to stderr when --stdout enabled."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"
        log_file = Path(f"{session_id}.jsonl")

        # Create multi-line content for stats to be generated (valid JSONL format)
        # Need both user and assistant messages to pass empty session check
        user_data = json.loads(fixtures.JSONL_USER_MESSAGE_STRING)
        user_data["sessionId"] = session_id
        assistant_data = json.loads(fixtures.JSONL_ASSISTANT_TEXT)
        assistant_data["sessionId"] = session_id

        entries = []
        for _ in range(5):
            entries.append(json.dumps(user_data))
            entries.append(json.dumps(assistant_data))
        log_file.write_text("\n".join(entries), encoding="utf-8")

        result = runner.invoke(preprocess_session, [str(log_file), "--stdout"])
        assert result.exit_code == 0

        # XML should be in stdout (result.output)
        assert "<session>" in result.output

        # Stats should NOT pollute stdout
        assert "Token reduction" not in result.output or "</session>" in result.output


def test_preprocess_session_backward_compatibility(tmp_path: Path) -> None:
    """Test that default behavior (no --stdout) still creates temp file."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        log_file = Path("session-123.jsonl")
        user_json = json.dumps(json.loads(fixtures.JSONL_USER_MESSAGE_STRING))
        log_file.write_text(user_json, encoding="utf-8")

        # Run without --stdout flag
        result = runner.invoke(preprocess_session, [str(log_file), "--no-filtering"])
        assert result.exit_code == 0

        # Output should contain temp file path (backward compatible)
        assert "session-session-123-" in result.output
        assert "-compressed.xml" in result.output

        # Should NOT output XML to stdout
        assert "<session>" not in result.output


# ============================================================================
# 10. Session ID Auto-Extraction and Filtering Tests
# ============================================================================


def test_preprocess_session_auto_extracts_session_id_from_filename(tmp_path: Path) -> None:
    """Test that session ID is auto-extracted from filename when not provided.

    This is a regression test for the bug where preprocess-session loaded ALL
    agent logs instead of filtering by session ID.
    """
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        # Main session log with session ID = "abc-123-xyz"
        main_log = Path("abc-123-xyz.jsonl")
        main_entries = [
            json.dumps(
                {
                    "sessionId": "abc-123-xyz",
                    "type": "user",
                    "message": {"content": "Hello"},
                }
            ),
            json.dumps(
                {
                    "sessionId": "abc-123-xyz",
                    "type": "assistant",
                    "message": {"content": [{"type": "text", "text": "Hi"}]},
                }
            ),
            json.dumps(
                {
                    "sessionId": "abc-123-xyz",
                    "type": "user",
                    "message": {"content": "Thanks"},
                }
            ),
        ]
        main_log.write_text("\n".join(main_entries), encoding="utf-8")

        # Agent log belonging to same session
        agent_match = Path("agent-match.jsonl")
        agent_match.write_text(
            json.dumps(
                {
                    "sessionId": "abc-123-xyz",
                    "type": "user",
                    "message": {"content": "Agent content"},
                }
            ),
            encoding="utf-8",
        )

        # Agent log belonging to DIFFERENT session (should be excluded)
        agent_other = Path("agent-other.jsonl")
        agent_other.write_text(
            json.dumps(
                {
                    "sessionId": "different-session",
                    "type": "user",
                    "message": {"content": "Other agent content"},
                }
            ),
            encoding="utf-8",
        )

        result = runner.invoke(preprocess_session, [str(main_log), "--stdout", "--no-filtering"])
        assert result.exit_code == 0

        # Main session should be included
        assert "<session>" in result.output
        assert "Hello" in result.output

        # Agent from same session should be included
        assert "Agent content" in result.output

        # Agent from different session should NOT be included
        assert "Other agent content" not in result.output


def test_preprocess_session_filters_agent_logs_by_session_id(tmp_path: Path) -> None:
    """Test that only agent logs matching session ID are included.

    Regression test for the bug where all agent logs were loaded regardless
    of session ID, causing a 67KB session to produce 10MB output.
    """
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        # Main session log
        main_log = Path("target-session.jsonl")
        sid = "target-session"
        main_entries = [
            json.dumps({"sessionId": sid, "type": "user", "message": {"content": "A"}}),
            json.dumps(
                {
                    "sessionId": sid,
                    "type": "assistant",
                    "message": {"content": [{"type": "text", "text": "B"}]},
                }
            ),
            json.dumps({"sessionId": sid, "type": "user", "message": {"content": "C"}}),
        ]
        main_log.write_text("\n".join(main_entries), encoding="utf-8")

        # Create multiple agent logs from different sessions
        for i in range(5):
            # Some agents from target session
            if i < 2:
                session_id = "target-session"
                content = f"target-agent-{i}"
            else:
                # Other agents from different sessions
                session_id = f"other-session-{i}"
                content = f"other-agent-{i}"

            agent = Path(f"agent-{i:03d}.jsonl")
            entry = {"sessionId": session_id, "type": "user", "message": {"content": content}}
            agent.write_text(json.dumps(entry), encoding="utf-8")

        result = runner.invoke(preprocess_session, [str(main_log), "--stdout", "--no-filtering"])
        assert result.exit_code == 0

        # Target session agents should be included
        assert "target-agent-0" in result.output
        assert "target-agent-1" in result.output

        # Other session agents should NOT be included
        assert "other-agent-2" not in result.output
        assert "other-agent-3" not in result.output
        assert "other-agent-4" not in result.output

        # Count sessions - should be 3 (main + 2 matching agents)
        assert result.output.count("<session>") == 3


# ============================================================================
# 11. Token Estimation Tests
# ============================================================================


def test_estimate_tokens_empty_string() -> None:
    """Test token estimation for empty string."""
    assert estimate_tokens("") == 0


def test_estimate_tokens_short_string() -> None:
    """Test token estimation for short string."""
    # 4 chars = 1 token
    assert estimate_tokens("abcd") == 1
    # 8 chars = 2 tokens
    assert estimate_tokens("abcdefgh") == 2


def test_estimate_tokens_long_string() -> None:
    """Test token estimation for longer strings."""
    # 100 chars = 25 tokens
    assert estimate_tokens("x" * 100) == 25
    # 1000 chars = 250 tokens
    assert estimate_tokens("y" * 1000) == 250


# ============================================================================
# 12. Split Entries to Chunks Tests
# ============================================================================


def test_split_entries_empty() -> None:
    """Test splitting empty entries list returns single empty session."""
    chunks = split_entries_to_chunks([], max_tokens=1000, source_label=None, enable_pruning=True)
    assert len(chunks) == 1
    assert "<session>" in chunks[0]
    assert "</session>" in chunks[0]


def test_split_entries_single_small_entry() -> None:
    """Test that single small entry returns single chunk."""
    entries = [{"type": "user", "message": {"content": "Hello"}}]
    chunks = split_entries_to_chunks(
        entries, max_tokens=1000, source_label=None, enable_pruning=True
    )
    assert len(chunks) == 1
    assert "Hello" in chunks[0]


def test_split_entries_splits_on_budget() -> None:
    """Test that entries are split when exceeding token budget."""
    # Create entries that together exceed budget but individually fit
    entries = [
        {"type": "user", "message": {"content": "A" * 100}},  # ~25 tokens
        {"type": "user", "message": {"content": "B" * 100}},  # ~25 tokens
        {"type": "user", "message": {"content": "C" * 100}},  # ~25 tokens
    ]
    # With max_tokens=40, should split into multiple chunks
    chunks = split_entries_to_chunks(entries, max_tokens=40, source_label=None, enable_pruning=True)
    # Should be more than 1 chunk
    assert len(chunks) > 1
    # Each chunk should be valid XML
    for chunk in chunks:
        assert "<session>" in chunk
        assert "</session>" in chunk


def test_split_entries_preserves_all_content() -> None:
    """Test that splitting preserves all original content."""
    entries = [
        {"type": "user", "message": {"content": "First message"}},
        {"type": "user", "message": {"content": "Second message"}},
        {"type": "user", "message": {"content": "Third message"}},
    ]
    # Use small budget to force splitting
    chunks = split_entries_to_chunks(entries, max_tokens=50, source_label=None, enable_pruning=True)

    # Concatenate all chunks and verify all content is present
    combined = "\n".join(chunks)
    assert "First message" in combined
    assert "Second message" in combined
    assert "Third message" in combined


def test_split_entries_includes_source_label() -> None:
    """Test that source label is included in all chunks."""
    entries = [
        {"type": "user", "message": {"content": "A" * 100}},
        {"type": "user", "message": {"content": "B" * 100}},
    ]
    chunks = split_entries_to_chunks(
        entries, max_tokens=40, source_label="agent-123", enable_pruning=True
    )

    # Source label should be in each chunk
    for chunk in chunks:
        assert '<meta source="agent-123" />' in chunk


def test_split_entries_each_chunk_is_valid_xml() -> None:
    """Test that each chunk is a valid XML document."""
    entries = [{"type": "user", "message": {"content": "Entry " + str(i)}} for i in range(10)]
    chunks = split_entries_to_chunks(entries, max_tokens=50, source_label=None, enable_pruning=True)

    for chunk in chunks:
        # Each chunk should have proper XML structure
        assert chunk.startswith("<session>")
        assert chunk.endswith("</session>")
        # Check for balanced tags
        assert chunk.count("<session>") == 1
        assert chunk.count("</session>") == 1


def test_split_entries_respects_token_limit() -> None:
    """Test that each chunk respects the token limit."""
    entries = [{"type": "user", "message": {"content": "Message " + "x" * 50}} for i in range(5)]
    max_tokens = 100
    chunks = split_entries_to_chunks(
        entries, max_tokens=max_tokens, source_label=None, enable_pruning=True
    )

    # Each chunk should be under the token limit (approximately)
    for chunk in chunks:
        chunk_tokens = estimate_tokens(chunk)
        # Allow some overhead for XML wrapper
        assert chunk_tokens <= max_tokens + 20


# ============================================================================
# 13. Max Tokens CLI Tests
# ============================================================================


def test_preprocess_session_max_tokens_creates_multiple_files(tmp_path: Path) -> None:
    """Test that --max-tokens creates multiple output files."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"
        log_file = Path(f"{session_id}.jsonl")

        # Create entries that will exceed a small token budget
        entries = []
        for i in range(10):
            entry = {
                "sessionId": session_id,
                "type": "user",
                "message": {"content": f"Message {i} " + "x" * 200},
            }
            entries.append(json.dumps(entry))

        # Add assistant messages to pass empty session check
        for i in range(10):
            entry = {
                "sessionId": session_id,
                "type": "assistant",
                "message": {"content": [{"type": "text", "text": f"Response {i}"}]},
            }
            entries.append(json.dumps(entry))

        log_file.write_text("\n".join(entries), encoding="utf-8")

        result = runner.invoke(
            preprocess_session, [str(log_file), "--max-tokens", "100", "--no-filtering"]
        )
        assert result.exit_code == 0

        # Should output multiple file paths
        output_lines = result.output.strip().split("\n")
        assert len(output_lines) > 1

        # Each path should exist and contain valid XML
        for line in output_lines:
            path = Path(line.strip())
            assert path.exists()
            content = path.read_text(encoding="utf-8")
            assert "<session>" in content
            assert "</session>" in content


def test_preprocess_session_max_tokens_stdout_uses_delimiter(tmp_path: Path) -> None:
    """Test that --max-tokens with --stdout uses chunk delimiter."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"
        log_file = Path(f"{session_id}.jsonl")

        # Create entries that will exceed a small token budget
        entries = []
        for i in range(10):
            entry = {
                "sessionId": session_id,
                "type": "user",
                "message": {"content": f"Message {i} " + "x" * 200},
            }
            entries.append(json.dumps(entry))

        # Add assistant messages
        for i in range(10):
            entry = {
                "sessionId": session_id,
                "type": "assistant",
                "message": {"content": [{"type": "text", "text": f"Response {i}"}]},
            }
            entries.append(json.dumps(entry))

        log_file.write_text("\n".join(entries), encoding="utf-8")

        result = runner.invoke(
            preprocess_session,
            [str(log_file), "--max-tokens", "100", "--stdout", "--no-filtering"],
        )
        assert result.exit_code == 0

        # Output should contain chunk delimiter
        assert "---CHUNK---" in result.output

        # Each chunk should be valid XML
        chunks = result.output.split("---CHUNK---")
        assert len(chunks) > 1
        for chunk in chunks:
            chunk = chunk.strip()
            if chunk:
                assert "<session>" in chunk
                assert "</session>" in chunk


def test_preprocess_session_max_tokens_no_split_if_under_budget(tmp_path: Path) -> None:
    """Test that --max-tokens doesn't split if content is under budget."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"
        log_file = Path(f"{session_id}.jsonl")

        # Create small entries that won't exceed budget
        entries = [
            json.dumps({"sessionId": session_id, "type": "user", "message": {"content": "Hello"}}),
            json.dumps(
                {
                    "sessionId": session_id,
                    "type": "assistant",
                    "message": {"content": [{"type": "text", "text": "Hi"}]},
                }
            ),
            json.dumps({"sessionId": session_id, "type": "user", "message": {"content": "Thanks"}}),
        ]
        log_file.write_text("\n".join(entries), encoding="utf-8")

        result = runner.invoke(
            preprocess_session, [str(log_file), "--max-tokens", "10000", "--no-filtering"]
        )
        assert result.exit_code == 0

        # Should output single file path (no splitting)
        output_lines = result.output.strip().split("\n")
        assert len(output_lines) == 1
        assert "part" not in output_lines[0]


def test_preprocess_session_max_tokens_preserves_content(tmp_path: Path) -> None:
    """Test that --max-tokens preserves all content across chunks."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "session-123"
        log_file = Path(f"{session_id}.jsonl")

        # Create entries with unique identifiable content
        messages = ["UNIQUE_MSG_AAA", "UNIQUE_MSG_BBB", "UNIQUE_MSG_CCC"]
        entries = []
        for msg in messages:
            content = msg + " " + "x" * 100
            entry = {"sessionId": session_id, "type": "user", "message": {"content": content}}
            entries.append(json.dumps(entry))
        # Add assistant response
        entries.append(
            json.dumps(
                {
                    "sessionId": session_id,
                    "type": "assistant",
                    "message": {"content": [{"type": "text", "text": "Response"}]},
                }
            )
        )

        log_file.write_text("\n".join(entries), encoding="utf-8")

        result = runner.invoke(
            preprocess_session,
            [str(log_file), "--max-tokens", "100", "--stdout", "--no-filtering"],
        )
        assert result.exit_code == 0

        # All unique messages should be present in output
        for msg in messages:
            assert msg in result.output


def test_preprocess_session_max_tokens_file_naming(tmp_path: Path) -> None:
    """Test that split files have correct naming pattern."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "test-session-abc"
        log_file = Path(f"{session_id}.jsonl")

        # Create enough content to force splitting
        entries = []
        for i in range(20):
            entries.append(
                json.dumps(
                    {
                        "sessionId": session_id,
                        "type": "user",
                        "message": {"content": f"Msg{i} " + "x" * 200},
                    }
                )
            )
            entries.append(
                json.dumps(
                    {
                        "sessionId": session_id,
                        "type": "assistant",
                        "message": {"content": [{"type": "text", "text": f"R{i}"}]},
                    }
                )
            )

        log_file.write_text("\n".join(entries), encoding="utf-8")

        result = runner.invoke(
            preprocess_session, [str(log_file), "--max-tokens", "50", "--no-filtering"]
        )
        assert result.exit_code == 0

        # Check file naming pattern
        output_lines = result.output.strip().split("\n")
        for i, line in enumerate(output_lines, start=1):
            path = Path(line.strip())
            # Should contain part number
            assert f"part{i}" in path.name
            # Should contain session ID
            assert session_id in path.name


# ============================================================================
# 14. Output Dir and Prefix Tests
# ============================================================================


def test_preprocess_session_output_dir_and_prefix_single_file(tmp_path: Path) -> None:
    """Test that --output-dir and --prefix create named file with session ID."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "abc-123-xyz"
        log_file = Path(f"{session_id}.jsonl")

        # Create entries with matching session ID
        entries = [
            json.dumps({"sessionId": session_id, "type": "user", "message": {"content": "Hello"}}),
            json.dumps(
                {
                    "sessionId": session_id,
                    "type": "assistant",
                    "message": {"content": [{"type": "text", "text": "Hi"}]},
                }
            ),
            json.dumps({"sessionId": session_id, "type": "user", "message": {"content": "Thanks"}}),
        ]
        log_file.write_text("\n".join(entries), encoding="utf-8")

        output_dir = tmp_path / "output"

        result = runner.invoke(
            preprocess_session,
            [
                str(log_file),
                "--output-dir",
                str(output_dir),
                "--prefix",
                "planning",
                "--no-filtering",
            ],
        )
        assert result.exit_code == 0

        # Check output file path
        output_lines = result.output.strip().split("\n")
        assert len(output_lines) == 1

        # File should be named: {prefix}-{session_id}.xml
        expected_file = output_dir / f"planning-{session_id}.xml"
        assert expected_file.exists()
        assert str(expected_file) == output_lines[0]

        # Verify content is valid XML
        content = expected_file.read_text(encoding="utf-8")
        assert "<session>" in content
        assert "</session>" in content


def test_preprocess_session_output_dir_creates_directory(tmp_path: Path) -> None:
    """Test that --output-dir creates the directory if it doesn't exist."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "test-session"
        log_file = Path(f"{session_id}.jsonl")

        entries = [
            json.dumps({"sessionId": session_id, "type": "user", "message": {"content": "A"}}),
            json.dumps(
                {
                    "sessionId": session_id,
                    "type": "assistant",
                    "message": {"content": [{"type": "text", "text": "B"}]},
                }
            ),
            json.dumps({"sessionId": session_id, "type": "user", "message": {"content": "C"}}),
        ]
        log_file.write_text("\n".join(entries), encoding="utf-8")

        # Use nested directory that doesn't exist
        output_dir = tmp_path / "deep" / "nested" / "dir"
        assert not output_dir.exists()

        result = runner.invoke(
            preprocess_session,
            [
                str(log_file),
                "--output-dir",
                str(output_dir),
                "--prefix",
                "impl",
                "--no-filtering",
            ],
        )
        assert result.exit_code == 0

        # Directory should be created
        assert output_dir.exists()
        # File should exist
        expected_file = output_dir / f"impl-{session_id}.xml"
        assert expected_file.exists()


def test_preprocess_session_output_dir_with_max_tokens_creates_chunks(tmp_path: Path) -> None:
    """Test that --output-dir with --max-tokens creates numbered chunk files."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "chunk-test"
        log_file = Path(f"{session_id}.jsonl")

        # Create enough content to force splitting
        entries = []
        for i in range(20):
            entries.append(
                json.dumps(
                    {
                        "sessionId": session_id,
                        "type": "user",
                        "message": {"content": f"Msg{i} " + "x" * 200},
                    }
                )
            )
            entries.append(
                json.dumps(
                    {
                        "sessionId": session_id,
                        "type": "assistant",
                        "message": {"content": [{"type": "text", "text": f"R{i}"}]},
                    }
                )
            )
        log_file.write_text("\n".join(entries), encoding="utf-8")

        output_dir = tmp_path / "chunks"

        result = runner.invoke(
            preprocess_session,
            [
                str(log_file),
                "--output-dir",
                str(output_dir),
                "--prefix",
                "impl",
                "--max-tokens",
                "100",
                "--no-filtering",
            ],
        )
        assert result.exit_code == 0

        # Should have multiple output files
        output_lines = result.output.strip().split("\n")
        assert len(output_lines) > 1

        # Each file should follow naming pattern: {prefix}-{session_id}-part{N}.xml
        for i, line in enumerate(output_lines, start=1):
            path = Path(line.strip())
            assert path.exists()
            assert path.name == f"impl-{session_id}-part{i}.xml"
            # Verify valid XML
            content = path.read_text(encoding="utf-8")
            assert "<session>" in content
            assert "</session>" in content


def test_preprocess_session_output_dir_requires_prefix(tmp_path: Path) -> None:
    """Test that --output-dir without --prefix raises error."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        # Create a dummy log file so the path validation passes
        log_file = Path("test.jsonl")
        log_file.write_text("{}", encoding="utf-8")

        result = runner.invoke(
            preprocess_session,
            [str(log_file), "--output-dir", "/some/dir"],
        )
        assert result.exit_code != 0
        assert "--output-dir and --prefix must be used together" in result.output


def test_preprocess_session_prefix_requires_output_dir(tmp_path: Path) -> None:
    """Test that --prefix without --output-dir raises error."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        # Create a dummy log file so the path validation passes
        log_file = Path("test.jsonl")
        log_file.write_text("{}", encoding="utf-8")

        result = runner.invoke(
            preprocess_session,
            [str(log_file), "--prefix", "planning"],
        )
        assert result.exit_code != 0
        assert "--output-dir and --prefix must be used together" in result.output


def test_preprocess_session_output_dir_and_stdout_mutually_exclusive(tmp_path: Path) -> None:
    """Test that --output-dir/--prefix and --stdout are mutually exclusive."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        log_file = Path("test.jsonl")
        log_file.write_text("{}", encoding="utf-8")

        result = runner.invoke(
            preprocess_session,
            [str(log_file), "--output-dir", str(tmp_path), "--prefix", "test", "--stdout"],
        )
        assert result.exit_code != 0
        assert "--output-dir/--prefix cannot be used with --stdout" in result.output


def test_preprocess_session_output_dir_preserves_all_content(tmp_path: Path) -> None:
    """Test that --output-dir preserves all content across chunks."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        session_id = "preserve-test"
        log_file = Path(f"{session_id}.jsonl")

        # Create entries with unique identifiable content
        messages = ["UNIQUE_AAA", "UNIQUE_BBB", "UNIQUE_CCC"]
        entries = []
        for msg in messages:
            content = msg + " " + "x" * 100
            entry = {"sessionId": session_id, "type": "user", "message": {"content": content}}
            entries.append(json.dumps(entry))
        # Add assistant response
        entries.append(
            json.dumps(
                {
                    "sessionId": session_id,
                    "type": "assistant",
                    "message": {"content": [{"type": "text", "text": "Response"}]},
                }
            )
        )
        log_file.write_text("\n".join(entries), encoding="utf-8")

        output_dir = tmp_path / "preserve"

        result = runner.invoke(
            preprocess_session,
            [
                str(log_file),
                "--output-dir",
                str(output_dir),
                "--prefix",
                "test",
                "--max-tokens",
                "100",
                "--no-filtering",
            ],
        )
        assert result.exit_code == 0

        # Read all output files and combine content
        combined_content = ""
        for line in result.output.strip().split("\n"):
            path = Path(line.strip())
            combined_content += path.read_text(encoding="utf-8")

        # All unique messages should be present
        for msg in messages:
            assert msg in combined_content
