"""Tests for test_runner.common.dependency_classifier."""

from __future__ import annotations

import sys
from types import SimpleNamespace

import pytest

from test_runner.common.dependency_classifier import (
    _compute_checksums,
    _format_name,
    classify,
    enrich_test_files,
    extract_code_unit_id,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _dep(target_id: str, *relation_types: str) -> SimpleNamespace:
    return SimpleNamespace(id=target_id, relationTypes=list(relation_types))


def _unit(
    uid: str,
    schema: str = "dbo",
    database: str = "DB",
    *,
    deps: list[SimpleNamespace] | None = None,
) -> SimpleNamespace:
    return SimpleNamespace(
        id=uid,
        source=SimpleNamespace(name=uid, schema_=schema, database=database),
        dependencies=SimpleNamespace(dependsOn=deps or []),
    )


def _build_index(*units: SimpleNamespace) -> dict[str, SimpleNamespace]:
    return {u.id: u for u in units}


# ---------------------------------------------------------------------------
# Direct relations
# ---------------------------------------------------------------------------

class TestDirectRelations:
    def test_direct_insert(self):
        index = _build_index(
            _unit("proc1", deps=[_dep("table1", "INSERT")]),
            _unit("table1"),
        )
        result = classify("proc1", index)
        assert result.modifies_data is True
        assert result.affected_ids == frozenset({"table1"})

    def test_pure_select(self):
        index = _build_index(
            _unit("proc1", deps=[_dep("table1", "SELECT - JOIN")]),
            _unit("table1"),
        )
        result = classify("proc1", index)
        assert result.modifies_data is False
        assert result.affected_ids == frozenset()

    def test_mixed_execute_and_insert(self):
        index = _build_index(
            _unit("proc1", deps=[_dep("proc2", "EXECUTE", "INSERT")]),
            _unit("proc2"),
        )
        result = classify("proc1", index)
        assert result.modifies_data is True
        assert "proc2" in result.affected_ids


# ---------------------------------------------------------------------------
# Transitive via closure
# ---------------------------------------------------------------------------

class TestTransitive:
    def test_execute_then_insert(self):
        index = _build_index(
            _unit("proc1", deps=[_dep("proc2", "EXECUTE")]),
            _unit("proc2", deps=[_dep("table1", "INSERT")]),
            _unit("table1"),
        )
        result = classify("proc1", index)
        assert result.modifies_data is True
        assert "table1" in result.affected_ids

    def test_multi_level_chain(self):
        index = _build_index(
            _unit("A", deps=[_dep("B", "EXECUTE")]),
            _unit("B", deps=[_dep("C", "EXECUTE")]),
            _unit("C", deps=[_dep("table1", "UPDATE")]),
            _unit("table1"),
        )
        result = classify("A", index)
        assert result.modifies_data is True
        assert "table1" in result.affected_ids

    def test_transitive_pure_read(self):
        index = _build_index(
            _unit("proc1", deps=[_dep("proc2", "EXECUTE")]),
            _unit("proc2", deps=[_dep("table1", "SELECT - FROM")]),
            _unit("table1"),
        )
        result = classify("proc1", index)
        assert result.modifies_data is False
        assert result.affected_ids == frozenset()


# ---------------------------------------------------------------------------
# Cycles (flat scan — no infinite-loop risk)
# ---------------------------------------------------------------------------

class TestCycles:
    def test_mutual_recursion_read_only(self):
        index = _build_index(
            _unit("A", deps=[_dep("B", "EXECUTE")]),
            _unit("B", deps=[_dep("A", "EXECUTE")]),
        )
        assert classify("A", index).modifies_data is False

    def test_mutual_recursion_with_write(self):
        index = _build_index(
            _unit("A", deps=[_dep("B", "EXECUTE")]),
            _unit("B", deps=[_dep("A", "EXECUTE"), _dep("table1", "INSERT")]),
            _unit("table1"),
        )
        assert classify("A", index).modifies_data is True
        assert classify("B", index).modifies_data is True

    def test_self_recursion(self):
        index = _build_index(
            _unit("A", deps=[_dep("A", "EXECUTE"), _dep("table1", "SELECT - FROM")]),
            _unit("table1"),
        )
        assert classify("A", index).modifies_data is False

    def test_triangle_with_write(self):
        index = _build_index(
            _unit("A", deps=[_dep("B", "EXECUTE")]),
            _unit("B", deps=[_dep("C", "EXECUTE")]),
            _unit("C", deps=[_dep("A", "EXECUTE"), _dep("table1", "DELETE")]),
            _unit("table1"),
        )
        assert classify("A", index).modifies_data is True
        assert classify("B", index).modifies_data is True
        assert classify("C", index).modifies_data is True


# ---------------------------------------------------------------------------
# Diamond (shared subgraph)
# ---------------------------------------------------------------------------

class TestDiamond:
    def test_deduplicates_affected_ids(self):
        index = _build_index(
            _unit("A", deps=[_dep("B", "EXECUTE"), _dep("C", "EXECUTE")]),
            _unit("B", deps=[_dep("D", "EXECUTE")]),
            _unit("C", deps=[_dep("D", "EXECUTE")]),
            _unit("D", deps=[_dep("table1", "UPDATE")]),
            _unit("table1"),
        )
        result = classify("A", index)
        assert result.modifies_data is True
        assert result.affected_ids == frozenset({"table1"})


# ---------------------------------------------------------------------------
# _format_name (always source)
# ---------------------------------------------------------------------------

class TestFormatName:
    def test_returns_fqn(self):
        unit = _unit("t1", schema="dbo", database="SrcDB")
        assert _format_name(unit) == "SrcDB.dbo.t1"

    def test_partial_name_no_database(self):
        unit = SimpleNamespace(
            source=SimpleNamespace(name="Orders", schema_="dbo", database=None),
        )
        assert _format_name(unit) == "dbo.Orders"

    def test_missing_source_returns_none(self):
        unit = SimpleNamespace(source=None)
        assert _format_name(unit) is None


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------

class TestEdgeCases:
    def test_empty_dependencies(self):
        result = classify("proc1", _build_index(_unit("proc1")))
        assert result.modifies_data is False
        assert result.affected_ids == frozenset()

    def test_null_dependencies(self):
        unit = SimpleNamespace(
            id="proc1",
            source=SimpleNamespace(name="proc1", schema_="dbo", database="DB"),
            dependencies=None,
        )
        result = classify("proc1", {"proc1": unit})
        assert result.modifies_data is False
        assert result.affected_ids == frozenset()

    def test_missing_target_graceful(self):
        index = _build_index(_unit("proc1", deps=[_dep("nonexistent", "EXECUTE")]))
        result = classify("proc1", index)
        assert result.modifies_data is False
        assert result.affected_ids == frozenset()

    def test_unknown_start_id(self):
        result = classify("nonexistent", {})
        assert result.modifies_data is False
        assert result.affected_ids == frozenset()

    def test_all_write_relation_types(self):
        for wt in ("INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "ALTER", "DROP"):
            index = _build_index(
                _unit("proc1", deps=[_dep("table1", wt)]),
                _unit("table1"),
            )
            assert classify("proc1", index).modifies_data is True, f"{wt} should be a write"

    def test_case_insensitive_relation_types(self):
        index = _build_index(
            _unit("proc1", deps=[_dep("table1", "insert")]),
            _unit("table1"),
        )
        assert classify("proc1", index).modifies_data is True


# ---------------------------------------------------------------------------
# extract_code_unit_id
# ---------------------------------------------------------------------------

class TestExtractCodeUnitId:
    def test_typical_path(self):
        path = "/project/artifacts/testdb/dbo/procedure/myproc/test/cu-abc-123.yml"
        assert extract_code_unit_id(path) == "cu-abc-123"

    def test_relative_path(self):
        path = "artifacts/db/dbo/procedure/p/test/unit-id.yml"
        assert extract_code_unit_id(path) == "unit-id"


# ---------------------------------------------------------------------------
# CUR integration
# ---------------------------------------------------------------------------

class TestCurIntegration:
    def test_build_closure_index_options_and_id_escaping(self):
        captured = {}
        from test_runner.common import dependency_classifier as mod

        class FakeFindOptions:
            def __init__(self, *, filter=None, fields=None, include_dependencies=False):
                self.filter = filter
                self.fields = fields
                self.include_dependencies = include_dependencies

        fake_pkg = SimpleNamespace(FindOptions=FakeFindOptions)
        mp = pytest.MonkeyPatch()
        mp.setitem(sys.modules, "snowflake_code_unit_registry", fake_pkg)

        class FakeRegistry:
            def find_all(self, options):
                captured["filter"] = options.filter
                captured["fields"] = list(options.fields or [])
                captured["include_deps"] = options.include_dependencies
                return [SimpleNamespace(id="a"), SimpleNamespace(id="b")]

        index = mod._build_closure_index(FakeRegistry(), "id-with-'quote")
        assert sorted(index.keys()) == ["a", "b"]
        assert captured["include_deps"] is True
        assert captured["fields"] == ["id", "source", "target", "dependencies", "files"]
        assert captured["filter"] == "id = 'id-with-''quote'"
        mp.undo()

    def test_enrich_respects_yaml_override(self, monkeypatch):
        """Classification is skipped for YAML-overridden files, but checksums are still computed."""
        root = _unit("cu-1", deps=[_dep("table1", "INSERT")])
        tf = SimpleNamespace(
            file_path="artifacts/db/dbo/procedure/p/test/cu-1.yml",
            modifies_data=False,  # YAML override — would be True from CUR deps
            affected_tables=["manual.override"],
            source_checksum=None,
            converted_checksum=None,
        )

        from test_runner.common import dependency_classifier as mod
        monkeypatch.setattr(mod, "_open_registry", lambda _: object())
        monkeypatch.setattr(mod, "_build_closure_index", lambda _r, _id: {"cu-1": root})
        monkeypatch.setattr(mod, "_compute_checksums", lambda _unit, _root: ("s", "c"))

        enrich_test_files([tf], "/project")
        assert tf.modifies_data is False          # not overwritten by CUR
        assert tf.affected_tables == ["manual.override"]  # not overwritten
        assert tf.source_checksum == "s"          # checksum is still populated
        assert tf.converted_checksum == "c"

    def test_enrich_populates_source_names(self, monkeypatch):
        root = _unit("cu-1", deps=[_dep("table1", "INSERT")])
        table = _unit("table1", schema="dbo", database="DB")

        tf = SimpleNamespace(
            file_path="artifacts/db/dbo/procedure/p/test/cu-1.yml",
            modifies_data=None,
            affected_tables=[],
            source_checksum=None,
            converted_checksum=None,
        )

        from test_runner.common import dependency_classifier as mod
        monkeypatch.setattr(mod, "_open_registry", lambda _: object())
        monkeypatch.setattr(
            mod, "_build_closure_index",
            lambda _r, _id: {u.id: u for u in (root, table)},
        )
        monkeypatch.setattr(mod, "_compute_checksums", lambda _unit, _root: ("srcsha", "convsha"))

        enrich_test_files([tf], "/project")
        assert tf.modifies_data is True
        assert tf.affected_tables == ["DB.dbo.table1"]
        assert tf.source_checksum == "srcsha"
        assert tf.converted_checksum == "convsha"


# ---------------------------------------------------------------------------
# _compute_checksums
# ---------------------------------------------------------------------------

class TestComputeChecksums:
    def _unit_with_files(self, src_path: str | None, conv_path: str | None) -> SimpleNamespace:
        src = SimpleNamespace(path=src_path) if src_path is not None else None
        conv = SimpleNamespace(path=conv_path) if conv_path is not None else None
        return SimpleNamespace(files=SimpleNamespace(source=src, converted=conv))

    def test_returns_checksums_for_existing_files(self, tmp_path, monkeypatch):
        src_file = tmp_path / "src.sql"
        conv_file = tmp_path / "conv.sql"
        src_file.write_text("SELECT 1")
        conv_file.write_text("SELECT 2")

        fake_pkg = SimpleNamespace(compute_file_checksum=lambda p: f"hash:{p}")
        monkeypatch.setitem(sys.modules, "snowflake_code_unit_registry", fake_pkg)

        unit = self._unit_with_files("src.sql", "conv.sql")
        src_cs, conv_cs = _compute_checksums(unit, tmp_path)
        assert src_cs == f"hash:{tmp_path / 'src.sql'}"
        assert conv_cs == f"hash:{tmp_path / 'conv.sql'}"

    @pytest.mark.parametrize("unit,case", [
        (SimpleNamespace(files=None), "files_is_none"),
        (SimpleNamespace(files=SimpleNamespace(source=None, converted=None)), "paths_are_none"),
    ])
    def test_returns_none_when_no_useful_files(self, unit, case, tmp_path, monkeypatch):
        fake_pkg = SimpleNamespace(compute_file_checksum=lambda p: "sha")
        monkeypatch.setitem(sys.modules, "snowflake_code_unit_registry", fake_pkg)

        src_cs, conv_cs = _compute_checksums(unit, tmp_path)
        assert (src_cs, conv_cs) == (None, None)

    def test_returns_none_on_compute_error(self, tmp_path, monkeypatch):
        def _bad_compute(p):
            raise OSError("file not found")

        fake_pkg = SimpleNamespace(compute_file_checksum=_bad_compute)
        monkeypatch.setitem(sys.modules, "snowflake_code_unit_registry", fake_pkg)

        unit = self._unit_with_files("missing.sql", None)
        src_cs, conv_cs = _compute_checksums(unit, tmp_path)
        assert src_cs is None
        assert conv_cs is None
