"""Tests for scanner integration in loom.skills.decomposer.

Covers:
  - _auto_scan wiring: scan_root triggers scan_codebase, result appears in prompt
  - Graceful degradation: scanner exceptions are caught, decompose continues
  - LOOM_SKIP_SCAN env var escape hatch
  - MAX_SCAN_CONTEXT_CHARS hard truncation of rendered context
"""

from __future__ import annotations

import os
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from loom.scanner import CodebaseSummary, FileSymbols
from loom.skills.decomposer import (
    CODEBASE_CONTEXT_SENTINEL,
    MAX_SCAN_CONTEXT_CHARS,
    _auto_scan,
    build_codebase_context,
)


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


def _make_summary(*file_specs: tuple[str, list[str], list[str]]) -> CodebaseSummary:
    """Build a CodebaseSummary from (path, functions, classes) tuples."""
    files = [
        FileSymbols(path=path, functions=funcs, classes=classes)
        for path, funcs, classes in file_specs
    ]
    return CodebaseSummary(root="/tmp/test-project", files=files)


FIXED_SUMMARY = _make_summary(
    ("src/app.py", ["main", "run_server"], ["Application"]),
    ("src/db.py", ["connect", "migrate"], ["Database"]),
)


# ── Tests: _auto_scan with mocked scanner ────────────────────────────


class TestAutoScanCallsScanner:
    """_auto_scan invokes scan_codebase and returns the result."""

    @patch("loom.skills.decomposer.scan_codebase", return_value=FIXED_SUMMARY)
    def test_scan_root_triggers_scanner(self, mock_scan: MagicMock) -> None:
        result = _auto_scan(Path("/some/project"))

        mock_scan.assert_called_once_with(Path("/some/project"))
        assert result is FIXED_SUMMARY

    @patch("loom.skills.decomposer.scan_codebase", return_value=FIXED_SUMMARY)
    def test_scan_result_symbols_appear_in_context(self, mock_scan: MagicMock) -> None:
        summary = _auto_scan(Path("/some/project"))
        ctx = build_codebase_context(summary)

        assert CODEBASE_CONTEXT_SENTINEL in ctx
        assert "main" in ctx
        assert "run_server" in ctx
        assert "Application" in ctx
        assert "Database" in ctx
        assert "src/app.py" in ctx

    def test_none_scan_root_returns_none(self) -> None:
        result = _auto_scan(None)
        assert result is None


class TestAutoScanGracefulDegradation:
    """Scanner exceptions are caught; _auto_scan returns None."""

    @patch("loom.skills.decomposer.scan_codebase", side_effect=OSError("disk error"))
    def test_os_error_returns_none(self, mock_scan: MagicMock) -> None:
        result = _auto_scan(Path("/broken"))

        assert result is None
        mock_scan.assert_called_once()

    @patch("loom.skills.decomposer.scan_codebase", side_effect=RuntimeError("boom"))
    def test_runtime_error_returns_none(self, mock_scan: MagicMock) -> None:
        result = _auto_scan(Path("/broken"))

        assert result is None

    @patch("loom.skills.decomposer.scan_codebase", side_effect=ValueError("bad root"))
    def test_value_error_returns_none(self, mock_scan: MagicMock) -> None:
        result = _auto_scan(Path("/nonexistent"))

        assert result is None


# ── Tests: LOOM_SKIP_SCAN env var ─────────────────────────────────────


class TestLoomSkipScan:
    """LOOM_SKIP_SCAN=1 prevents scanning entirely."""

    @patch("loom.skills.decomposer.scan_codebase")
    def test_skip_scan_true(self, mock_scan: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("LOOM_SKIP_SCAN", "1")

        result = _auto_scan(Path("/some/project"))

        assert result is None
        mock_scan.assert_not_called()

    @patch("loom.skills.decomposer.scan_codebase")
    def test_skip_scan_true_string(self, mock_scan: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("LOOM_SKIP_SCAN", "true")

        result = _auto_scan(Path("/some/project"))

        assert result is None
        mock_scan.assert_not_called()

    @patch("loom.skills.decomposer.scan_codebase")
    def test_skip_scan_yes(self, mock_scan: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("LOOM_SKIP_SCAN", "yes")

        result = _auto_scan(Path("/some/project"))

        assert result is None
        mock_scan.assert_not_called()

    @patch("loom.skills.decomposer.scan_codebase", return_value=FIXED_SUMMARY)
    def test_skip_scan_zero_does_not_skip(self, mock_scan: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("LOOM_SKIP_SCAN", "0")

        result = _auto_scan(Path("/some/project"))

        assert result is FIXED_SUMMARY
        mock_scan.assert_called_once()

    @patch("loom.skills.decomposer.scan_codebase", return_value=FIXED_SUMMARY)
    def test_skip_scan_unset_does_not_skip(self, mock_scan: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("LOOM_SKIP_SCAN", raising=False)

        result = _auto_scan(Path("/some/project"))

        assert result is FIXED_SUMMARY
        mock_scan.assert_called_once()


# ── Tests: MAX_SCAN_CONTEXT_CHARS truncation ──────────────────────────


class TestMaxScanContextCharsTruncation:
    """build_codebase_context hard-truncates at MAX_SCAN_CONTEXT_CHARS."""

    def test_small_summary_not_truncated(self) -> None:
        summary = _make_summary(("tiny.py", ["f"], []))
        ctx = build_codebase_context(summary)

        assert len(ctx) <= MAX_SCAN_CONTEXT_CHARS
        assert "[... truncated" not in ctx

    def test_large_summary_truncated(self) -> None:
        """A summary with many files should be truncated to MAX_SCAN_CONTEXT_CHARS."""
        files = [
            (f"module_{i:04d}.py", [f"func_{i}_a", f"func_{i}_b", f"func_{i}_c"], [f"Class{i}Alpha", f"Class{i}Beta"])
            for i in range(200)
        ]
        summary = _make_summary(*files)
        ctx = build_codebase_context(summary, max_tokens=100_000, max_chars=MAX_SCAN_CONTEXT_CHARS)

        assert len(ctx) <= MAX_SCAN_CONTEXT_CHARS
        assert "[... truncated to fit context budget ...]" in ctx

    def test_truncated_still_has_sentinel(self) -> None:
        """Even after truncation, the sentinel marker is present."""
        files = [
            (f"mod_{i:04d}.py", [f"fn_{i}_{j}" for j in range(10)], [f"Cls{i}"])
            for i in range(200)
        ]
        summary = _make_summary(*files)
        ctx = build_codebase_context(summary, max_tokens=100_000, max_chars=MAX_SCAN_CONTEXT_CHARS)

        # Sentinel is near the start and should survive truncation
        assert CODEBASE_CONTEXT_SENTINEL in ctx

    def test_custom_max_chars(self) -> None:
        """Custom max_chars parameter is respected."""
        files = [
            (f"f_{i}.py", [f"fn_{i}"], [f"C{i}"])
            for i in range(100)
        ]
        summary = _make_summary(*files)
        ctx = build_codebase_context(summary, max_tokens=100_000, max_chars=500)

        assert len(ctx) <= 500
        assert "[... truncated to fit context budget ...]" in ctx

    def test_max_scan_context_chars_constant(self) -> None:
        """The module constant is 4000."""
        assert MAX_SCAN_CONTEXT_CHARS == 4000


# ── Tests: decompose() scan_root parameter integration ────────────────


class TestDecomposeScanRootParameter:
    """The decompose() function's scan_root parameter triggers auto-scanning."""

    @patch("loom.skills.decomposer.scan_codebase", return_value=FIXED_SUMMARY)
    @patch("loom.skills.decomposer.run_skill")
    @patch("loom.skills.decomposer.load_skill")
    async def test_scan_root_triggers_scan_in_decompose(
        self, mock_load: MagicMock, mock_run: MagicMock, mock_scan: MagicMock
    ) -> None:
        """When scan_root is passed and codebase_summary is None, scanner runs."""
        from loom.skills.decomposer import decompose

        mock_run.return_value = {
            "tasks": [
                {"title": "Task A", "type": "task", "done_when": "tests pass"},
            ]
        }

        result = await decompose(
            goal="Build something",
            project_id="proj-123",
            config=MagicMock(),
            pool=MagicMock(),
            redis=MagicMock(),
            scan_root=Path("/my/project"),
            enrich=False,
            confirm=True,
        )

        mock_scan.assert_called_once_with(Path("/my/project"))
        assert result.total_count == 1

    @patch("loom.skills.decomposer.scan_codebase")
    @patch("loom.skills.decomposer.run_skill")
    @patch("loom.skills.decomposer.load_skill")
    async def test_explicit_summary_skips_auto_scan(
        self, mock_load: MagicMock, mock_run: MagicMock, mock_scan: MagicMock
    ) -> None:
        """When codebase_summary is explicitly provided, scan_root is ignored."""
        from loom.skills.decomposer import decompose

        mock_run.return_value = {
            "tasks": [
                {"title": "Task A", "type": "task", "done_when": "tests pass"},
            ]
        }

        await decompose(
            goal="Build something",
            project_id="proj-123",
            config=MagicMock(),
            pool=MagicMock(),
            redis=MagicMock(),
            codebase_summary=FIXED_SUMMARY,
            scan_root=Path("/my/project"),
            enrich=False,
            confirm=True,
        )

        mock_scan.assert_not_called()

    @patch("loom.skills.decomposer.scan_codebase", side_effect=RuntimeError("fail"))
    @patch("loom.skills.decomposer.run_skill")
    @patch("loom.skills.decomposer.load_skill")
    async def test_scan_failure_does_not_crash_decompose(
        self, mock_load: MagicMock, mock_run: MagicMock, mock_scan: MagicMock
    ) -> None:
        """When scanner fails, decompose still succeeds without context."""
        from loom.skills.decomposer import decompose

        mock_run.return_value = {
            "tasks": [
                {"title": "Task A", "type": "task", "done_when": "tests pass"},
            ]
        }

        result = await decompose(
            goal="Build something",
            project_id="proj-123",
            config=MagicMock(),
            pool=MagicMock(),
            redis=MagicMock(),
            scan_root=Path("/bad/path"),
            enrich=False,
            confirm=True,
        )

        assert result.total_count == 1
