"""Tests for graph-powered refactoring operations."""

import tempfile
import threading
import time
from pathlib import Path

from code_review_graph.graph import GraphStore
from code_review_graph.parser import CodeParser, EdgeInfo, NodeInfo
from code_review_graph.refactor import (
    REFACTOR_EXPIRY_SECONDS,
    _pending_refactors,
    _refactor_lock,
    apply_refactor,
    find_dead_code,
    rename_preview,
    suggest_refactorings,
)


class TestRenamePreview:
    """Tests for rename_preview."""

    def setup_method(self):
        self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
        self.store = GraphStore(self.tmp.name)
        self._seed()

    def teardown_method(self):
        self.store.close()
        Path(self.tmp.name).unlink(missing_ok=True)
        # Clean up pending refactors.
        with _refactor_lock:
            _pending_refactors.clear()

    def _seed(self):
        """Seed the store with test data for rename tests."""
        # File nodes
        self.store.upsert_node(NodeInfo(
            kind="File", name="/repo/utils.py", file_path="/repo/utils.py",
            line_start=1, line_end=50, language="python",
        ))
        self.store.upsert_node(NodeInfo(
            kind="File", name="/repo/main.py", file_path="/repo/main.py",
            line_start=1, line_end=30, language="python",
        ))
        # Function to rename
        self.store.upsert_node(NodeInfo(
            kind="Function", name="helper", file_path="/repo/utils.py",
            line_start=10, line_end=20, language="python",
        ))
        # Caller function
        self.store.upsert_node(NodeInfo(
            kind="Function", name="run", file_path="/repo/main.py",
            line_start=5, line_end=15, language="python",
        ))
        # CALLS edge: run -> helper
        self.store.upsert_edge(EdgeInfo(
            kind="CALLS", source="/repo/main.py::run",
            target="/repo/utils.py::helper", file_path="/repo/main.py", line=10,
        ))
        # IMPORTS_FROM edge: main.py imports helper
        self.store.upsert_edge(EdgeInfo(
            kind="IMPORTS_FROM", source="/repo/main.py",
            target="/repo/utils.py::helper", file_path="/repo/main.py", line=1,
        ))
        self.store.commit()

    def test_rename_preview_returns_edits_with_refactor_id(self):
        """rename_preview returns a dict with refactor_id and edits."""
        result = rename_preview(self.store, "helper", "new_helper")
        assert result is not None
        assert "refactor_id" in result
        assert len(result["refactor_id"]) == 8
        assert result["type"] == "rename"
        assert result["old_name"] == "helper"
        assert result["new_name"] == "new_helper"
        assert isinstance(result["edits"], list)
        assert len(result["edits"]) > 0
        assert "stats" in result
        assert result["stats"]["high"] > 0

    def test_rename_finds_callers(self):
        """rename_preview finds definition + call sites."""
        result = rename_preview(self.store, "helper", "new_helper")
        assert result is not None
        edits = result["edits"]
        # Should have at least: 1 definition + 1 call + 1 import = 3
        assert len(edits) >= 3
        files = {e["file"] for e in edits}
        assert "/repo/utils.py" in files  # definition
        assert "/repo/main.py" in files   # call site + import site

    def test_rename_not_found(self):
        """rename_preview returns None if symbol not found."""
        result = rename_preview(self.store, "nonexistent_function", "new_name")
        assert result is None

    def test_rename_stores_in_pending(self):
        """rename_preview stores the preview in _pending_refactors."""
        result = rename_preview(self.store, "helper", "new_helper")
        assert result is not None
        rid = result["refactor_id"]
        with _refactor_lock:
            assert rid in _pending_refactors


class TestFindDeadCode:
    """Tests for find_dead_code."""

    def setup_method(self):
        self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
        self.store = GraphStore(self.tmp.name)
        self._seed()

    def teardown_method(self):
        self.store.close()
        Path(self.tmp.name).unlink(missing_ok=True)

    def _seed(self):
        """Seed with a mix of used and unused functions."""
        # File
        self.store.upsert_node(NodeInfo(
            kind="File", name="/repo/app.py", file_path="/repo/app.py",
            line_start=1, line_end=100, language="python",
        ))
        # A function that IS called
        self.store.upsert_node(NodeInfo(
            kind="Function", name="used_func", file_path="/repo/app.py",
            line_start=10, line_end=20, language="python",
        ))
        # A function that is NOT called (dead code)
        self.store.upsert_node(NodeInfo(
            kind="Function", name="dead_func", file_path="/repo/app.py",
            line_start=30, line_end=40, language="python",
        ))
        # An entry point function (should be excluded)
        self.store.upsert_node(NodeInfo(
            kind="Function", name="main", file_path="/repo/app.py",
            line_start=50, line_end=60, language="python",
        ))
        # A test function (should be excluded)
        self.store.upsert_node(NodeInfo(
            kind="Test", name="test_something", file_path="/repo/test_app.py",
            line_start=1, line_end=10, language="python", is_test=True,
        ))

        # Caller for used_func
        self.store.upsert_node(NodeInfo(
            kind="Function", name="caller", file_path="/repo/app.py",
            line_start=70, line_end=80, language="python",
        ))
        self.store.upsert_edge(EdgeInfo(
            kind="CALLS", source="/repo/app.py::caller",
            target="/repo/app.py::used_func", file_path="/repo/app.py", line=75,
        ))
        self.store.commit()

    def test_find_dead_code(self):
        """find_dead_code detects unreferenced functions."""
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "dead_func" in dead_names

    def test_find_dead_code_response_fields(self):
        """dead_code entries include file_path, relative_path, and language."""
        dead = find_dead_code(self.store, root="/repo")
        entry = next(d for d in dead if d["name"] == "dead_func")
        assert entry["file_path"] == "/repo/app.py"
        assert entry["relative_path"] == "app.py"
        assert entry["language"] == "python"
        # backward compat: 'file' key still present
        assert entry["file"] == "/repo/app.py"

    def test_find_dead_code_relative_path_without_root(self):
        """Without root, relative_path falls back to file_path."""
        dead = find_dead_code(self.store)
        entry = next(d for d in dead if d["name"] == "dead_func")
        assert entry["relative_path"] == "/repo/app.py"

    def test_find_dead_code_excludes_called(self):
        """find_dead_code does NOT include functions with callers."""
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "used_func" not in dead_names

    def test_find_dead_code_excludes_entry_points(self):
        """Entry points (like 'main') are not flagged as dead code."""
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "main" not in dead_names

    def test_find_dead_code_excludes_tests(self):
        """Test nodes are not flagged as dead code."""
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "test_something" not in dead_names

    def test_find_dead_code_kind_filter(self):
        """kind filter restricts results."""
        dead = find_dead_code(self.store, kind="Class")
        # We have no Class nodes, so should be empty
        assert len(dead) == 0

    def test_find_dead_code_file_pattern(self):
        """file_pattern filter works."""
        dead = find_dead_code(self.store, file_pattern="nonexistent")
        assert len(dead) == 0

    def test_find_dead_code_excludes_dunder(self):
        """Dunder methods are not flagged as dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Function", name="__init__", file_path="/repo/app.py",
            line_start=90, line_end=95, language="python",
            parent_name="MyClass",
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "__init__" not in dead_names

    def test_find_dead_code_excludes_constructor(self):
        """JS/TS constructors are not flagged as dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Function", name="constructor", file_path="/repo/component.ts",
            line_start=10, line_end=15, language="typescript",
            parent_name="MyComponent",
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "constructor" not in dead_names

    def test_find_dead_code_excludes_angular_lifecycle(self):
        """Angular lifecycle hooks are not flagged as dead code."""
        for name in ("ngOnInit", "ngOnChanges", "ngOnDestroy", "transform",
                     "writeValue", "canActivate"):
            self.store.upsert_node(NodeInfo(
                kind="Function", name=name, file_path="/repo/component.ts",
                line_start=10, line_end=15, language="typescript",
                parent_name="MyComponent",
            ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        for name in ("ngOnInit", "ngOnChanges", "ngOnDestroy", "transform",
                     "writeValue", "canActivate"):
            assert name not in dead_names, f"{name} should not be dead"

    def test_find_dead_code_excludes_decorated_entry(self):
        """Functions with framework decorators are not flagged as dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Function", name="get_users", file_path="/repo/app.py",
            line_start=90, line_end=95, language="python",
            extra={"decorators": ["app.get('/users')"]},
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "get_users" not in dead_names

    def test_find_dead_code_excludes_type_referenced_class(self):
        """Classes referenced in function type annotations are not dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Class", name="UserSchema", file_path="/repo/app.py",
            line_start=5, line_end=15, language="python",
        ))
        # A function that uses UserSchema in its params
        self.store.upsert_node(NodeInfo(
            kind="Function", name="create_user", file_path="/repo/app.py",
            line_start=20, line_end=30, language="python",
            params="body: UserSchema",
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "UserSchema" not in dead_names

    def test_find_dead_code_excludes_return_type_reference(self):
        """Classes referenced in return types are not dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Class", name="UserResponse", file_path="/repo/app.py",
            line_start=5, line_end=15, language="python",
        ))
        self.store.upsert_node(NodeInfo(
            kind="Function", name="get_user", file_path="/repo/app.py",
            line_start=20, line_end=30, language="python",
            return_type="Optional[UserResponse]",
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "UserResponse" not in dead_names

    def test_find_dead_code_excludes_orm_model(self):
        """Classes inheriting from known ORM bases are not dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Class", name="User", file_path="/repo/app.py",
            line_start=5, line_end=20, language="python",
        ))
        self.store.upsert_edge(EdgeInfo(
            kind="INHERITS", source="/repo/app.py::User",
            target="Base", file_path="/repo/app.py", line=5,
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "User" not in dead_names

    def test_find_dead_code_excludes_pydantic_settings(self):
        """Classes inheriting from BaseSettings are not dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Class", name="AppConfig", file_path="/repo/app.py",
            line_start=5, line_end=15, language="python",
        ))
        self.store.upsert_edge(EdgeInfo(
            kind="INHERITS", source="/repo/app.py::AppConfig",
            target="BaseSettings", file_path="/repo/app.py", line=5,
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "AppConfig" not in dead_names

    def test_find_dead_code_excludes_agent_tool(self):
        """Functions with @agent.tool decorator are not dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Function", name="query_data", file_path="/repo/app.py",
            line_start=10, line_end=20, language="python",
            extra={"decorators": ["health_agent.tool"]},
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "query_data" not in dead_names

    def test_find_dead_code_excludes_alembic_upgrade(self):
        """upgrade() and downgrade() in alembic files are not dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Function", name="upgrade", file_path="/repo/alembic/versions/001.py",
            line_start=5, line_end=15, language="python",
        ))
        self.store.upsert_node(NodeInfo(
            kind="Function", name="downgrade", file_path="/repo/alembic/versions/001.py",
            line_start=20, line_end=30, language="python",
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "upgrade" not in dead_names
        assert "downgrade" not in dead_names

    def test_find_dead_code_excludes_subclassed_class(self):
        """Classes with subclasses (INHERITS edges) are not dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Class", name="BaseConnector", file_path="/repo/connectors.py",
            line_start=5, line_end=50, language="python",
        ))
        # A subclass inherits from BaseConnector (bare-name target)
        self.store.upsert_edge(EdgeInfo(
            kind="INHERITS", source="/repo/connectors.py::GarminConnector",
            target="BaseConnector", file_path="/repo/connectors.py", line=60,
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "BaseConnector" not in dead_names

    def test_find_dead_code_bare_name_not_tricked_by_unrelated_caller(self):
        """Bare-name CALLS from unrelated files don't save a dead function
        when there are multiple definitions with the same name."""
        # Two unrelated functions named "processor" in different files
        self.store.upsert_node(NodeInfo(
            kind="Function", name="processor", file_path="/repo/api/routes.py",
            line_start=10, line_end=20, language="python",
        ))
        self.store.upsert_node(NodeInfo(
            kind="Function", name="processor", file_path="/repo/worker/tasks.py",
            line_start=10, line_end=20, language="python",
        ))
        # A bare CALLS edge from a third file that imports only routes.py
        self.store.upsert_edge(EdgeInfo(
            kind="IMPORTS_FROM", source="/repo/main.py",
            target="/repo/api/routes.py", file_path="/repo/main.py", line=1,
        ))
        self.store.upsert_edge(EdgeInfo(
            kind="CALLS", source="/repo/main.py::start",
            target="processor", file_path="/repo/main.py", line=10,
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_qnames = {d["qualified_name"] for d in dead}
        # routes.py processor is saved (caller imports its file)
        assert "/repo/api/routes.py::processor" not in dead_qnames
        # worker/tasks.py processor is dead (no relationship with caller)
        assert "/repo/worker/tasks.py::processor" in dead_qnames

    def test_find_dead_code_excludes_mock_variables(self):
        """Mock/stub variables in test files are not flagged as dead code."""
        for name in ("mockDynamoClient", "s3ClientMock", "MockService", "createMockRequest"):
            self.store.upsert_node(NodeInfo(
                kind="Function", name=name, file_path="/repo/tests/handler.spec.ts",
                line_start=10, line_end=15, language="typescript",
            ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        for name in ("mockDynamoClient", "s3ClientMock", "MockService", "createMockRequest"):
            assert name not in dead_names, f"{name} should not be dead (mock pattern)"

    def test_find_dead_code_excludes_angular_decorated_class(self):
        """Angular @Component classes are not flagged as dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Class", name="ClipboardButtonComponent",
            file_path="/repo/src/app/clipboard.component.ts",
            line_start=5, line_end=50, language="typescript",
            extra={"decorators": ["Component({selector: 'app-clipboard'})"]},
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "ClipboardButtonComponent" not in dead_names

    def test_find_dead_code_excludes_property(self):
        """Functions decorated with @property are not dead code."""
        self.store.upsert_node(NodeInfo(
            kind="Function", name="db", file_path="/repo/deps.py",
            line_start=10, line_end=15, language="python",
            extra={"decorators": ["property"]},
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "db" not in dead_names


class TestSuggestRefactorings:
    """Tests for suggest_refactorings."""

    def setup_method(self):
        self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
        self.store = GraphStore(self.tmp.name)
        self._seed()

    def teardown_method(self):
        self.store.close()
        Path(self.tmp.name).unlink(missing_ok=True)

    def _seed(self):
        """Seed with dead code to generate suggestions."""
        self.store.upsert_node(NodeInfo(
            kind="File", name="/repo/lib.py", file_path="/repo/lib.py",
            line_start=1, line_end=50, language="python",
        ))
        # Unreferenced function -> removal suggestion
        self.store.upsert_node(NodeInfo(
            kind="Function", name="orphan_func", file_path="/repo/lib.py",
            line_start=10, line_end=20, language="python",
        ))
        self.store.commit()

    def test_suggest_refactorings(self):
        """suggest_refactorings returns a list of suggestions."""
        suggestions = suggest_refactorings(self.store)
        assert isinstance(suggestions, list)
        # Should have at least the dead-code removal suggestion
        assert len(suggestions) >= 1
        types = {s["type"] for s in suggestions}
        assert "remove" in types

    def test_suggestion_structure(self):
        """Each suggestion has the required fields."""
        suggestions = suggest_refactorings(self.store)
        for s in suggestions:
            assert "type" in s
            assert "description" in s
            assert "symbols" in s
            assert "rationale" in s
            assert s["type"] in ("move", "remove")


class TestApplyRefactor:
    """Tests for apply_refactor."""

    def setup_method(self):
        with _refactor_lock:
            _pending_refactors.clear()

    def teardown_method(self):
        with _refactor_lock:
            _pending_refactors.clear()

    def test_apply_refactor_validates_id(self):
        """apply_refactor rejects nonexistent refactor_id."""
        # Use a real temp dir as repo_root (needs .git or .code-review-graph)
        tmp_dir = Path(tempfile.mkdtemp())
        (tmp_dir / ".git").mkdir()
        try:
            result = apply_refactor("nonexistent_id", tmp_dir)
            assert result["status"] == "error"
            assert "not found" in result["error"].lower() or "expired" in result["error"].lower()
        finally:
            (tmp_dir / ".git").rmdir()
            tmp_dir.rmdir()

    def test_apply_refactor_expiry(self):
        """apply_refactor rejects expired previews."""
        tmp_dir = Path(tempfile.mkdtemp())
        (tmp_dir / ".git").mkdir()
        try:
            # Insert a preview that is already expired.
            rid = "expired1"
            with _refactor_lock:
                _pending_refactors[rid] = {
                    "refactor_id": rid,
                    "type": "rename",
                    "old_name": "old",
                    "new_name": "new",
                    "edits": [],
                    "stats": {"high": 0, "medium": 0, "low": 0},
                    "created_at": time.time() - REFACTOR_EXPIRY_SECONDS - 10,
                }
            result = apply_refactor(rid, tmp_dir)
            assert result["status"] == "error"
            assert "expired" in result["error"].lower()
        finally:
            (tmp_dir / ".git").rmdir()
            tmp_dir.rmdir()

    def test_apply_refactor_path_traversal(self):
        """apply_refactor blocks edits outside repo root."""
        tmp_dir = Path(tempfile.mkdtemp())
        (tmp_dir / ".git").mkdir()
        try:
            rid = "traversal"
            with _refactor_lock:
                _pending_refactors[rid] = {
                    "refactor_id": rid,
                    "type": "rename",
                    "old_name": "old",
                    "new_name": "new",
                    "edits": [{
                        "file": "/etc/passwd",
                        "line": 1,
                        "old": "old",
                        "new": "new",
                        "confidence": "high",
                    }],
                    "stats": {"high": 1, "medium": 0, "low": 0},
                    "created_at": time.time(),
                }
            result = apply_refactor(rid, tmp_dir)
            assert result["status"] == "error"
            assert "outside repo root" in result["error"].lower()
        finally:
            (tmp_dir / ".git").rmdir()
            tmp_dir.rmdir()

    def test_apply_refactor_success(self):
        """apply_refactor applies string replacement to a real file."""
        tmp_dir = Path(tempfile.mkdtemp())
        (tmp_dir / ".git").mkdir()
        target_file = tmp_dir / "example.py"
        target_file.write_text("def old_func():\n    pass\n", encoding="utf-8")
        try:
            rid = "success1"
            with _refactor_lock:
                _pending_refactors[rid] = {
                    "refactor_id": rid,
                    "type": "rename",
                    "old_name": "old_func",
                    "new_name": "new_func",
                    "edits": [{
                        "file": str(target_file),
                        "line": 1,
                        "old": "old_func",
                        "new": "new_func",
                        "confidence": "high",
                    }],
                    "stats": {"high": 1, "medium": 0, "low": 0},
                    "created_at": time.time(),
                }
            result = apply_refactor(rid, tmp_dir)
            assert result["status"] == "ok"
            assert result["edits_applied"] == 1
            assert len(result["files_modified"]) == 1
            # Verify file content was changed.
            content = target_file.read_text(encoding="utf-8")
            assert "new_func" in content
            assert "old_func" not in content
        finally:
            target_file.unlink(missing_ok=True)
            (tmp_dir / ".git").rmdir()
            tmp_dir.rmdir()

    def test_apply_refactor_dry_run_returns_diff_without_writing(self):
        """dry_run=True returns a unified diff without touching disk and
        keeps the refactor_id valid for a follow-up write (#176)."""
        tmp_dir = Path(tempfile.mkdtemp())
        (tmp_dir / ".git").mkdir()
        target_file = tmp_dir / "example.py"
        original = "def old_func():\n    pass\n"
        target_file.write_text(original, encoding="utf-8")
        try:
            rid = "dryrun1"
            with _refactor_lock:
                _pending_refactors[rid] = {
                    "refactor_id": rid,
                    "type": "rename",
                    "old_name": "old_func",
                    "new_name": "new_func",
                    "edits": [{
                        "file": str(target_file),
                        "line": 1,
                        "old": "old_func",
                        "new": "new_func",
                        "confidence": "high",
                    }],
                    "stats": {"high": 1, "medium": 0, "low": 0},
                    "created_at": time.time(),
                }

            # Step 1: dry_run — no writes, returns diff
            result = apply_refactor(rid, tmp_dir, dry_run=True)
            assert result["status"] == "ok"
            assert result["dry_run"] is True
            assert result["edits_applied"] == 1
            assert len(result["would_modify"]) == 1
            assert result["files_modified"] == []  # nothing written yet
            assert str(target_file) in result["would_modify"]
            # Diff should mention both the old and new name
            diff = result["diffs"][str(target_file)]
            assert "-def old_func():" in diff
            assert "+def new_func():" in diff
            # File on disk must be unchanged
            assert target_file.read_text(encoding="utf-8") == original

            # Step 2: refactor_id should still be valid — dry_run doesn't consume it
            with _refactor_lock:
                assert rid in _pending_refactors

            # Step 3: real apply — uses same refactor_id
            real_result = apply_refactor(rid, tmp_dir, dry_run=False)
            assert real_result["status"] == "ok"
            assert real_result.get("dry_run") is None  # not set on the real path
            assert real_result["edits_applied"] == 1
            assert len(real_result["files_modified"]) == 1
            # File content changed
            new_content = target_file.read_text(encoding="utf-8")
            assert "new_func" in new_content
            assert "old_func" not in new_content

            # refactor_id consumed after real apply
            with _refactor_lock:
                assert rid not in _pending_refactors
        finally:
            target_file.unlink(missing_ok=True)
            (tmp_dir / ".git").rmdir()
            tmp_dir.rmdir()

    def test_apply_refactor_dry_run_no_edits(self):
        """dry_run with an empty edit list returns an empty diff dict."""
        tmp_dir = Path(tempfile.mkdtemp())
        (tmp_dir / ".git").mkdir()
        try:
            rid = "dryrun-empty"
            with _refactor_lock:
                _pending_refactors[rid] = {
                    "refactor_id": rid,
                    "type": "rename",
                    "old_name": "x",
                    "new_name": "y",
                    "edits": [],
                    "stats": {"high": 0, "medium": 0, "low": 0},
                    "created_at": time.time(),
                }
            result = apply_refactor(rid, tmp_dir, dry_run=True)
            assert result["status"] == "ok"
            assert result["dry_run"] is True
            assert result["would_modify"] == []
            assert result["diffs"] == {}
        finally:
            with _refactor_lock:
                _pending_refactors.pop("dryrun-empty", None)
            (tmp_dir / ".git").rmdir()
            tmp_dir.rmdir()


class TestPendingRefactorsThreadSafe:
    """Tests for thread-safety of the pending refactors storage."""

    def test_pending_refactors_thread_safe(self):
        """The _refactor_lock is a threading.Lock instance."""
        assert isinstance(_refactor_lock, type(threading.Lock()))

    def test_concurrent_access(self):
        """Multiple threads can safely access _pending_refactors."""
        results = []

        def writer(rid: str):
            with _refactor_lock:
                _pending_refactors[rid] = {
                    "refactor_id": rid,
                    "created_at": time.time(),
                }
                results.append(rid)

        threads = [threading.Thread(target=writer, args=(f"t{i}",)) for i in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        with _refactor_lock:
            assert len(results) == 10
            assert len(_pending_refactors) >= 10
            # Clean up
            _pending_refactors.clear()


class TestFindDeadCodeWithReferences:
    """Tests for REFERENCES-aware dead code detection."""

    def setup_method(self):
        self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
        self.store = GraphStore(self.tmp.name)
        self._seed()

    def teardown_method(self):
        self.store.close()
        Path(self.tmp.name).unlink(missing_ok=True)

    def _seed(self):
        """Seed with functions that have REFERENCES edges (map dispatch pattern)."""
        # File
        self.store.upsert_node(NodeInfo(
            kind="File", name="/repo/handlers.ts", file_path="/repo/handlers.ts",
            line_start=1, line_end=100, language="typescript",
        ))
        # A function referenced in a map (should NOT be dead)
        self.store.upsert_node(NodeInfo(
            kind="Function", name="handleCreate", file_path="/repo/handlers.ts",
            line_start=10, line_end=20, language="typescript",
        ))
        # A function with CALLS edge (should NOT be dead)
        self.store.upsert_node(NodeInfo(
            kind="Function", name="calledFunc", file_path="/repo/handlers.ts",
            line_start=30, line_end=40, language="typescript",
        ))
        # A truly dead function (no edges at all)
        self.store.upsert_node(NodeInfo(
            kind="Function", name="deadFunc", file_path="/repo/handlers.ts",
            line_start=50, line_end=60, language="typescript",
        ))
        # Caller
        self.store.upsert_node(NodeInfo(
            kind="Function", name="dispatch", file_path="/repo/handlers.ts",
            line_start=70, line_end=80, language="typescript",
        ))
        # REFERENCES edge: dispatch -> handleCreate (map dispatch pattern)
        self.store.upsert_edge(EdgeInfo(
            kind="REFERENCES", source="/repo/handlers.ts::dispatch",
            target="/repo/handlers.ts::handleCreate",
            file_path="/repo/handlers.ts", line=75,
        ))
        # CALLS edge: dispatch -> calledFunc
        self.store.upsert_edge(EdgeInfo(
            kind="CALLS", source="/repo/handlers.ts::dispatch",
            target="/repo/handlers.ts::calledFunc",
            file_path="/repo/handlers.ts", line=76,
        ))
        self.store.commit()

    def test_referenced_function_not_dead(self):
        """Functions with REFERENCES edges should NOT be flagged as dead code."""
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "handleCreate" not in dead_names

    def test_called_function_not_dead(self):
        """Functions with CALLS edges remain excluded (existing behavior)."""
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "calledFunc" not in dead_names

    def test_truly_dead_function_still_reported(self):
        """Functions with no edges at all should still be flagged as dead code."""
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "deadFunc" in dead_names

    def test_only_references_edge_sufficient(self):
        """A function with ONLY a REFERENCES edge (no CALLS/IMPORTS) is not dead."""
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        # handleCreate has only a REFERENCES edge, no CALLS targeting it
        assert "handleCreate" not in dead_names


class TestTransitiveImportResolution:
    """Tests for 2-hop transitive import resolution in plausible caller."""

    def setup_method(self):
        self.store = GraphStore(":memory:")
        for f in ("/repo/consumer.ts", "/repo/lib/index.ts", "/repo/lib/utils.ts"):
            self.store.upsert_node(NodeInfo(
                kind="File", name=f, file_path=f,
                line_start=1, line_end=50, language="typescript",
            ))

    def test_transitive_import_via_barrel_file(self):
        """consumer.ts imports index.ts which re-exports from utils.ts.
        A bare-name CALLS from consumer.ts should be plausible for utils.ts functions."""
        # Function defined in utils.ts
        self.store.upsert_node(NodeInfo(
            kind="Function", name="safeJsonParse",
            file_path="/repo/lib/utils.ts",
            line_start=10, line_end=20, language="typescript",
        ))
        # Import chain: consumer -> index -> utils
        self.store.upsert_edge(EdgeInfo(
            kind="IMPORTS_FROM", source="/repo/consumer.ts",
            target="/repo/lib/index.ts", file_path="/repo/consumer.ts", line=1,
        ))
        self.store.upsert_edge(EdgeInfo(
            kind="IMPORTS_FROM", source="/repo/lib/index.ts",
            target="/repo/lib/utils.ts", file_path="/repo/lib/index.ts", line=1,
        ))
        # Bare-name CALLS from consumer
        self.store.upsert_edge(EdgeInfo(
            kind="CALLS", source="/repo/consumer.ts::processData",
            target="safeJsonParse", file_path="/repo/consumer.ts", line=5,
        ))
        self.store.commit()
        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "safeJsonParse" not in dead_names, (
            "2-hop import chain should make consumer a plausible caller"
        )


class TestFindDeadCodeModuleScope:
    """End-to-end regression: parse → store → find_dead_code.

    Pins the contract that functions invoked only from module scope are not
    flagged as dead. Bypasses the hand-built graph fixtures used elsewhere in
    this file so that a regression in any of the parser's 5 module-scope
    CALLS paths is caught.
    """

    def setup_method(self):
        self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
        self.store = GraphStore(self.tmp.name)
        self.parser = CodeParser()

    def teardown_method(self):
        self.store.close()
        Path(self.tmp.name).unlink(missing_ok=True)

    def _store_parsed(self, path: Path, source: bytes) -> None:
        nodes, edges = self.parser.parse_bytes(path, source)
        for n in nodes:
            self.store.upsert_node(n)
        for e in edges:
            self.store.upsert_edge(e)
        self.store.commit()

    def test_module_scope_caller_prevents_dead_code_flag(self, tmp_path):
        """A function called only from top-level script glue is not dead."""
        # ``run_job`` has no non-dunder name match and no framework decorator,
        # so without the module-scope CALLS fix it would be flagged dead.
        path = tmp_path / "script.py"
        path.write_bytes(
            b"def run_job():\n"
            b"    return 1\n"
            b"\n"
            b"run_job()\n"
        )
        self._store_parsed(path, path.read_bytes())

        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "run_job" not in dead_names, (
            "module-scope caller should prevent run_job from being flagged dead"
        )

    def test_if_main_block_caller_prevents_dead_code_flag(self, tmp_path):
        """A function called only inside ``if __name__ == '__main__'`` is not dead."""
        path = tmp_path / "cli.py"
        path.write_bytes(
            b"def launch():\n"
            b"    return 1\n"
            b"\n"
            b"if __name__ == '__main__':\n"
            b"    launch()\n"
        )
        self._store_parsed(path, path.read_bytes())

        dead = find_dead_code(self.store)
        dead_names = {d["name"] for d in dead}
        assert "launch" not in dead_names
