"""Tests for .crv sidecar file persistence."""

from __future__ import annotations

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

import pytest

from carve.models import ProjectStore, TreeStore
from carve.parser import TreeSitterParser
from carve.settings import CarveSettings, load_settings
from carve.tools import (
    _node_id_to_file_relative,
    _read_sidecar,
    _restore_metadata_from_sidecar,
    _write_sidecar_entry,
    create_file,
    delete,
    insert,
    move,
    project_import,
    update,
)


@pytest.fixture
def parser() -> TreeSitterParser:
    return TreeSitterParser()


@pytest.fixture
def sample_python() -> str:
    return '''class User:
    """A user."""

    def __init__(self, name: str):
        self.name = name

    def greet(self) -> str:
        return f"Hello, {self.name}"
'''


@pytest.fixture
def tree_store(parser: TreeSitterParser, sample_python: str, tmp_path: Path) -> TreeStore:
    """Create a TreeStore backed by a temp file."""
    py_file = tmp_path / "app.py"
    py_file.write_text(sample_python)
    store = parser.to_tree_store(sample_python, "python")
    store.file_path = str(py_file)
    return store


@pytest.fixture
def project_dir(tmp_path: Path, sample_python: str) -> Path:
    """Create a minimal project directory."""
    proj = tmp_path / "myproject"
    proj.mkdir()
    (proj / "app.py").write_text(sample_python)
    (proj / "util.py").write_text("def helper():\n    pass\n")
    return proj


# =============================================================================
# TestSidecarSettings
# =============================================================================


class TestSidecarSettings:
    def test_default_enabled(self) -> None:
        settings = CarveSettings()
        assert settings.sidecars is True

    def test_disable_via_toml(self, tmp_path: Path) -> None:
        toml = tmp_path / "carvesettings.toml"
        toml.write_text("sidecars = false\n")
        settings = load_settings(tmp_path)
        assert settings.sidecars is False

    def test_missing_key_defaults_true(self, tmp_path: Path) -> None:
        toml = tmp_path / "carvesettings.toml"
        toml.write_text("[formatters]\n")
        settings = load_settings(tmp_path)
        assert settings.sidecars is True


# =============================================================================
# TestNodeIdToFileRelative
# =============================================================================


class TestNodeIdToFileRelative:
    def test_tree_store_strips_module_prefix(self, tree_store: TreeStore) -> None:
        rel = _node_id_to_file_relative(tree_store, "module::User::greet")
        assert rel == "User::greet"

    def test_tree_store_root_returns_empty(self, tree_store: TreeStore) -> None:
        assert tree_store.root is not None
        rel = _node_id_to_file_relative(tree_store, tree_store.root.node_id)
        assert rel == ""

    def test_project_store_strips_file_prefix(
        self, parser: TreeSitterParser, project_dir: Path
    ) -> None:
        store = parser.to_project_store(project_dir)
        # Find a code node in app.py
        app_file_id = None
        for fid in store.file_sources:
            if fid.endswith("app.py"):
                app_file_id = fid
                break
        assert app_file_id is not None
        code_node_id = f"{app_file_id}::User::greet"
        rel = _node_id_to_file_relative(store, code_node_id)
        assert rel == "User::greet"

    def test_project_store_file_node_returns_empty(
        self, parser: TreeSitterParser, project_dir: Path
    ) -> None:
        store = parser.to_project_store(project_dir)
        for fid in store.file_sources:
            if fid.endswith("app.py"):
                assert _node_id_to_file_relative(store, fid) == ""
                break


# =============================================================================
# TestWriteSidecarEntry
# =============================================================================


class TestWriteSidecarEntry:
    def test_appends_jsonl_line(self, tree_store: TreeStore, tmp_path: Path) -> None:
        _write_sidecar_entry(
            tree_store,
            None,
            "update",
            "module::User::greet",
            model="claude",
            prompt="make it better",
        )
        crv = tmp_path / "app.py.crv"
        assert crv.exists()
        entries = _read_sidecar(crv)
        assert len(entries) == 1
        assert entries[0]["operation"] == "update"
        assert entries[0]["node_id"] == "User::greet"
        assert entries[0]["model"] == "claude"
        assert entries[0]["prompt"] == "make it better"

    def test_skips_when_disabled(self, tree_store: TreeStore, tmp_path: Path) -> None:
        # Write a carvesettings.toml that disables sidecars
        settings_file = tmp_path / "carvesettings.toml"
        settings_file.write_text("sidecars = false\n")
        _write_sidecar_entry(
            tree_store,
            None,
            "update",
            "module::User::greet",
        )
        crv = tmp_path / "app.py.crv"
        assert not crv.exists()

    def test_skips_when_no_disk_path(self, parser: TreeSitterParser) -> None:
        store = parser.to_tree_store("x = 1", "python")
        # No file_path set → no sidecar written (no crash either)
        _write_sidecar_entry(store, None, "update", "module::x")
        # Nothing to assert except no exception

    def test_handles_oserror_gracefully(self, tree_store: TreeStore, tmp_path: Path) -> None:
        with patch("pathlib.Path.open", side_effect=OSError("disk full")):
            # Should not raise
            _write_sidecar_entry(
                tree_store,
                None,
                "update",
                "module::User",
            )

    def test_notes_field_recorded(self, tree_store: TreeStore, tmp_path: Path) -> None:
        _write_sidecar_entry(
            tree_store,
            None,
            "update",
            "module::User::greet",
            notes="refactored to f-string",
        )
        crv = tmp_path / "app.py.crv"
        entries = _read_sidecar(crv)
        assert entries[0]["notes"] == "refactored to f-string"


# =============================================================================
# TestReadSidecar
# =============================================================================


class TestReadSidecar:
    def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
        assert _read_sidecar(tmp_path / "nope.crv") == []

    def test_corrupted_json_returns_empty(self, tmp_path: Path) -> None:
        crv = tmp_path / "test.py.crv"
        crv.write_text("NOT VALID JSON AT ALL")
        entries = _read_sidecar(crv)
        assert entries == []

    def test_non_array_json_returns_empty(self, tmp_path: Path) -> None:
        crv = tmp_path / "test.py.crv"
        crv.write_text('{"operation":"insert","node_id":"Foo"}')
        entries = _read_sidecar(crv)
        assert entries == []

    def test_reads_json_array(self, tmp_path: Path) -> None:
        crv = tmp_path / "test.py.crv"
        data = [
            {"operation": "insert", "node_id": "Foo"},
            {"operation": "update", "node_id": "Foo"},
        ]
        crv.write_text(json.dumps(data, indent=2))
        entries = _read_sidecar(crv)
        assert len(entries) == 2
        assert entries[0]["operation"] == "insert"
        assert entries[1]["operation"] == "update"


# =============================================================================
# TestSidecarOnMutations
# =============================================================================


class TestSidecarOnMutations:
    def test_insert_writes_sidecar(self, tree_store: TreeStore, tmp_path: Path) -> None:
        result = insert(
            tree_store,
            "module::User",
            "end",
            "def new_method(self):\n    pass",
            prompt="add new method",
            model="gpt-4",
            notes="test insert",
        )
        assert "error" not in result
        crv = tmp_path / "app.py.crv"
        assert crv.exists()
        entries = _read_sidecar(crv)
        assert len(entries) == 1
        assert entries[0]["operation"] == "insert"
        assert entries[0]["prompt"] == "add new method"
        assert entries[0]["model"] == "gpt-4"
        assert entries[0]["notes"] == "test insert"

    def test_update_writes_sidecar_with_model_prompt(
        self, tree_store: TreeStore, tmp_path: Path
    ) -> None:
        result = update(
            tree_store,
            "module::User::greet",
            'def greet(self) -> str:\n    return "Hi!"',
            prompt="simplify greeting",
            model="claude-sonnet",
            notes="simplified",
        )
        assert "error" not in result
        crv = tmp_path / "app.py.crv"
        entries = _read_sidecar(crv)
        assert len(entries) == 1
        assert entries[0]["operation"] == "update"
        assert entries[0]["node_id"] == "User::greet"
        assert entries[0]["model"] == "claude-sonnet"
        assert entries[0]["notes"] == "simplified"

    def test_delete_writes_sidecar(self, tree_store: TreeStore, tmp_path: Path) -> None:
        result = delete(tree_store, "module::User::greet", notes="no longer needed")
        assert "error" not in result
        crv = tmp_path / "app.py.crv"
        entries = _read_sidecar(crv)
        assert len(entries) == 1
        assert entries[0]["operation"] == "delete"
        assert entries[0]["notes"] == "no longer needed"

    def test_move_writes_sidecar(self, tree_store: TreeStore, tmp_path: Path) -> None:
        # Insert a second class to move the method into
        insert(tree_store, "module", "end", "class Admin:\n    pass")
        result = move(
            tree_store,
            "module::User::greet",
            "module::Admin",
            "end",
            notes="moved to Admin",
        )
        assert "error" not in result
        crv = tmp_path / "app.py.crv"
        entries = _read_sidecar(crv)
        # At least one move entry
        move_entries = [e for e in entries if e["operation"] == "move"]
        assert len(move_entries) == 1
        assert move_entries[0]["notes"] == "moved to Admin"

    def test_create_file_writes_sidecar(self, parser: TreeSitterParser, project_dir: Path) -> None:
        store = parser.to_project_store(project_dir)
        root_id = store.root.node_id if store.root else ""
        result = create_file(
            store,
            root_id,
            "new_file.py",
            "x = 1\n",
            prompt="create config",
            model="claude",
            notes="initial setup",
        )
        assert "error" not in result
        crv = project_dir / "new_file.py.crv"
        assert crv.exists()
        entries = _read_sidecar(crv)
        assert len(entries) == 1
        assert entries[0]["operation"] == "create_file"
        assert entries[0]["prompt"] == "create config"

    def test_multiple_ops_append(self, tree_store: TreeStore, tmp_path: Path) -> None:
        update(
            tree_store,
            "module::User::greet",
            'def greet(self) -> str:\n    return "Hi!"',
            prompt="v1",
        )
        update(
            tree_store,
            "module::User::greet",
            'def greet(self) -> str:\n    return "Hey!"',
            prompt="v2",
        )
        crv = tmp_path / "app.py.crv"
        entries = _read_sidecar(crv)
        assert len(entries) == 2
        assert entries[0]["prompt"] == "v1"
        assert entries[1]["prompt"] == "v2"

    def test_failed_mutation_no_sidecar(self, tree_store: TreeStore, tmp_path: Path) -> None:
        # Attempt to update a non-existent node
        result = update(tree_store, "module::DoesNotExist", "def x(): pass")
        assert "error" in result
        crv = tmp_path / "app.py.crv"
        assert not crv.exists()


# =============================================================================
# TestRestoreMetadata
# =============================================================================


class TestRestoreMetadata:
    def test_restores_model_and_prompt(self, tree_store: TreeStore, tmp_path: Path) -> None:
        # Write a sidecar manually
        crv = tmp_path / "app.py.crv"
        entry = {
            "operation": "update",
            "node_id": "User::greet",
            "timestamp": "2025-01-01T00:00:00+00:00",
            "model": "claude-opus",
            "prompt": "make greeting fancy",
            "notes": None,
        }
        crv.write_text(json.dumps([entry], indent=2))

        _restore_metadata_from_sidecar(tree_store)

        node = tree_store.get_node("module::User::greet")
        assert node is not None
        assert node.metadata.model == "claude-opus"
        assert node.metadata.prompt == "make greeting fancy"
        assert node.metadata.version == 1

    def test_latest_entry_wins(self, tree_store: TreeStore, tmp_path: Path) -> None:
        crv = tmp_path / "app.py.crv"
        entries = [
            {
                "operation": "update",
                "node_id": "User::greet",
                "timestamp": "2025-01-01T00:00:00+00:00",
                "model": "gpt-4",
                "prompt": "first change",
                "notes": None,
            },
            {
                "operation": "update",
                "node_id": "User::greet",
                "timestamp": "2025-01-01T01:00:00+00:00",
                "model": "claude",
                "prompt": "second change",
                "notes": None,
            },
        ]
        crv.write_text(json.dumps(entries, indent=2))

        _restore_metadata_from_sidecar(tree_store)

        node = tree_store.get_node("module::User::greet")
        assert node is not None
        assert node.metadata.model == "claude"
        assert node.metadata.prompt == "second change"
        assert node.metadata.version == 2
        assert len(node.metadata.history) == 1
        assert node.metadata.history[0]["model"] == "gpt-4"

    def test_missing_crv_no_error(self, tree_store: TreeStore) -> None:
        # No .crv file → should do nothing, no error
        _restore_metadata_from_sidecar(tree_store)

    def test_corrupted_json_no_metadata(self, tree_store: TreeStore, tmp_path: Path) -> None:
        crv = tmp_path / "app.py.crv"
        crv.write_text("NOT VALID JSON AT ALL")
        _restore_metadata_from_sidecar(tree_store)
        node = tree_store.get_node("module::User::greet")
        assert node is not None
        assert node.metadata.model is None

    def test_tree_store_round_trip(self, tree_store: TreeStore, tmp_path: Path) -> None:
        """Mutation → sidecar → re-import → metadata restored."""
        update(
            tree_store,
            "module::User::greet",
            'def greet(self) -> str:\n    return "Hi!"',
            prompt="simplify",
            model="test-model",
        )

        # Re-import from disk
        store2 = project_import(str(tmp_path / "app.py"))
        node = store2.get_node("module::User::greet")
        assert node is not None
        assert node.metadata.model == "test-model"
        assert node.metadata.prompt == "simplify"

    def test_project_store_round_trip(self, parser: TreeSitterParser, project_dir: Path) -> None:
        """Mutation on ProjectStore → sidecar → re-import → metadata restored."""
        store = parser.to_project_store(project_dir)

        # Find the app.py file node
        app_file_id = None
        for fid in store.file_sources:
            if fid.endswith("app.py"):
                app_file_id = fid
                break
        assert app_file_id is not None

        greet_id = f"{app_file_id}::User::greet"
        update(
            store,
            greet_id,
            'def greet(self) -> str:\n    return "Hey!"',
            prompt="change greeting",
            model="opus",
        )

        # Re-import
        store2 = project_import(str(project_dir))
        assert isinstance(store2, ProjectStore)
        # Find greet node again
        app2 = None
        for fid in store2.file_sources:
            if fid.endswith("app.py"):
                app2 = fid
                break
        assert app2 is not None
        node = store2.get_node(f"{app2}::User::greet")
        assert node is not None
        assert node.metadata.model == "opus"
        assert node.metadata.prompt == "change greeting"
