"""
Kodara — Core smoke tests.

Tests cover the critical path:
  scanner → graph → memory → engine_client (mocked HTTP backend)

Run: pytest tests/
"""

import time
import tempfile
import textwrap
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

from kodara.memory.store import MemoryStore, KodaraIndex, INDEX_VERSION
from kodara.indexer.scanner import RepositoryScanner, FreePlanLimitError, FREE_PLAN_LIMIT
from kodara.graph.dependency import DependencyGraph
import kodara.engine_client as engine_client


# ── Fixtures ───────────────────────────────────────────────────────────────────

@pytest.fixture
def sample_project(tmp_path):
    """Create a minimal Python project for testing."""
    (tmp_path / "api").mkdir()
    (tmp_path / "auth").mkdir()
    (tmp_path / "models").mkdir()

    (tmp_path / "main.py").write_text(textwrap.dedent("""
        from api.server import app
        from auth.middleware import verify_token

        def main():
            app.run()
    """))

    (tmp_path / "api" / "server.py").write_text(textwrap.dedent("""
        from auth.middleware import verify_token
        from models.user import User

        class APIServer:
            def run(self): pass
            def handle_request(self): pass
    """))

    (tmp_path / "auth" / "middleware.py").write_text(textwrap.dedent("""
        from models.user import User

        class AuthMiddleware:
            pass

        def verify_token(token: str) -> bool:
            return True
    """))

    (tmp_path / "models" / "user.py").write_text(textwrap.dedent("""
        class User:
            def __init__(self, id: int, email: str):
                self.id = id
                self.email = email

        class UserSession:
            pass
    """))

    return tmp_path


@pytest.fixture
def sample_index(sample_project):
    """Build and return a KodaraIndex from sample_project."""
    scanner = RepositoryScanner(sample_project)
    scan = scanner.scan()

    graph = DependencyGraph()
    graph.build(scan)
    graph_data = graph.to_dict()

    modules = {}
    for path, info in scan.files.items():
        modules[path] = {
            "path": path,
            "module_name": Path(path).stem,
            "language": info.language,
            "classes": info.classes,
            "functions": info.functions,
            "exports": info.exports,
            "imports": info.imports,
            "file_hash": info.hash,
            "summary": "",
            "dependencies": graph.get_dependencies(path),
            "dependents": graph.get_dependents(path),
        }

    return KodaraIndex(
        repo_id=MemoryStore.repo_id_from_path(sample_project),
        display_name="test-project",
        source_root=str(sample_project),
        indexed_at=time.time(),
        index_version=INDEX_VERSION,
        file_count=len(scan.files),
        language_counts=scan.languages,
        modules=modules,
        graph_edges=graph_data["edges"],
        file_hashes={p: f.hash for p, f in scan.files.items()},
    )


# ── 1. Scanner ─────────────────────────────────────────────────────────────────

class TestScanner:

    def test_scan_detects_python_files(self, sample_project):
        scanner = RepositoryScanner(sample_project)
        result = scanner.scan()
        assert len(result.files) > 0
        assert "python" in result.languages

    def test_scan_extracts_classes(self, sample_project):
        scanner = RepositoryScanner(sample_project)
        result = scanner.scan()
        all_classes = [c for f in result.files.values() for c in f.classes]
        assert "User" in all_classes
        assert "AuthMiddleware" in all_classes

    def test_scan_extracts_functions(self, sample_project):
        scanner = RepositoryScanner(sample_project)
        result = scanner.scan()
        all_functions = [fn for f in result.files.values() for fn in f.functions]
        assert "verify_token" in all_functions
        assert "main" in all_functions

    def test_scan_computes_hashes(self, sample_project):
        scanner = RepositoryScanner(sample_project)
        result = scanner.scan()
        for info in result.files.values():
            assert len(info.hash) == 64  # SHA-256 hex

    def test_scan_skips_pycache(self, sample_project):
        pycache = sample_project / "__pycache__"
        pycache.mkdir()
        (pycache / "dummy.pyc").write_bytes(b"")
        scanner = RepositoryScanner(sample_project)
        result = scanner.scan()
        assert not any("__pycache__" in p for p in result.files)

    def test_free_plan_limit(self, tmp_path):
        """FreePlanLimitError raised when files exceed FREE_PLAN_LIMIT."""
        import kodara.indexer.scanner as s
        original = s.FREE_PLAN_LIMIT
        s.FREE_PLAN_LIMIT = 2  # artificially low
        try:
            for i in range(5):
                (tmp_path / f"module_{i}.py").write_text(f"def func_{i}(): pass")
            scanner = RepositoryScanner(tmp_path)
            with pytest.raises(FreePlanLimitError) as exc_info:
                scanner.scan()
            assert exc_info.value.file_count > 2
        finally:
            s.FREE_PLAN_LIMIT = original


# ── 2. Memory Store ────────────────────────────────────────────────────────────

class TestMemoryStore:

    def test_save_and_load(self, sample_index, tmp_path):
        store = MemoryStore(tmp_path)
        store.save(sample_index)
        loaded = store.load()
        assert loaded is not None
        assert loaded.repo_id == sample_index.repo_id
        assert loaded.file_count == sample_index.file_count

    def test_modules_preserved(self, sample_index, tmp_path):
        store = MemoryStore(tmp_path)
        store.save(sample_index)
        loaded = store.load()
        assert len(loaded.modules) == len(sample_index.modules)

    def test_graph_edges_preserved(self, sample_index, tmp_path):
        store = MemoryStore(tmp_path)
        store.save(sample_index)
        loaded = store.load()
        assert len(loaded.graph_edges) == len(sample_index.graph_edges)

    def test_file_hashes_preserved(self, sample_index, tmp_path):
        store = MemoryStore(tmp_path)
        store.save(sample_index)
        loaded = store.load()
        assert loaded.file_hashes == sample_index.file_hashes

    def test_exists(self, sample_index, tmp_path):
        store = MemoryStore(tmp_path)
        assert not store.exists()
        store.save(sample_index)
        assert store.exists()

    def test_delete(self, sample_index, tmp_path):
        store = MemoryStore(tmp_path)
        store.save(sample_index)
        store.delete()
        assert not store.exists()

    def test_get_module(self, sample_index, tmp_path):
        store = MemoryStore(tmp_path)
        store.save(sample_index)
        path = list(sample_index.modules.keys())[0]
        mod = store.get_module(path)
        assert mod is not None
        assert mod["path"] == path

    def test_get_changed_files(self, sample_index, tmp_path):
        store = MemoryStore(tmp_path)
        store.save(sample_index)

        # Simulate: one file modified, one added
        new_hashes = dict(sample_index.file_hashes)
        first_key = list(new_hashes.keys())[0]
        new_hashes[first_key] = "changed_hash_xyz"
        new_hashes["new_file.py"] = "new_file_hash"

        added, modified, deleted = store.get_changed_files(new_hashes)
        assert "new_file.py" in added
        assert first_key in modified

    def test_load_returns_none_when_missing(self, tmp_path):
        store = MemoryStore(tmp_path)
        assert store.load() is None

    def test_repo_id_format(self, tmp_path):
        repo_id = MemoryStore.repo_id_from_path(tmp_path)
        assert repo_id.startswith("local/")
        assert "-" in repo_id


# ── 3. Dependency Graph ────────────────────────────────────────────────────────

class TestDependencyGraph:

    def test_builds_nodes(self, sample_project):
        scanner = RepositoryScanner(sample_project)
        scan = scanner.scan()
        graph = DependencyGraph()
        graph.build(scan)
        assert graph.graph.number_of_nodes() > 0

    def test_builds_edges(self, sample_project):
        scanner = RepositoryScanner(sample_project)
        scan = scanner.scan()
        graph = DependencyGraph()
        graph.build(scan)
        assert graph.graph.number_of_edges() > 0

    def test_to_dict(self, sample_project):
        scanner = RepositoryScanner(sample_project)
        scan = scanner.scan()
        graph = DependencyGraph()
        graph.build(scan)
        data = graph.to_dict()
        assert "nodes" in data
        assert "edges" in data


# ── 4. Engine Client — context query ──────────────────────────────────────────

def _make_context_response(query="test query", files=None):
    """Helper: build a mock backend response for query_context."""
    files = files or ["auth/middleware.py"]
    return {
        "query": query,
        "modules": [
            {
                "file": f,
                "module": Path(f).stem,
                "language": "python",
                "classes": [],
                "functions": [],
                "exports": [],
                "imports": [],
                "dependencies": [],
                "summary": f"{Path(f).stem} module",
                "git": {},
            }
            for f in files
        ],
        "summaries": [f"**{Path(f).stem}**: {Path(f).stem} module" for f in files],
        "relevant_files": files,
        "graph_fragment": {"nodes": [], "edges": []},
        "token_estimate": 150,
        "total_modules_searched": 4,
    }


class TestEngineClientContext:

    def test_query_context_calls_post_correct_path(self, sample_index):
        """query_context() must POST to /v1/context/query."""
        mock_resp = _make_context_response()
        with patch("kodara.engine_client._post", return_value=mock_resp) as mock_post:
            result = engine_client.query_context(
                sample_index.modules, sample_index.graph_edges, "authentication middleware"
            )
            mock_post.assert_called_once()
            path_arg = mock_post.call_args[0][0]
            assert path_arg == "/v1/context/query"

    def test_query_context_sends_query_field(self, sample_index):
        """The payload sent to the backend must include the query string."""
        mock_resp = _make_context_response(query="auth token")
        with patch("kodara.engine_client._post", return_value=mock_resp) as mock_post:
            engine_client.query_context(
                sample_index.modules, sample_index.graph_edges, "auth token"
            )
            payload = mock_post.call_args[0][1]
            assert payload["query"] == "auth token"

    def test_query_context_sends_modules_and_edges(self, sample_index):
        """Payload must include modules and graph_edges from the index."""
        mock_resp = _make_context_response()
        with patch("kodara.engine_client._post", return_value=mock_resp) as mock_post:
            engine_client.query_context(
                sample_index.modules, sample_index.graph_edges, "user model"
            )
            payload = mock_post.call_args[0][1]
            assert "modules" in payload
            assert "graph_edges" in payload

    def test_query_context_returns_required_keys(self, sample_index):
        """Return value must contain all required response keys."""
        mock_resp = _make_context_response(files=["auth/middleware.py", "models/user.py"])
        with patch("kodara.engine_client._post", return_value=mock_resp):
            result = engine_client.query_context(
                sample_index.modules, sample_index.graph_edges, "auth user"
            )
        required = {
            "query", "modules", "summaries", "relevant_files",
            "graph_fragment", "token_estimate", "total_modules_searched",
        }
        assert required.issubset(result.keys())

    def test_query_context_returns_relevant_files(self, sample_index):
        """relevant_files in the response is forwarded as-is."""
        mock_resp = _make_context_response(files=["auth/middleware.py"])
        with patch("kodara.engine_client._post", return_value=mock_resp):
            result = engine_client.query_context(
                sample_index.modules, sample_index.graph_edges, "auth"
            )
        assert result["relevant_files"] == ["auth/middleware.py"]

    def test_query_context_token_estimate_positive(self, sample_index):
        """token_estimate in the response must be a positive number."""
        mock_resp = _make_context_response()
        with patch("kodara.engine_client._post", return_value=mock_resp):
            result = engine_client.query_context(
                sample_index.modules, sample_index.graph_edges, "user session"
            )
        assert result["token_estimate"] > 0

    def test_query_context_respects_max_modules(self, sample_index):
        """max_modules parameter is forwarded in the payload."""
        mock_resp = _make_context_response()
        with patch("kodara.engine_client._post", return_value=mock_resp) as mock_post:
            engine_client.query_context(
                sample_index.modules, sample_index.graph_edges, "api server", max_modules=3
            )
            payload = mock_post.call_args[0][1]
            assert payload["max_modules"] == 3


# ── 5. Engine Client — impact query ───────────────────────────────────────────

def _make_impact_response(target="auth/middleware.py"):
    """Helper: build a mock backend response for query_impact."""
    return {
        "target": target,
        "target_module": Path(target).stem,
        "direct": ["api/server.py"],
        "indirect": ["main.py"],
        "risk_level": "medium",
        "total_affected": 2,
    }


class TestEngineClientImpact:

    def test_query_impact_calls_post_correct_path(self, sample_index):
        """query_impact() must POST to /v1/impact/analyze."""
        mock_resp = _make_impact_response()
        with patch("kodara.engine_client._post", return_value=mock_resp) as mock_post:
            engine_client.query_impact(sample_index.modules, "auth/middleware.py")
            mock_post.assert_called_once()
            path_arg = mock_post.call_args[0][0]
            assert path_arg == "/v1/impact/analyze"

    def test_query_impact_sends_target_path(self, sample_index):
        """Payload must include the target_path field."""
        mock_resp = _make_impact_response()
        with patch("kodara.engine_client._post", return_value=mock_resp) as mock_post:
            engine_client.query_impact(sample_index.modules, "auth/middleware.py")
            payload = mock_post.call_args[0][1]
            assert payload["target_path"] == "auth/middleware.py"

    def test_query_impact_sends_modules(self, sample_index):
        """Payload must include the modules dict."""
        mock_resp = _make_impact_response()
        with patch("kodara.engine_client._post", return_value=mock_resp) as mock_post:
            engine_client.query_impact(sample_index.modules, "models/user.py")
            payload = mock_post.call_args[0][1]
            assert "modules" in payload

    def test_query_impact_returns_required_keys(self, sample_index):
        """Return value must contain all required response keys."""
        mock_resp = _make_impact_response()
        with patch("kodara.engine_client._post", return_value=mock_resp):
            result = engine_client.query_impact(sample_index.modules, "auth/middleware.py")
        required = {"target", "target_module", "direct", "indirect", "risk_level", "total_affected"}
        assert required.issubset(result.keys())

    def test_query_impact_direct_and_indirect(self, sample_index):
        """direct and indirect lists are forwarded from backend response."""
        mock_resp = _make_impact_response(target="models/user.py")
        mock_resp["direct"] = ["auth/middleware.py", "api/server.py"]
        mock_resp["indirect"] = ["main.py"]
        with patch("kodara.engine_client._post", return_value=mock_resp):
            result = engine_client.query_impact(sample_index.modules, "models/user.py")
        assert "auth/middleware.py" in result["direct"]
        assert "main.py" in result["indirect"]

    def test_query_impact_risk_level_present(self, sample_index):
        """risk_level is a non-empty string in the response."""
        mock_resp = _make_impact_response()
        with patch("kodara.engine_client._post", return_value=mock_resp):
            result = engine_client.query_impact(sample_index.modules, "auth/middleware.py")
        assert isinstance(result["risk_level"], str)
        assert len(result["risk_level"]) > 0

    def test_query_impact_total_affected(self, sample_index):
        """total_affected is a non-negative integer."""
        mock_resp = _make_impact_response()
        with patch("kodara.engine_client._post", return_value=mock_resp):
            result = engine_client.query_impact(sample_index.modules, "auth/middleware.py")
        assert isinstance(result["total_affected"], int)
        assert result["total_affected"] >= 0
