"""Tests for validate_file_refs() from llm/missions/validation.py.

Tests verify:
- File references are validated against the filesystem
- Invalid paths are removed with warnings
- Line ranges are clamped to actual file bounds
- Path traversal attempts are blocked
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st

from unfamiliar.llm.missions.schemas import MissionFileRefResponse
from unfamiliar.llm.missions.validation import validate_file_refs

if TYPE_CHECKING:
    from pathlib import Path


def make_file_ref(
    file_path: str,
    line_start: int | None = None,
    line_end: int | None = None,
    description: str = "Test file reference",
) -> MissionFileRefResponse:
    """Create a MissionFileRefResponse for testing."""
    return MissionFileRefResponse(
        file_path=file_path,
        line_start=line_start,
        line_end=line_end,
        description=description,
    )


def create_file_with_lines(tmp_path: Path, relative_path: str, line_count: int) -> Path:
    """Create a file with the specified number of lines.

    Args:
        tmp_path: Temporary directory fixture.
        relative_path: Path relative to tmp_path.
        line_count: Number of lines to create.

    Returns:
        Path to the created file.
    """
    file_path = tmp_path / relative_path
    file_path.parent.mkdir(parents=True, exist_ok=True)
    content = "\n".join(f"Line {i + 1}" for i in range(line_count))
    file_path.write_text(content, encoding="utf-8")
    return file_path


class TestValidateFileRefsHappyPath:
    """Tests for validate_file_refs() happy path scenarios."""

    def test_empty_file_refs_returns_empty_lists(self, tmp_path: Path):
        """Empty file_refs list returns empty validated list and no warnings."""
        validated, warnings = validate_file_refs([], tmp_path)

        assert validated == []
        assert warnings == []

    def test_valid_file_path_returns_ref_unchanged(self, tmp_path: Path):
        """Valid file path with correct line range returns ref unchanged."""
        create_file_with_lines(tmp_path, "src/main.py", 50)

        ref = make_file_ref("src/main.py", line_start=10, line_end=20)
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert len(validated) == 1
        assert validated[0].file_path == "src/main.py"
        assert validated[0].line_start == 10
        assert validated[0].line_end == 20
        assert validated[0].description == "Test file reference"
        assert warnings == []

    def test_multiple_valid_refs_returned_in_order(self, tmp_path: Path):
        """Multiple valid file refs are all returned in original order."""
        create_file_with_lines(tmp_path, "src/a.py", 10)
        create_file_with_lines(tmp_path, "src/b.py", 20)
        create_file_with_lines(tmp_path, "src/c.py", 30)

        refs = [
            make_file_ref("src/a.py", 1, 5),
            make_file_ref("src/b.py", 5, 15),
            make_file_ref("src/c.py", 10, 25),
        ]
        validated, warnings = validate_file_refs(refs, tmp_path)

        assert len(validated) == 3
        assert validated[0].file_path == "src/a.py"
        assert validated[1].file_path == "src/b.py"
        assert validated[2].file_path == "src/c.py"
        assert warnings == []


class TestValidateFileRefsNonExistentFiles:
    """Tests for validate_file_refs() with non-existent file paths."""

    def test_non_existent_file_removed_with_warning(self, tmp_path: Path):
        """Non-existent file path is removed from output with warning."""
        ref = make_file_ref("does/not/exist.py", 1, 10)
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert validated == []
        assert len(warnings) == 1
        assert "File not found" in warnings[0]
        assert "does/not/exist.py" in warnings[0]

    def test_path_traversal_attempt_removed_with_warning(self, tmp_path: Path):
        """Path traversal attempt like '../../../etc/passwd' is removed with warning.

        Note: The validation may report either 'Path traversal' or 'File not found'
        depending on whether the target file exists. Both are secure behaviors
        that block the traversal attempt.
        """
        create_file_with_lines(tmp_path, "src/safe.py", 10)

        ref = make_file_ref("../../../etc/passwd", 1, 10)
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert validated == []
        assert len(warnings) == 1
        # Either "Path traversal" or "File not found" is acceptable -
        # both indicate the traversal attempt was blocked
        assert "Path traversal" in warnings[0] or "File not found" in warnings[0]

    def test_all_invalid_paths_returns_empty_with_warnings(self, tmp_path: Path):
        """All file paths invalid returns empty list with one warning per
        invalid path.
        """
        refs = [
            make_file_ref("missing1.py", 1, 10),
            make_file_ref("missing2.py", 5, 15),
            make_file_ref("missing3.py", 2, 8),
        ]
        validated, warnings = validate_file_refs(refs, tmp_path)

        assert validated == []
        assert len(warnings) == 3
        # Each warning should mention its respective file
        assert any("missing1.py" in w for w in warnings)
        assert any("missing2.py" in w for w in warnings)
        assert any("missing3.py" in w for w in warnings)


class TestValidateFileRefsLineRangeSwapping:
    """Tests for validate_file_refs() line range swapping."""

    def test_line_start_greater_than_line_end_swapped(self, tmp_path: Path):
        """line_start > line_end is swapped with warning logged."""
        create_file_with_lines(tmp_path, "src/utils.py", 50)

        ref = make_file_ref("src/utils.py", line_start=30, line_end=10)
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert len(validated) == 1
        assert validated[0].line_start == 10
        assert validated[0].line_end == 30
        assert len(warnings) == 1
        assert "Swapped line range" in warnings[0]
        assert "10-30" in warnings[0]


class TestValidateFileRefsLineClamping:
    """Tests for validate_file_refs() line clamping to file bounds."""

    def test_line_start_exceeds_file_count_clamped(self, tmp_path: Path):
        """line_start > file_line_count is clamped to last line."""
        create_file_with_lines(tmp_path, "src/small.py", 10)

        ref = make_file_ref("src/small.py", line_start=50, line_end=60)
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert len(validated) == 1
        assert validated[0].line_start == 10
        assert validated[0].line_end == 10
        assert len(warnings) == 1
        assert "Clamped" in warnings[0]

    def test_line_start_zero_fixed_to_one(self, tmp_path: Path):
        """line_start = 0 is fixed to 1 (1-indexed auto-fix)."""
        create_file_with_lines(tmp_path, "src/code.py", 20)

        ref = make_file_ref("src/code.py", line_start=0, line_end=10)
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert len(validated) == 1
        assert validated[0].line_start == 1
        assert validated[0].line_end == 10
        assert len(warnings) == 1
        assert "0 -> 1" in warnings[0] or "1-indexed" in warnings[0]

    def test_line_end_exceeds_file_count_clamped(self, tmp_path: Path):
        """line_end > file_line_count is clamped to file_line_count."""
        create_file_with_lines(tmp_path, "src/short.py", 15)

        ref = make_file_ref("src/short.py", line_start=5, line_end=100)
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert len(validated) == 1
        assert validated[0].line_start == 5
        assert validated[0].line_end == 15
        assert len(warnings) == 1
        assert "Clamped line_end" in warnings[0]


class TestValidateFileRefsNullLineValues:
    """Tests for validate_file_refs() with null line values (whole-file refs)."""

    def test_null_line_values_passed_through_unchanged(self, tmp_path: Path):
        """line_start=None, line_end=None are passed through unchanged
        (whole-file ref).
        """
        create_file_with_lines(tmp_path, "src/entire.py", 100)

        ref = make_file_ref("src/entire.py", line_start=None, line_end=None)
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert len(validated) == 1
        assert validated[0].line_start is None
        assert validated[0].line_end is None
        assert warnings == []


class TestValidateFileRefsBinaryFiles:
    """Tests for validate_file_refs() with binary/unreadable files."""

    def test_binary_file_skipped_with_warning(self, tmp_path: Path):
        """Binary/unreadable file is skipped with warning."""
        binary_path = tmp_path / "binary.bin"
        # Write actual binary content (not valid UTF-8)
        binary_path.write_bytes(b"\x00\x01\x02\xff\xfe\xfd")

        ref = make_file_ref("binary.bin", line_start=1, line_end=10)
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert validated == []
        assert len(warnings) == 1
        assert "Could not read file" in warnings[0] or "binary.bin" in warnings[0]


class TestValidateFileRefsPropertyBased:
    """Property-based tests for validate_file_refs() invariants."""

    @given(
        line_start=st.integers(min_value=0, max_value=1000),
        line_end=st.integers(min_value=0, max_value=1000),
        line_count=st.integers(min_value=1, max_value=100),
    )
    @settings(
        max_examples=50,
        suppress_health_check=[
            HealthCheck.function_scoped_fixture,
            HealthCheck.differing_executors,
        ],
        deadline=None,
    )
    def test_line_clamping_invariant(
        self, tmp_path: Path, line_start: int, line_end: int, line_count: int
    ):
        """Line clamping invariant: output always satisfies
        1 <= line_start <= line_end <= N.

        For any line_start (0-1000), line_end (0-1000), and a file with N lines (1-100):
        output always satisfies 1 <= line_start <= line_end <= N.
        """
        create_file_with_lines(tmp_path, "test.py", line_count)

        ref = make_file_ref("test.py", line_start=line_start, line_end=line_end)
        validated, warnings = validate_file_refs([ref], tmp_path)

        # Should always return exactly one ref since file exists
        assert len(validated) == 1

        result = validated[0]
        # After validation, line_start and line_end should never be None
        # (unless both were None, meaning whole-file ref)
        if line_start is not None or line_end is not None:
            # If either was specified, they should now be valid bounds
            assert result.line_start is not None
            assert result.line_end is not None
            assert 1 <= result.line_start <= line_count
            assert 1 <= result.line_end <= line_count
            assert result.line_start <= result.line_end

    def test_idempotence_validated_refs_unchanged(self, tmp_path: Path):
        """Idempotence: Validating an already-validated set of file refs
         produces identical output.

        Running validate_file_refs twice on valid refs produces same output with no
        new warnings.
        """
        create_file_with_lines(tmp_path, "src/stable.py", 50)

        ref = make_file_ref("src/stable.py", line_start=10, line_end=30)
        first_validated, first_warnings = validate_file_refs([ref], tmp_path)

        # Second pass with the validated refs
        second_validated, second_warnings = validate_file_refs(
            first_validated, tmp_path
        )

        # Output should be identical
        assert len(second_validated) == len(first_validated)
        assert second_validated[0].file_path == first_validated[0].file_path
        assert second_validated[0].line_start == first_validated[0].line_start
        assert second_validated[0].line_end == first_validated[0].line_end
        assert second_warnings == []


class TestValidateFileRefsMixedScenarios:
    """Tests for validate_file_refs() with mixed valid/invalid scenarios."""

    def test_mixed_valid_and_invalid_returns_only_valid(self, tmp_path: Path):
        """Mix of valid and invalid refs returns only valid ones with
        appropriate warnings.
        """
        create_file_with_lines(tmp_path, "src/valid.py", 20)

        refs = [
            make_file_ref("src/valid.py", 1, 10),
            make_file_ref("src/missing.py", 1, 10),
            make_file_ref("src/valid.py", 15, 20),
        ]
        validated, warnings = validate_file_refs(refs, tmp_path)

        assert len(validated) == 2
        assert all(r.file_path == "src/valid.py" for r in validated)
        assert len(warnings) == 1
        assert "missing.py" in warnings[0]

    def test_valid_ref_with_line_adjustments_preserved(self, tmp_path: Path):
        """Valid file with line range needing adjustment is still included."""
        create_file_with_lines(tmp_path, "src/file.py", 25)

        ref = make_file_ref("src/file.py", line_start=20, line_end=50)  # Exceeds file
        validated, warnings = validate_file_refs([ref], tmp_path)

        assert len(validated) == 1
        assert validated[0].file_path == "src/file.py"
        assert validated[0].line_end == 25  # Clamped
        assert len(warnings) == 1
