"""Tests for loom.scanner — project file tree walker."""

from __future__ import annotations

import os
from pathlib import Path

import pytest

pathspec = pytest.importorskip("pathspec")

from loom.scanner import walk_project_files


# ── Helpers ──────────────────────────────────────────────────────────


def _touch(path: Path) -> None:
    """Create a file and its parent directories."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.touch()


# ── Tests ────────────────────────────────────────────────────────────


class TestBasicTraversal:
    """walk_project_files returns expected files from a small tree."""

    def test_returns_sorted_relative_paths(self, tmp_path: Path) -> None:
        _touch(tmp_path / "main.py")
        _touch(tmp_path / "lib" / "utils.py")
        _touch(tmp_path / "README.md")

        result = walk_project_files(tmp_path)

        assert result == [
            Path("README.md"),
            Path("lib/utils.py"),
            Path("main.py"),
        ]

    def test_all_paths_are_relative(self, tmp_path: Path) -> None:
        _touch(tmp_path / "src" / "app.py")

        result = walk_project_files(tmp_path)

        for p in result:
            assert not p.is_absolute(), f"Path should be relative: {p}"


class TestHardcodedExclusions:
    """Hardcoded EXCLUDED_DIRS and EXCLUDED_SUFFIXES are respected."""

    def test_excludes_pycache(self, tmp_path: Path) -> None:
        _touch(tmp_path / "__pycache__" / "foo.cpython-312.pyc")
        _touch(tmp_path / "app.py")

        result = walk_project_files(tmp_path)

        assert Path("app.py") in result
        assert not any("__pycache__" in str(p) for p in result)

    def test_excludes_venv(self, tmp_path: Path) -> None:
        _touch(tmp_path / ".venv" / "lib" / "site.py")
        _touch(tmp_path / "app.py")

        result = walk_project_files(tmp_path)

        assert Path("app.py") in result
        assert not any(".venv" in str(p) for p in result)

    def test_excludes_node_modules(self, tmp_path: Path) -> None:
        _touch(tmp_path / "node_modules" / "express" / "index.js")
        _touch(tmp_path / "index.js")

        result = walk_project_files(tmp_path)

        assert Path("index.js") in result
        assert not any("node_modules" in str(p) for p in result)

    def test_excludes_dot_git(self, tmp_path: Path) -> None:
        _touch(tmp_path / ".git" / "config")
        _touch(tmp_path / "app.py")

        result = walk_project_files(tmp_path)

        assert not any(".git" in str(p) for p in result)

    def test_excludes_egg_info(self, tmp_path: Path) -> None:
        _touch(tmp_path / "mypackage.egg-info" / "PKG-INFO")
        _touch(tmp_path / "setup.py")

        result = walk_project_files(tmp_path)

        assert Path("setup.py") in result
        assert not any("egg-info" in str(p) for p in result)

    def test_excludes_pyc_suffix(self, tmp_path: Path) -> None:
        _touch(tmp_path / "compiled.pyc")
        _touch(tmp_path / "source.py")

        result = walk_project_files(tmp_path)

        assert Path("source.py") in result
        assert Path("compiled.pyc") not in result

    def test_excludes_so_suffix(self, tmp_path: Path) -> None:
        _touch(tmp_path / "extension.so")
        _touch(tmp_path / "module.py")

        result = walk_project_files(tmp_path)

        assert Path("module.py") in result
        assert Path("extension.so") not in result


class TestGitignore:
    """Gitignore rules are respected when .gitignore is present."""

    def test_gitignore_excludes_matching_files(self, tmp_path: Path) -> None:
        (tmp_path / ".gitignore").write_text("*.log\nsecret/\n")
        _touch(tmp_path / "app.log")
        _touch(tmp_path / "debug.log")
        _touch(tmp_path / "secret" / "keys.txt")
        _touch(tmp_path / "app.py")

        result = walk_project_files(tmp_path)

        # .gitignore itself should be included
        assert Path(".gitignore") in result
        assert Path("app.py") in result
        assert Path("app.log") not in result
        assert Path("debug.log") not in result
        assert not any("secret" in str(p) for p in result)

    def test_gitignore_negation(self, tmp_path: Path) -> None:
        (tmp_path / ".gitignore").write_text("*.log\n!keep.log\n")
        _touch(tmp_path / "discard.log")
        _touch(tmp_path / "keep.log")
        _touch(tmp_path / "app.py")

        result = walk_project_files(tmp_path)

        assert Path("keep.log") in result
        assert Path("discard.log") not in result
        assert Path("app.py") in result

    def test_gitignore_directory_pattern(self, tmp_path: Path) -> None:
        (tmp_path / ".gitignore").write_text("output/\n")
        _touch(tmp_path / "output" / "result.txt")
        _touch(tmp_path / "src" / "main.py")

        result = walk_project_files(tmp_path)

        assert not any("output" in str(p) for p in result)
        assert Path("src/main.py") in result


class TestNoGitignore:
    """Function works correctly without a .gitignore file."""

    def test_returns_files_without_gitignore(self, tmp_path: Path) -> None:
        _touch(tmp_path / "main.py")
        _touch(tmp_path / "data.csv")

        result = walk_project_files(tmp_path)

        assert Path("main.py") in result
        assert Path("data.csv") in result


class TestEmptyDirectory:
    """Empty directory returns empty list."""

    def test_empty_dir_returns_empty_list(self, tmp_path: Path) -> None:
        result = walk_project_files(tmp_path)

        assert result == []


class TestInvalidRoot:
    """Non-existent or invalid root raises ValueError."""

    def test_nonexistent_path_raises(self, tmp_path: Path) -> None:
        bad_path = tmp_path / "does_not_exist"

        with pytest.raises(ValueError, match="is not a valid directory"):
            walk_project_files(bad_path)

    def test_file_as_root_raises(self, tmp_path: Path) -> None:
        file_path = tmp_path / "afile.txt"
        file_path.touch()

        with pytest.raises(ValueError, match="is not a valid directory"):
            walk_project_files(file_path)


class TestSymlinks:
    """Symlinks outside root are skipped; symlinks inside root are followed."""

    def test_symlink_outside_root_skipped(self, tmp_path: Path) -> None:
        # Create a directory outside the project root
        outside = tmp_path / "outside"
        outside.mkdir()
        _touch(outside / "external.py")

        project = tmp_path / "project"
        project.mkdir()
        _touch(project / "main.py")

        # Create symlink from project to outside
        link = project / "linked"
        try:
            link.symlink_to(outside)
        except OSError:
            pytest.skip("Cannot create symlinks on this platform")

        result = walk_project_files(project)

        assert Path("main.py") in result
        # external.py should not appear because symlink points outside root
        assert not any("external" in str(p) for p in result)

    def test_symlink_inside_root_followed(self, tmp_path: Path) -> None:
        _touch(tmp_path / "real" / "code.py")

        link = tmp_path / "alias.py"
        target = tmp_path / "real" / "code.py"
        try:
            link.symlink_to(target)
        except OSError:
            pytest.skip("Cannot create symlinks on this platform")

        result = walk_project_files(tmp_path)

        assert Path("alias.py") in result
        assert Path("real/code.py") in result


class TestDeterminism:
    """Multiple calls return identical results."""

    def test_same_result_twice(self, tmp_path: Path) -> None:
        _touch(tmp_path / "z.py")
        _touch(tmp_path / "a.py")
        _touch(tmp_path / "m" / "x.py")
        _touch(tmp_path / "b" / "c" / "d.py")

        result1 = walk_project_files(tmp_path)
        result2 = walk_project_files(tmp_path)

        assert result1 == result2
        # Verify it's actually sorted
        assert result1 == sorted(result1)



# ── Tests for extract_symbols ────────────────────────────────────────

from loom.scanner import extract_symbols


def _write(path: Path, content: str) -> None:
    """Write *content* to *path*, creating parent dirs as needed."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")


class TestExtractSymbolsNormal:
    """extract_symbols extracts classes, functions, and imports from a file."""

    def test_extracts_class_function_async_and_imports(self, tmp_path: Path) -> None:
        src = tmp_path / "app.py"
        _write(src, """\
import os
from pathlib import Path

class MyApp:
    def run(self):
        pass

    async def start(self):
        pass

def helper():
    pass

async def async_helper():
    pass
""")

        result = extract_symbols([str(src)])

        info = result[str(src)]
        assert info["classes"] == ["MyApp"]
        assert info["functions"] == ["async_helper", "helper", "run", "start"]
        assert info["imports"] == ["os", "pathlib"]


class TestExtractSymbolsOnlyImports:
    """File with only imports produces no classes or functions."""

    def test_only_imports(self, tmp_path: Path) -> None:
        src = tmp_path / "imports_only.py"
        _write(src, """\
import sys
import os
from collections import OrderedDict
from pathlib import Path
""")

        result = extract_symbols([str(src)])

        info = result[str(src)]
        assert info["classes"] == []
        assert info["functions"] == []
        assert sorted(info["imports"]) == ["collections", "os", "pathlib", "sys"]


class TestExtractSymbolsClassMethods:
    """Methods (sync and async) inside a class are extracted as functions."""

    def test_class_methods(self, tmp_path: Path) -> None:
        src = tmp_path / "cls.py"
        _write(src, """\
class Handler:
    def get(self):
        pass

    def post(self):
        pass

    async def stream(self):
        pass
""")

        result = extract_symbols([str(src)])

        info = result[str(src)]
        assert info["classes"] == ["Handler"]
        assert info["functions"] == ["get", "post", "stream"]
        assert info["imports"] == []


class TestExtractSymbolsSyntaxError:
    """A file with a syntax error returns empty lists, not an exception."""

    def test_syntax_error_file(self, tmp_path: Path) -> None:
        src = tmp_path / "bad.py"
        _write(src, "def broken(:\n    pass\n")

        result = extract_symbols([str(src)])

        info = result[str(src)]
        assert info == {"classes": [], "functions": [], "imports": []}


class TestExtractSymbolsEmptyFile:
    """An empty Python file returns empty lists."""

    def test_empty_file(self, tmp_path: Path) -> None:
        src = tmp_path / "empty.py"
        _write(src, "")

        result = extract_symbols([str(src)])

        info = result[str(src)]
        assert info == {"classes": [], "functions": [], "imports": []}


class TestExtractSymbolsBothImportForms:
    """Both `import X` and `from X import Y` are captured."""

    def test_both_import_forms(self, tmp_path: Path) -> None:
        src = tmp_path / "mixed_imports.py"
        _write(src, """\
import json
from datetime import datetime
from . import local_mod  # relative — should be skipped
""")

        result = extract_symbols([str(src)])

        info = result[str(src)]
        # Relative import should be excluded
        assert info["imports"] == ["datetime", "json"]


class TestExtractSymbolsEmptyInput:
    """Passing an empty iterable returns an empty dict."""

    def test_empty_input(self) -> None:
        result = extract_symbols([])
        assert result == {}


class TestExtractSymbolsAsyncFunctions:
    """Async functions at the top level and inside classes are captured."""

    def test_async_functions(self, tmp_path: Path) -> None:
        src = tmp_path / "async_mod.py"
        _write(src, """\
async def fetch():
    pass

async def process():
    pass

class Worker:
    async def execute(self):
        pass
""")

        result = extract_symbols([str(src)])

        info = result[str(src)]
        assert info["classes"] == ["Worker"]
        assert info["functions"] == ["execute", "fetch", "process"]
        assert info["imports"] == []


# ── Tests for scan_codebase, CodebaseSummary, FileSymbols ────────────

from loom.scanner import FileSymbols, CodebaseSummary, scan_codebase


class TestScanCodebase:
    """scan_codebase produces a CodebaseSummary from a directory tree."""

    def test_scan_returns_summary_with_files(self, tmp_path: Path) -> None:
        _write(tmp_path / "main.py", "def run():\n    pass\n")
        _write(tmp_path / "lib" / "utils.py", "class Helper:\n    pass\n")

        summary = scan_codebase(tmp_path)

        assert summary.root == str(tmp_path.resolve())
        assert len(summary.files) == 2
        paths = [f.path for f in summary.files]
        assert "lib/utils.py" in paths
        assert "main.py" in paths

    def test_scan_extracts_symbols(self, tmp_path: Path) -> None:
        _write(tmp_path / "app.py", """\
import os

class App:
    def start(self):
        pass

def helper():
    pass
""")

        summary = scan_codebase(tmp_path)

        assert len(summary.files) == 1
        fs = summary.files[0]
        assert fs.path == "app.py"
        assert "App" in fs.classes
        assert "helper" in fs.functions
        assert "start" in fs.functions
        assert "os" in fs.imports

    def test_scan_filters_non_python_files(self, tmp_path: Path) -> None:
        _write(tmp_path / "code.py", "x = 1\n")
        _write(tmp_path / "readme.md", "# Hello\n")
        _write(tmp_path / "data.json", "{}\n")

        summary = scan_codebase(tmp_path)

        # Only .py files should appear
        assert len(summary.files) == 1
        assert summary.files[0].path == "code.py"

    def test_scan_empty_directory(self, tmp_path: Path) -> None:
        summary = scan_codebase(tmp_path)

        assert summary.root == str(tmp_path.resolve())
        assert summary.files == []

    def test_scan_handles_syntax_error_file(self, tmp_path: Path) -> None:
        _write(tmp_path / "good.py", "def ok():\n    pass\n")
        _write(tmp_path / "bad.py", "def broken(:\n    pass\n")

        summary = scan_codebase(tmp_path)

        assert len(summary.files) == 2
        good = [f for f in summary.files if f.path == "good.py"][0]
        bad = [f for f in summary.files if f.path == "bad.py"][0]
        assert "ok" in good.functions
        # Syntax error file should have empty lists
        assert bad.functions == []
        assert bad.classes == []


class TestToPromptStr:
    """to_prompt_str formats files compactly and respects token budget."""

    def test_basic_format(self) -> None:
        summary = CodebaseSummary(
            root="/tmp/proj",
            files=[
                FileSymbols(
                    path="app.py",
                    functions=["run", "stop"],
                    classes=["Server"],
                    imports=["os"],
                ),
            ],
        )

        result = summary.to_prompt_str()

        assert "## app.py" in result
        assert "functions: run, stop" in result
        assert "classes: Server" in result
        assert "imports: os" in result

    def test_omits_empty_sections(self) -> None:
        summary = CodebaseSummary(
            root="/tmp/proj",
            files=[
                FileSymbols(path="empty.py", functions=[], classes=[], imports=[]),
            ],
        )

        result = summary.to_prompt_str()

        assert "## empty.py" in result
        assert "functions:" not in result
        assert "classes:" not in result
        assert "imports:" not in result

    def test_empty_codebase(self) -> None:
        summary = CodebaseSummary(root="/tmp/proj", files=[])

        result = summary.to_prompt_str()

        assert result == "(empty codebase)"

    def test_token_budget_truncation(self) -> None:
        """With 25 files and a tiny budget, some files should be truncated."""
        files = []
        for i in range(25):
            files.append(
                FileSymbols(
                    path=f"mod_{i:02d}.py",
                    functions=[f"func_{i}"],
                    classes=[],
                    imports=[],
                )
            )

        summary = CodebaseSummary(root="/tmp/proj", files=files)

        # Use a very small budget so truncation is guaranteed
        result = summary.to_prompt_str(max_tokens=20)

        assert "## [truncated:" in result
        assert "files omitted]" in result
        # At least one file must always be included
        assert "## mod_" in result

    def test_always_includes_at_least_one_file(self) -> None:
        """Even with max_tokens=1, at least one file is included."""
        summary = CodebaseSummary(
            root="/tmp/proj",
            files=[
                FileSymbols(
                    path="big.py",
                    functions=["a", "b", "c", "d", "e"],
                    classes=["X", "Y", "Z"],
                    imports=["os", "sys"],
                ),
            ],
        )

        result = summary.to_prompt_str(max_tokens=1)

        assert "## big.py" in result

    def test_importance_ordering(self) -> None:
        """main.py should appear before a test_ file."""
        summary = CodebaseSummary(
            root="/tmp/proj",
            files=[
                FileSymbols(
                    path="test_stuff.py",
                    functions=["test_a"],
                    classes=[],
                    imports=[],
                ),
                FileSymbols(
                    path="main.py",
                    functions=["main"],
                    classes=[],
                    imports=[],
                ),
            ],
        )

        result = summary.to_prompt_str()
        main_pos = result.index("## main.py")
        test_pos = result.index("## test_stuff.py")

        assert main_pos < test_pos, "main.py should appear before test_ files"


class TestDictRoundTrip:
    """to_dict and from_dict should round-trip correctly."""

    def test_round_trip(self) -> None:
        original = CodebaseSummary(
            root="/tmp/proj",
            files=[
                FileSymbols(
                    path="app.py",
                    functions=["run"],
                    classes=["App"],
                    imports=["os", "sys"],
                ),
                FileSymbols(
                    path="lib/utils.py",
                    functions=["helper"],
                    classes=[],
                    imports=["pathlib"],
                ),
            ],
        )

        data = original.to_dict()
        restored = CodebaseSummary.from_dict(data)

        assert restored.root == original.root
        assert len(restored.files) == len(original.files)
        for orig_f, rest_f in zip(original.files, restored.files):
            assert orig_f.path == rest_f.path
            assert orig_f.functions == rest_f.functions
            assert orig_f.classes == rest_f.classes
            assert orig_f.imports == rest_f.imports

    def test_round_trip_empty(self) -> None:
        original = CodebaseSummary(root="/tmp/empty", files=[])

        data = original.to_dict()
        restored = CodebaseSummary.from_dict(data)

        assert restored.root == original.root
        assert restored.files == []

    def test_to_dict_structure(self) -> None:
        summary = CodebaseSummary(
            root="/tmp/proj",
            files=[
                FileSymbols(path="x.py", functions=["f"], classes=["C"], imports=["os"]),
            ],
        )

        data = summary.to_dict()

        assert isinstance(data, dict)
        assert data["root"] == "/tmp/proj"
        assert isinstance(data["files"], list)
        assert data["files"][0]["path"] == "x.py"
        assert data["files"][0]["functions"] == ["f"]
        assert data["files"][0]["classes"] == ["C"]
        assert data["files"][0]["imports"] == ["os"]


class TestFileSymbolsModel:
    """FileSymbols model validation."""

    def test_defaults(self) -> None:
        fs = FileSymbols(path="foo.py")
        assert fs.functions == []
        assert fs.classes == []
        assert fs.imports == []

    def test_with_data(self) -> None:
        fs = FileSymbols(
            path="bar.py",
            functions=["run"],
            classes=["App"],
            imports=["os"],
        )
        assert fs.path == "bar.py"
        assert fs.functions == ["run"]
        assert fs.classes == ["App"]
        assert fs.imports == ["os"]
