"""Tests for ievo.hooks.precompact_save — PreCompact hook."""

from __future__ import annotations

import json
import sqlite3
from pathlib import Path
from unittest.mock import patch

from ievo.hooks.precompact_save import (
    _extract_from_assistant,
    _extract_from_user,
    _track_tool_use,
    _truncate,
    deduplicate,
    extract_observations,
    get_project_from_cwd,
    main,
    read_transcript,
    save_to_db,
)

# ── get_project_from_cwd ────────────────────────────────────


def test_get_project_from_cwd_with_projects_dir() -> None:
    with patch("os.getcwd", return_value="/home/user/projects/myapp/src/deep"):
        assert get_project_from_cwd() == "myapp"


def test_get_project_from_cwd_with_repos_dir() -> None:
    with patch("os.getcwd", return_value="/home/user/repos/myrepo"):
        assert get_project_from_cwd() == "myrepo"


def test_get_project_from_cwd_with_src_dir() -> None:
    with patch("os.getcwd", return_value="/workspace/src/lib/module"):
        assert get_project_from_cwd() == "lib"


def test_get_project_from_cwd_fallback() -> None:
    with patch("os.getcwd", return_value="/home/user/misc/folder"):
        assert get_project_from_cwd() == "folder"


# ── read_transcript ──────────────────────────────────────────


def test_read_transcript_normal(tmp_path: Path) -> None:
    transcript = tmp_path / "session.jsonl"
    transcript.write_text(
        json.dumps({"type": "assistant", "message": {"role": "assistant", "content": "hi"}})
        + "\n"
        + json.dumps({"type": "user", "message": {"role": "user", "content": "hello"}})
        + "\n"
    )
    messages = read_transcript(str(transcript))
    assert len(messages) == 2


def test_read_transcript_missing_file() -> None:
    messages = read_transcript("/nonexistent/path/transcript.jsonl")
    assert messages == []


def test_read_transcript_too_large(tmp_path: Path) -> None:
    transcript = tmp_path / "huge.jsonl"
    # Create a file that reports as >50MB using sparse approach
    transcript.write_text("x")
    with patch.object(
        type(transcript.stat()),
        "st_size",
        new_callable=lambda: property(lambda self: 60 * 1024 * 1024),
    ):
        # Use mock instead
        pass

    # Simpler: just mock stat
    with patch("ievo.hooks.precompact_save.Path") as mock_path_cls:
        mock_path = mock_path_cls.return_value
        mock_path.exists.return_value = True
        mock_stat = mock_path.stat.return_value
        mock_stat.st_size = 60 * 1024 * 1024
        messages = read_transcript(str(transcript))
        assert messages == []


def test_read_transcript_empty_lines(tmp_path: Path) -> None:
    transcript = tmp_path / "session.jsonl"
    transcript.write_text(
        "\n" + json.dumps({"type": "user"}) + "\n\n" + json.dumps({"type": "assistant"}) + "\n"
    )
    messages = read_transcript(str(transcript))
    assert len(messages) == 2


def test_read_transcript_invalid_json(tmp_path: Path) -> None:
    transcript = tmp_path / "session.jsonl"
    transcript.write_text("not valid json\n" + json.dumps({"type": "user"}) + "\n")
    messages = read_transcript(str(transcript))
    assert len(messages) == 1


# ── _track_tool_use ──────────────────────────────────────────


def test_track_tool_use_edit() -> None:
    modified: set[str] = set()
    read: set[str] = set()
    _track_tool_use("Edit", {"file_path": "/path/to/file.py"}, modified, read)
    assert "file.py" in modified
    assert len(read) == 0


def test_track_tool_use_write() -> None:
    modified: set[str] = set()
    read: set[str] = set()
    _track_tool_use("Write", {"file_path": "/path/to/new.py"}, modified, read)
    assert "new.py" in modified


def test_track_tool_use_notebook_edit() -> None:
    modified: set[str] = set()
    read: set[str] = set()
    _track_tool_use("NotebookEdit", {"file_path": "/path/to/nb.ipynb"}, modified, read)
    assert "nb.ipynb" in modified


def test_track_tool_use_read() -> None:
    modified: set[str] = set()
    read: set[str] = set()
    _track_tool_use("Read", {"file_path": "/path/to/config.yaml"}, modified, read)
    assert "config.yaml" in read
    assert len(modified) == 0


def test_track_tool_use_empty_path() -> None:
    modified: set[str] = set()
    read: set[str] = set()
    _track_tool_use("Edit", {"file_path": ""}, modified, read)
    assert len(modified) == 0


def test_track_tool_use_read_empty_path() -> None:
    modified: set[str] = set()
    read: set[str] = set()
    _track_tool_use("Read", {"file_path": ""}, modified, read)
    assert len(read) == 0


def test_track_tool_use_unknown_tool() -> None:
    modified: set[str] = set()
    read: set[str] = set()
    _track_tool_use("Bash", {"command": "ls"}, modified, read)
    assert len(modified) == 0
    assert len(read) == 0


# ── _extract_from_assistant ──────────────────────────────────


def test_extract_assistant_decision() -> None:
    obs: list[dict] = []
    _extract_from_assistant("We decided to use PostgreSQL for the database backend", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "decision"


def test_extract_assistant_bug_fix() -> None:
    obs: list[dict] = []
    _extract_from_assistant("Fixed the authentication bug by updating the token validation", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "discovery"


def test_extract_assistant_implementation() -> None:
    obs: list[dict] = []
    _extract_from_assistant("Implemented the new caching layer for API responses", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "change"


def test_extract_assistant_short_line_ignored() -> None:
    obs: list[dict] = []
    _extract_from_assistant("decided to", obs)
    assert len(obs) == 0


def test_extract_assistant_no_match() -> None:
    obs: list[dict] = []
    _extract_from_assistant("The tests are passing now.", obs)
    assert len(obs) == 0


def test_extract_assistant_the_approach() -> None:
    obs: list[dict] = []
    _extract_from_assistant("The approach we're taking is to use microservices", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "decision"


def test_extract_assistant_root_cause() -> None:
    obs: list[dict] = []
    _extract_from_assistant("The root cause was a missing null check in the handler", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "discovery"


def test_extract_assistant_added_new() -> None:
    obs: list[dict] = []
    _extract_from_assistant("Added new middleware for request validation and logging", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "change"


def test_extract_assistant_chose_to() -> None:
    obs: list[dict] = []
    _extract_from_assistant("We chose to implement the feature using a decorator pattern", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "decision"


def test_extract_assistant_going_with() -> None:
    obs: list[dict] = []
    _extract_from_assistant("Going with the simpler approach of using environment variables", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "decision"


def test_extract_assistant_the_issue_was() -> None:
    obs: list[dict] = []
    _extract_from_assistant("The issue was that the config file wasn't being loaded correctly", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "discovery"


def test_extract_assistant_the_bug() -> None:
    obs: list[dict] = []
    _extract_from_assistant("The bug existed in the validation layer for user input", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "discovery"


def test_extract_assistant_created() -> None:
    obs: list[dict] = []
    _extract_from_assistant("Created the new authentication module with JWT support", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "change"


def test_extract_assistant_bug_short_ignored() -> None:
    """Bug-fix keyword but too short to be meaningful."""
    obs: list[dict] = []
    _extract_from_assistant("fixed it", obs)
    assert len(obs) == 0


def test_extract_assistant_impl_short_ignored() -> None:
    """Implementation keyword but too short."""
    obs: list[dict] = []
    _extract_from_assistant("implemented", obs)
    assert len(obs) == 0


# ── _extract_from_user ───────────────────────────────────────


def test_extract_user_correction() -> None:
    obs: list[dict] = []
    _extract_from_user("That's wrong, the endpoint should be /api/v2/users", obs)
    assert len(obs) == 1
    assert obs[0]["type"] == "discovery"
    assert "User correction" in obs[0]["title"]


def test_extract_user_fix() -> None:
    obs: list[dict] = []
    _extract_from_user("Please fix the import path, it should be from core.utils", obs)
    assert len(obs) == 1


def test_extract_user_russian_correction() -> None:
    """Russian 'not right' keyword triggers correction detection."""
    obs: list[dict] = []
    _extract_from_user("\u043d\u0435 \u0442\u0430\u043a, use another approach", obs)
    assert len(obs) == 1


def test_extract_user_russian_fix() -> None:
    """Russian 'change it' keyword triggers correction detection."""
    obs: list[dict] = []
    _extract_from_user(
        "\u043f\u043e\u043c\u0435\u043d\u044f\u0439 this to a cached implementation", obs
    )
    assert len(obs) == 1


def test_extract_user_russian_isprav() -> None:
    """Russian 'fix' keyword triggers correction detection."""
    obs: list[dict] = []
    _extract_from_user(
        "\u0438\u0441\u043f\u0440\u0430\u0432\u044c the error in request handling", obs
    )
    assert len(obs) == 1


def test_extract_user_too_short() -> None:
    obs: list[dict] = []
    _extract_from_user("fix it", obs)
    assert len(obs) == 0


def test_extract_user_too_long() -> None:
    obs: list[dict] = []
    _extract_from_user("fix " + "x" * 500, obs)
    assert len(obs) == 0


def test_extract_user_no_correction() -> None:
    obs: list[dict] = []
    _extract_from_user("Looks good, let's continue", obs)
    assert len(obs) == 0


# ── _truncate ────────────────────────────────────────────────


def test_truncate_short() -> None:
    assert _truncate("hello world", 80) == "hello world"


def test_truncate_long() -> None:
    result = _truncate("x" * 100, 50)
    assert len(result) == 50
    assert result.endswith("...")


def test_truncate_strips_markdown() -> None:
    assert _truncate("## Heading text", 80) == "Heading text"
    assert _truncate("- list item", 80) == "list item"
    assert _truncate("* bold text", 80) == "bold text"
    assert _truncate("> quoted text", 80) == "quoted text"


# ── extract_observations ─────────────────────────────────────


def test_extract_observations_assistant_decision() -> None:
    messages = [
        {
            "type": "assistant",
            "message": {
                "role": "assistant",
                "content": "We decided to use Redis for caching layer implementation",
            },
        }
    ]
    obs = extract_observations(messages)
    assert len(obs) == 1
    assert obs[0]["type"] == "decision"


def test_extract_observations_with_tool_use() -> None:
    messages = [
        {
            "type": "assistant",
            "message": {
                "role": "assistant",
                "content": [
                    {"type": "text", "text": "Let me edit that file"},
                    {
                        "type": "tool_use",
                        "name": "Edit",
                        "input": {"file_path": "/path/to/app.py"},
                    },
                ],
            },
        }
    ]
    obs = extract_observations(messages)
    assert any(o["type"] == "change" for o in obs)


def test_extract_observations_user_correction() -> None:
    messages = [
        {
            "type": "user",
            "message": {
                "role": "user",
                "content": "That's wrong, the database should use PostgreSQL instead",
            },
        }
    ]
    obs = extract_observations(messages)
    assert len(obs) == 1
    assert obs[0]["type"] == "discovery"


def test_extract_observations_skips_system() -> None:
    messages = [{"type": "system", "message": {"content": "System message"}}]
    obs = extract_observations(messages)
    assert len(obs) == 0


def test_extract_observations_skips_empty_inner() -> None:
    messages = [{"type": "assistant", "message": None}]
    obs = extract_observations(messages)
    assert len(obs) == 0


def test_extract_observations_skips_empty_content() -> None:
    messages = [{"type": "assistant", "message": {"role": "assistant", "content": ""}}]
    obs = extract_observations(messages)
    assert len(obs) == 0


def test_extract_observations_content_list_with_strings() -> None:
    messages = [
        {
            "type": "assistant",
            "message": {
                "role": "assistant",
                "content": ["We decided to use a monorepo structure for the project"],
            },
        }
    ]
    obs = extract_observations(messages)
    assert len(obs) == 1


def test_extract_observations_content_non_string() -> None:
    """Content that is neither str nor list should be skipped."""
    messages = [{"type": "assistant", "message": {"role": "assistant", "content": 42}}]
    obs = extract_observations(messages)
    assert len(obs) == 0


def test_extract_observations_no_message_key() -> None:
    """Missing 'message' key should be skipped."""
    messages = [{"type": "assistant"}]
    obs = extract_observations(messages)
    assert len(obs) == 0


def test_extract_observations_empty_messages() -> None:
    obs = extract_observations([])
    assert len(obs) == 0


def test_extract_observations_files_modified() -> None:
    """File tracking adds a summary observation."""
    messages = [
        {
            "type": "assistant",
            "message": {
                "role": "assistant",
                "content": [
                    {
                        "type": "tool_use",
                        "name": "Write",
                        "input": {"file_path": "/path/to/new_file.py"},
                    },
                ],
            },
        }
    ]
    obs = extract_observations(messages)
    assert any("Files modified" in o.get("title", "") for o in obs)


# ── deduplicate ──────────────────────────────────────────────


def test_deduplicate_no_duplicates() -> None:
    obs = [
        {"text": "first observation", "type": "decision"},
        {"text": "second observation", "type": "change"},
    ]
    result = deduplicate(obs)
    assert len(result) == 2


def test_deduplicate_with_duplicates() -> None:
    obs = [
        {"text": "same text", "type": "decision"},
        {"text": "different text", "type": "change"},
        {"text": "same text", "type": "discovery"},
    ]
    result = deduplicate(obs)
    assert len(result) == 2


def test_deduplicate_empty() -> None:
    assert deduplicate([]) == []


# ── save_to_db ───────────────────────────────────────────────


def _create_test_db(db_path: Path) -> None:
    """Create a minimal claude-mem schema for testing."""
    conn = sqlite3.connect(str(db_path))
    conn.execute(
        """CREATE TABLE IF NOT EXISTS sdk_sessions (
            content_session_id TEXT PRIMARY KEY,
            memory_session_id TEXT,
            project TEXT,
            started_at TEXT,
            started_at_epoch INTEGER,
            status TEXT
        )"""
    )
    conn.execute(
        """CREATE TABLE IF NOT EXISTS observations (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            memory_session_id TEXT,
            project TEXT,
            text TEXT,
            type TEXT,
            title TEXT,
            content_hash TEXT,
            created_at TEXT,
            created_at_epoch INTEGER
        )"""
    )
    conn.commit()
    conn.close()


def test_save_to_db_no_db() -> None:
    """Returns 0 when DB doesn't exist."""
    with patch("ievo.hooks.precompact_save.DB_PATH", Path("/nonexistent/db.sqlite")):
        result = save_to_db([{"text": "test", "type": "decision"}], "sess-1", "proj")
    assert result == 0


def test_save_to_db_success(tmp_path: Path) -> None:
    """Saves observations to DB."""
    db_path = tmp_path / "claude-mem.db"
    _create_test_db(db_path)

    obs = [
        {"text": "first observation", "type": "decision", "title": "Decision 1"},
        {"text": "second observation", "type": "change", "title": "Change 1"},
    ]
    with patch("ievo.hooks.precompact_save.DB_PATH", db_path):
        saved = save_to_db(obs, "sess-1", "test-project")
    assert saved == 2

    # Verify data in DB
    conn = sqlite3.connect(str(db_path))
    rows = conn.execute("SELECT * FROM observations").fetchall()
    conn.close()
    assert len(rows) == 2


def test_save_to_db_dedup_recent(tmp_path: Path) -> None:
    """Skips observations with same hash within last hour."""
    db_path = tmp_path / "claude-mem.db"
    _create_test_db(db_path)

    obs = [{"text": "duplicate text", "type": "decision", "title": "Dup"}]

    with patch("ievo.hooks.precompact_save.DB_PATH", db_path):
        saved1 = save_to_db(obs, "sess-1", "proj")
        saved2 = save_to_db(obs, "sess-2", "proj")

    assert saved1 == 1
    assert saved2 == 0


def test_save_to_db_obs_without_title(tmp_path: Path) -> None:
    """Observation without title uses empty string."""
    db_path = tmp_path / "claude-mem.db"
    _create_test_db(db_path)

    obs = [{"text": "no title observation", "type": "discovery"}]
    with patch("ievo.hooks.precompact_save.DB_PATH", db_path):
        saved = save_to_db(obs, "sess-1", "proj")
    assert saved == 1


# ── main ─────────────────────────────────────────────────────


def test_main_empty_stdin() -> None:
    """Empty stdin does nothing."""
    with patch("sys.stdin") as mock_stdin:
        mock_stdin.read.return_value = ""
        main()


def test_main_invalid_json() -> None:
    """Invalid JSON does nothing."""
    with patch("sys.stdin") as mock_stdin:
        mock_stdin.read.return_value = "not json"
        main()


def test_main_no_transcript_path() -> None:
    """Missing transcript_path does nothing."""
    with patch("sys.stdin") as mock_stdin:
        mock_stdin.read.return_value = json.dumps({"session_id": "test"})
        main()


def test_main_empty_transcript(tmp_path: Path) -> None:
    """Empty transcript file does nothing."""
    transcript = tmp_path / "empty.jsonl"
    transcript.write_text("")

    hook_input = json.dumps({"transcript_path": str(transcript), "session_id": "test-session"})
    with patch("sys.stdin") as mock_stdin:
        mock_stdin.read.return_value = hook_input
        main()


def test_main_no_observations(tmp_path: Path) -> None:
    """Transcript with no extractable observations does nothing."""
    transcript = tmp_path / "session.jsonl"
    transcript.write_text(
        json.dumps({"type": "system", "message": {"content": "system msg"}}) + "\n"
    )

    hook_input = json.dumps({"transcript_path": str(transcript), "session_id": "test-session"})
    with patch("sys.stdin") as mock_stdin:
        mock_stdin.read.return_value = hook_input
        main()


def test_main_with_observations(tmp_path: Path) -> None:
    """Full pipeline: transcript → observations → DB."""
    transcript = tmp_path / "session.jsonl"
    transcript.write_text(
        json.dumps(
            {
                "type": "assistant",
                "message": {
                    "role": "assistant",
                    "content": "We decided to use SQLite for the local storage backend",
                },
            }
        )
        + "\n"
    )

    db_path = tmp_path / "claude-mem.db"
    _create_test_db(db_path)

    hook_input = json.dumps({"transcript_path": str(transcript), "session_id": "test-session"})
    with (
        patch("sys.stdin") as mock_stdin,
        patch("ievo.hooks.precompact_save.DB_PATH", db_path),
    ):
        mock_stdin.read.return_value = hook_input
        main()

    conn = sqlite3.connect(str(db_path))
    rows = conn.execute("SELECT * FROM observations").fetchall()
    conn.close()
    assert len(rows) >= 1


def test_main_uses_default_session_id(tmp_path: Path) -> None:
    """When session_id missing, uses time-based default."""
    transcript = tmp_path / "session.jsonl"
    transcript.write_text(
        json.dumps(
            {
                "type": "assistant",
                "message": {
                    "role": "assistant",
                    "content": "We decided to implement retry logic with exponential backoff",
                },
            }
        )
        + "\n"
    )

    db_path = tmp_path / "claude-mem.db"
    _create_test_db(db_path)

    hook_input = json.dumps({"transcript_path": str(transcript)})
    with (
        patch("sys.stdin") as mock_stdin,
        patch("ievo.hooks.precompact_save.DB_PATH", db_path),
    ):
        mock_stdin.read.return_value = hook_input
        main()


def test_main_stdin_os_error() -> None:
    """OSError reading stdin does nothing."""
    with patch("sys.stdin") as mock_stdin:
        mock_stdin.read.side_effect = OSError("broken pipe")
        main()


def test_main_caps_observations(tmp_path: Path) -> None:
    """Observations capped at 50."""
    transcript = tmp_path / "session.jsonl"
    lines = []
    for i in range(60):
        lines.append(
            json.dumps(
                {
                    "type": "assistant",
                    "message": {
                        "role": "assistant",
                        "content": f"We decided to refactor module {i} with unique approach {i}",
                    },
                }
            )
        )
    transcript.write_text("\n".join(lines) + "\n")

    db_path = tmp_path / "claude-mem.db"
    _create_test_db(db_path)

    hook_input = json.dumps({"transcript_path": str(transcript), "session_id": "test"})
    with (
        patch("sys.stdin") as mock_stdin,
        patch("ievo.hooks.precompact_save.DB_PATH", db_path),
    ):
        mock_stdin.read.return_value = hook_input
        main()

    conn = sqlite3.connect(str(db_path))
    rows = conn.execute("SELECT * FROM observations").fetchall()
    conn.close()
    assert len(rows) <= 50


def test_main_saved_count_printed(tmp_path: Path, capsys) -> None:
    """Output message when observations are saved."""
    transcript = tmp_path / "session.jsonl"
    transcript.write_text(
        json.dumps(
            {
                "type": "assistant",
                "message": {
                    "role": "assistant",
                    "content": "We decided to add comprehensive error handling to all endpoints",
                },
            }
        )
        + "\n"
    )

    db_path = tmp_path / "claude-mem.db"
    _create_test_db(db_path)

    hook_input = json.dumps({"transcript_path": str(transcript), "session_id": "test-session"})
    with (
        patch("sys.stdin") as mock_stdin,
        patch("ievo.hooks.precompact_save.DB_PATH", db_path),
    ):
        mock_stdin.read.return_value = hook_input
        main()

    captured = capsys.readouterr()
    assert "PreCompact: saved" in captured.out
