"""Tests for the @covers decorator, linkage registry, and coverage anchor scanner.

Validates REQ format checking, brief-backed existence validation,
LinkageRecord registration, metadata-only behavior, AST scanning,
and multi-level coverage rollup computation.
"""

import tempfile
import textwrap
import unittest
from pathlib import Path

from gzkit.traceability import (
    CoverageReport,
    compute_coverage,
    covers,
    find_covers_in_source,
    get_registry,
    reset_registry,
    scan_feature_tree,
    scan_test_tree,
    set_known_reqs,
)
from gzkit.triangle import (
    DiscoveredReq,
    EdgeType,
    ReqEntity,
    ReqId,
    ReqStatus,
    VertexType,
)

# Known REQs for testing (simulates brief-extracted REQs)
_TEST_REQS = frozenset(
    {
        "REQ-0.15.0-03-01",
        "REQ-0.15.0-03-02",
        "REQ-0.20.0-01-01",
    }
)


# ---------------------------------------------------------------------------
# OBPI-01: @covers decorator tests
# ---------------------------------------------------------------------------


class TestCoversFormatValidation(unittest.TestCase):
    """REQ format validation at decoration time."""

    def setUp(self):
        reset_registry()
        set_known_reqs(_TEST_REQS)

    def tearDown(self):
        reset_registry()

    def test_valid_req_format_accepted(self):
        @covers("REQ-0.15.0-03-02")
        def test_fn():
            return 42

        self.assertEqual(test_fn(), 42)

    @covers("REQ-0.21.0-01-02")
    def test_invalid_format_raises_value_error(self):
        with self.assertRaises(ValueError) as ctx:

            @covers("INVALID")
            def test_fn():
                pass

        self.assertIn("Invalid REQ identifier", str(ctx.exception))

    def test_empty_string_raises_value_error(self):
        with self.assertRaises(ValueError):

            @covers("")
            def test_fn():
                pass

    def test_partial_format_raises_value_error(self):
        with self.assertRaises(ValueError):

            @covers("REQ-0.15.0")
            def test_fn():
                pass

    def test_obpi_format_raises_value_error(self):
        with self.assertRaises(ValueError):

            @covers("OBPI-0.15.0-03")
            def test_fn():
                pass


class TestCoversExistenceValidation(unittest.TestCase):
    """Brief-backed REQ existence validation."""

    def setUp(self):
        reset_registry()
        set_known_reqs(_TEST_REQS)

    def tearDown(self):
        reset_registry()

    @covers("REQ-0.21.0-01-03")
    def test_unknown_req_raises_value_error(self):
        with self.assertRaises(ValueError) as ctx:

            @covers("REQ-9.9.9-99-99")
            def test_fn():
                pass

        self.assertIn("Unknown REQ identifier", str(ctx.exception))
        self.assertIn("REQ-9.9.9-99-99", str(ctx.exception))

    def test_known_req_does_not_raise(self):
        @covers("REQ-0.15.0-03-01")
        def test_fn():
            pass

    def test_no_orphan_linkage_on_unknown(self):
        try:

            @covers("REQ-9.9.9-99-99")
            def test_fn():
                pass
        except ValueError:
            pass

        self.assertEqual(len(get_registry()), 0)


class TestCoversLinkageRegistration(unittest.TestCase):
    """LinkageRecord registration and structure."""

    def setUp(self):
        reset_registry()
        set_known_reqs(_TEST_REQS)

    def tearDown(self):
        reset_registry()

    @covers("REQ-0.21.0-01-01")
    def test_single_covers_registers_one_linkage(self):
        @covers("REQ-0.15.0-03-02")
        def test_fn():
            pass

        self.assertEqual(len(get_registry()), 1)

    def test_linkage_record_structure(self):
        @covers("REQ-0.15.0-03-02")
        def test_fn():
            pass

        record = get_registry()[0]
        self.assertEqual(record.edge_type, EdgeType.COVERS)
        self.assertEqual(record.source.vertex_type, VertexType.TEST)
        self.assertEqual(record.target.vertex_type, VertexType.SPEC)
        self.assertEqual(record.target.identifier, "REQ-0.15.0-03-02")
        self.assertIn("test_fn", record.source.identifier)

    @covers("REQ-0.21.0-01-04")
    def test_multiple_covers_register_multiple_linkages(self):
        @covers("REQ-0.15.0-03-01")
        @covers("REQ-0.15.0-03-02")
        def test_fn():
            pass

        registry = get_registry()
        self.assertEqual(len(registry), 2)
        target_ids = {r.target.identifier for r in registry}
        self.assertEqual(target_ids, {"REQ-0.15.0-03-01", "REQ-0.15.0-03-02"})

    def test_linkage_source_includes_file_info(self):
        @covers("REQ-0.15.0-03-02")
        def test_fn():
            pass

        record = get_registry()[0]
        self.assertIsNotNone(record.source.location)
        self.assertIsNotNone(record.source.line)


class TestCoversMetadataOnly(unittest.TestCase):
    """@covers is metadata-only -- no behavior change."""

    def setUp(self):
        reset_registry()
        set_known_reqs(_TEST_REQS)

    def tearDown(self):
        reset_registry()

    @covers("REQ-0.21.0-01-05")
    def test_return_value_preserved(self):
        @covers("REQ-0.15.0-03-02")
        def test_fn():
            return 42

        self.assertEqual(test_fn(), 42)

    def test_arguments_passed_through(self):
        @covers("REQ-0.15.0-03-02")
        def test_fn(a, b, keyword=None):
            return (a, b, keyword)

        self.assertEqual(test_fn(1, 2, keyword="x"), (1, 2, "x"))

    def test_function_identity_preserved(self):
        def original_fn():
            pass

        decorated = covers("REQ-0.15.0-03-02")(original_fn)
        self.assertIs(decorated, original_fn)


class TestCoversWithTestCase(unittest.TestCase):
    """@covers works with unittest.TestCase methods."""

    def setUp(self):
        reset_registry()
        set_known_reqs(_TEST_REQS)

    def tearDown(self):
        reset_registry()

    def test_unittest_method_registration(self):
        class SampleTest(unittest.TestCase):
            @covers("REQ-0.15.0-03-02")
            def test_sample(self):
                self.assertTrue(True)

        suite = unittest.TestLoader().loadTestsFromTestCase(SampleTest)
        result = unittest.TestResult()
        suite.run(result)

        self.assertEqual(result.testsRun, 1)
        self.assertEqual(len(result.failures), 0)
        self.assertEqual(len(result.errors), 0)

        registry = get_registry()
        self.assertEqual(len(registry), 1)
        self.assertIn("SampleTest.test_sample", registry[0].source.identifier)


class TestCoversWithStandaloneFunction(unittest.TestCase):
    """@covers works with standalone test functions."""

    def setUp(self):
        reset_registry()
        set_known_reqs(_TEST_REQS)

    def tearDown(self):
        reset_registry()

    def test_standalone_function_registration(self):
        @covers("REQ-0.15.0-03-02")
        def standalone_test():
            return "result"

        self.assertEqual(standalone_test(), "result")

        registry = get_registry()
        self.assertEqual(len(registry), 1)
        self.assertIn("standalone_test", registry[0].source.identifier)


class TestGlobalRegistry(unittest.TestCase):
    """Global registry collects all registered linkages."""

    def setUp(self):
        reset_registry()
        set_known_reqs(_TEST_REQS)

    def tearDown(self):
        reset_registry()

    def test_registry_accumulates_across_decorators(self):
        @covers("REQ-0.15.0-03-01")
        def test_a():
            pass

        @covers("REQ-0.15.0-03-02")
        def test_b():
            pass

        @covers("REQ-0.20.0-01-01")
        def test_c():
            pass

        self.assertEqual(len(get_registry()), 3)

    def test_get_registry_returns_copy(self):
        @covers("REQ-0.15.0-03-02")
        def test_fn():
            pass

        registry = get_registry()
        registry.clear()
        self.assertEqual(len(get_registry()), 1)

    def test_reset_clears_registry(self):
        @covers("REQ-0.15.0-03-02")
        def test_fn():
            pass

        self.assertEqual(len(get_registry()), 1)
        reset_registry()
        set_known_reqs(_TEST_REQS)
        self.assertEqual(len(get_registry()), 0)


# ---------------------------------------------------------------------------
# OBPI-02: Coverage anchor scanner tests
# ---------------------------------------------------------------------------


def _make_req(semver: str, obpi_item: str, criterion: str, parent_obpi: str) -> DiscoveredReq:
    """Helper to create a DiscoveredReq for testing."""
    req_id = ReqId(semver=semver, obpi_item=obpi_item, criterion_index=criterion)
    entity = ReqEntity(
        id=req_id,
        description=f"Test criterion {criterion}",
        status=ReqStatus.UNCHECKED,
        parent_obpi=parent_obpi,
    )
    return DiscoveredReq(entity=entity, source_path="test_brief.md")


class TestScanTestTree(unittest.TestCase):
    """AST-based scanner discovers @covers annotations across test files."""

    @covers("REQ-0.21.0-02-01")
    def test_discovers_annotations_across_files(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "test_a.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("REQ-0.15.0-03-01")
                    def test_alpha():
                        pass

                    @covers("REQ-0.15.0-03-02")
                    def test_beta():
                        pass
                """),
                encoding="utf-8",
            )
            (root / "test_b.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("REQ-0.20.0-01-01")
                    def test_gamma():
                        pass
                """),
                encoding="utf-8",
            )

            records = scan_test_tree(root)

        self.assertEqual(len(records), 3)
        target_ids = {r.target.identifier for r in records}
        self.assertEqual(target_ids, {"REQ-0.15.0-03-01", "REQ-0.15.0-03-02", "REQ-0.20.0-01-01"})

    def test_captures_line_numbers(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "test_lines.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("REQ-0.15.0-03-01")
                    def test_first():
                        pass

                    @covers("REQ-0.15.0-03-02")
                    def test_second():
                        pass
                """),
                encoding="utf-8",
            )

            records = scan_test_tree(root)

        self.assertEqual(len(records), 2)
        self.assertEqual(records[0].evidence_line, 3)
        self.assertEqual(records[0].source.line, 4)
        self.assertEqual(records[1].evidence_line, 7)
        self.assertEqual(records[1].source.line, 8)

    def test_discovers_class_method_annotations(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "test_cls.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers
                    import unittest

                    class TestFoo(unittest.TestCase):
                        @covers("REQ-0.15.0-03-01")
                        def test_method(self):
                            pass
                """),
                encoding="utf-8",
            )

            records = scan_test_tree(root)

        self.assertEqual(len(records), 1)
        self.assertIn("TestFoo.test_method", records[0].source.identifier)

    def test_multiple_covers_on_same_function(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "test_multi.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("REQ-0.15.0-03-01")
                    @covers("REQ-0.15.0-03-02")
                    def test_both():
                        pass
                """),
                encoding="utf-8",
            )

            records = scan_test_tree(root)

        self.assertEqual(len(records), 2)
        target_ids = {r.target.identifier for r in records}
        self.assertEqual(target_ids, {"REQ-0.15.0-03-01", "REQ-0.15.0-03-02"})

    def test_deterministic_output(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "test_z.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("REQ-0.20.0-01-01")
                    def test_z():
                        pass
                """),
                encoding="utf-8",
            )
            (root / "test_a.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("REQ-0.15.0-03-01")
                    def test_a():
                        pass
                """),
                encoding="utf-8",
            )

            result1 = scan_test_tree(root)
            result2 = scan_test_tree(root)

        self.assertEqual(len(result1), len(result2))
        for r1, r2 in zip(result1, result2, strict=True):
            self.assertEqual(r1.target.identifier, r2.target.identifier)
            self.assertEqual(r1.source.identifier, r2.source.identifier)

    def test_skips_malformed_req(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "test_bad.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("INVALID-FORMAT")
                    def test_bad():
                        pass

                    @covers("REQ-0.15.0-03-01")
                    def test_good():
                        pass
                """),
                encoding="utf-8",
            )

            with self.assertLogs("gzkit.traceability", level="WARNING") as cm:
                records = scan_test_tree(root)

        self.assertEqual(len(records), 1)
        self.assertEqual(records[0].target.identifier, "REQ-0.15.0-03-01")
        self.assertTrue(any("Malformed REQ" in msg for msg in cm.output))

    def test_empty_directory(self):
        with tempfile.TemporaryDirectory() as tmp:
            records = scan_test_tree(Path(tmp))

        self.assertEqual(records, [])

    def test_no_execution_of_test_files(self):
        """Scanner must not import/execute files — syntax errors in body are OK."""
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "test_exec.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("REQ-0.15.0-03-01")
                    def test_would_crash():
                        raise RuntimeError("This should never execute during scanning")
                """),
                encoding="utf-8",
            )

            records = scan_test_tree(root)

        self.assertEqual(len(records), 1)
        self.assertEqual(records[0].target.identifier, "REQ-0.15.0-03-01")

    def test_scans_subdirectories(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            sub = root / "sub"
            sub.mkdir()
            (sub / "test_nested.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("REQ-0.15.0-03-01")
                    def test_nested():
                        pass
                """),
                encoding="utf-8",
            )

            records = scan_test_tree(root)

        self.assertEqual(len(records), 1)

    def test_picks_up_docstring_form_covers(self):
        """Docstring/comment-form @covers must be discovered alongside decorator form (#120)."""
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "test_doc.py").write_text(
                textwrap.dedent('''\
                    """Module-level docstring with @covers REQ-0.15.0-03-01 in prose."""

                    def test_one():
                        """Test docstring referencing @covers REQ-0.15.0-03-02 here."""
                        pass

                    # @covers REQ-0.20.0-01-01 inline comment
                    def test_two():
                        pass
                '''),
                encoding="utf-8",
            )

            records = scan_test_tree(root)

        target_ids = {r.target.identifier for r in records}
        self.assertEqual(
            target_ids,
            {"REQ-0.15.0-03-01", "REQ-0.15.0-03-02", "REQ-0.20.0-01-01"},
        )

    def test_decorator_and_docstring_dedup_by_line(self):
        """Decorator @covers and a docstring @covers on the same line do not double-count."""
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "test_mixed.py").write_text(
                textwrap.dedent("""\
                    from gzkit.traceability import covers

                    @covers("REQ-0.15.0-03-01")
                    def test_one():
                        '''@covers REQ-0.15.0-03-02 in body docstring.'''
                        pass
                """),
                encoding="utf-8",
            )

            records = scan_test_tree(root)

        target_ids = sorted(r.target.identifier for r in records)
        self.assertEqual(target_ids, ["REQ-0.15.0-03-01", "REQ-0.15.0-03-02"])


class TestFindCoversInSource(unittest.TestCase):
    """REQ-#120: shared regex scanner used by drift, audit-check, and gz covers."""

    def test_decorator_double_quotes(self):
        hits = find_covers_in_source('@covers("REQ-0.15.0-03-01")\n')
        self.assertEqual(hits, [("REQ-0.15.0-03-01", 1)])

    def test_decorator_single_quotes(self):
        hits = find_covers_in_source("@covers('REQ-0.15.0-03-01')\n")
        self.assertEqual(hits, [("REQ-0.15.0-03-01", 1)])

    def test_decorator_with_space_before_paren(self):
        hits = find_covers_in_source('@covers ("REQ-0.15.0-03-01")\n')
        self.assertEqual(hits, [("REQ-0.15.0-03-01", 1)])

    def test_docstring_form(self):
        hits = find_covers_in_source('"""@covers REQ-0.15.0-03-01"""\n')
        self.assertEqual(hits, [("REQ-0.15.0-03-01", 1)])

    def test_comment_form(self):
        hits = find_covers_in_source("# @covers REQ-0.15.0-03-01\n")
        self.assertEqual(hits, [("REQ-0.15.0-03-01", 1)])

    def test_multiple_hits_track_line_numbers(self):
        source = 'line one\n@covers("REQ-0.15.0-03-01")\nline three\n@covers REQ-0.20.0-01-01\n'
        hits = find_covers_in_source(source)
        self.assertEqual(
            hits,
            [("REQ-0.15.0-03-01", 2), ("REQ-0.20.0-01-01", 4)],
        )

    def test_does_not_match_unrelated_decorator(self):
        hits = find_covers_in_source('@coverage("REQ-0.15.0-03-01")\n')
        self.assertEqual(hits, [])

    def test_does_not_match_malformed_req(self):
        hits = find_covers_in_source("@covers REQ-bogus\n")
        self.assertEqual(hits, [])

    def test_empty_source(self):
        self.assertEqual(find_covers_in_source(""), [])

    def test_string_literal_fixture_content_is_not_returned_as_covers_hit(self):
        """A ``@covers(...)`` reference inside a non-docstring string literal
        (e.g. ``write_text(textwrap.dedent("@covers(\\"REQ-...\\")"))`` in a
        test fixture) must NOT register as a real coverage anchor — AST never
        parses it as a decorator and the legacy regex post-pass over raw
        source produced false positives that flagged ADR-0.0.16's audit even
        though the fixture is not a real coverage site (GHI #390 Case A).
        """
        source = (
            "import textwrap\n"
            "def make_fixture():\n"
            '    payload = textwrap.dedent("""\n'
            '        @covers("REQ-0.0.16-01-05")\n'
            "        def t():\n"
            "            pass\n"
            '    """)\n'
            "    return payload\n"
        )
        self.assertEqual(find_covers_in_source(source), [])

    def test_module_docstring_covers_reference_still_returned(self):
        """Module-level docstring ``@covers REQ-...`` references remain
        in scope — docstrings are the legitimate AST-invisible site the
        regex post-pass exists to cover.
        """
        source = '"""Module purpose.\n\n@covers REQ-0.15.0-03-01\n"""\n'
        hits = find_covers_in_source(source)
        self.assertIn(("REQ-0.15.0-03-01", 3), hits)

    def test_function_docstring_covers_reference_still_returned(self):
        """Function-level docstring ``@covers REQ-...`` references remain
        in scope — same docstring carve-out as module level.
        """
        source = (
            "def helper():\n"
            '    """Helper purpose.\n'
            "\n"
            "    @covers REQ-0.15.0-03-02\n"
            '    """\n'
            "    return None\n"
        )
        hits = find_covers_in_source(source)
        self.assertIn(("REQ-0.15.0-03-02", 4), hits)


class TestComputeCoverage(unittest.TestCase):
    """Coverage rollup computation at ADR, OBPI, and REQ levels."""

    def _make_known_reqs(self) -> list[DiscoveredReq]:
        """Create a set of 8 known REQs across 2 ADRs and 3 OBPIs."""
        return [
            _make_req("0.15.0", "03", "01", "OBPI-0.15.0-03"),
            _make_req("0.15.0", "03", "02", "OBPI-0.15.0-03"),
            _make_req("0.15.0", "03", "03", "OBPI-0.15.0-03"),
            _make_req("0.15.0", "04", "01", "OBPI-0.15.0-04"),
            _make_req("0.15.0", "04", "02", "OBPI-0.15.0-04"),
            _make_req("0.20.0", "01", "01", "OBPI-0.20.0-01"),
            _make_req("0.20.0", "01", "02", "OBPI-0.20.0-01"),
            _make_req("0.20.0", "01", "03", "OBPI-0.20.0-01"),
        ]

    def _make_linkage_records(self) -> list:
        """Create 5 linkage records covering some of the 8 REQs."""
        from gzkit.triangle import LinkageRecord, VertexRef

        records = []
        covered_reqs = [
            "REQ-0.15.0-03-01",
            "REQ-0.15.0-03-02",
            "REQ-0.15.0-04-01",
            "REQ-0.20.0-01-01",
            "REQ-0.20.0-01-02",
        ]
        for req_str in covered_reqs:
            records.append(
                LinkageRecord(
                    source=VertexRef(
                        vertex_type=VertexType.TEST,
                        identifier=f"test_for_{req_str}",
                    ),
                    target=VertexRef(
                        vertex_type=VertexType.SPEC,
                        identifier=req_str,
                    ),
                    edge_type=EdgeType.COVERS,
                )
            )
        return records

    @covers("REQ-0.21.0-02-02")
    def test_overall_coverage_percentage(self):
        known = self._make_known_reqs()
        linkages = self._make_linkage_records()
        report = compute_coverage(known, linkages)

        self.assertEqual(report.summary.total_reqs, 8)
        self.assertEqual(report.summary.covered_reqs, 5)
        self.assertEqual(report.summary.uncovered_reqs, 3)
        self.assertEqual(report.summary.coverage_percent, 62.5)

    @covers("REQ-0.21.0-02-03")
    def test_per_adr_rollup(self):
        known = self._make_known_reqs()
        linkages = self._make_linkage_records()
        report = compute_coverage(known, linkages)

        adr_map = {r.identifier: r for r in report.by_adr}
        self.assertIn("ADR-0.15.0", adr_map)
        self.assertIn("ADR-0.20.0", adr_map)

        adr_15 = adr_map["ADR-0.15.0"]
        self.assertEqual(adr_15.total_reqs, 5)
        self.assertEqual(adr_15.covered_reqs, 3)
        self.assertEqual(adr_15.coverage_percent, 60.0)

        adr_20 = adr_map["ADR-0.20.0"]
        self.assertEqual(adr_20.total_reqs, 3)
        self.assertEqual(adr_20.covered_reqs, 2)
        self.assertAlmostEqual(adr_20.coverage_percent, 66.7, places=1)

    def test_per_obpi_rollup(self):
        known = self._make_known_reqs()
        linkages = self._make_linkage_records()
        report = compute_coverage(known, linkages)

        obpi_map = {r.identifier: r for r in report.by_obpi}
        self.assertIn("OBPI-0.15.0-03", obpi_map)
        self.assertIn("OBPI-0.15.0-04", obpi_map)
        self.assertIn("OBPI-0.20.0-01", obpi_map)

        obpi_03 = obpi_map["OBPI-0.15.0-03"]
        self.assertEqual(obpi_03.total_reqs, 3)
        self.assertEqual(obpi_03.covered_reqs, 2)

        obpi_04 = obpi_map["OBPI-0.15.0-04"]
        self.assertEqual(obpi_04.total_reqs, 2)
        self.assertEqual(obpi_04.covered_reqs, 1)

    def test_per_req_entries(self):
        known = self._make_known_reqs()
        linkages = self._make_linkage_records()
        report = compute_coverage(known, linkages)

        self.assertEqual(len(report.entries), 8)
        entry_map = {e.req_id: e for e in report.entries}

        self.assertTrue(entry_map["REQ-0.15.0-03-01"].covered)
        self.assertFalse(entry_map["REQ-0.15.0-03-03"].covered)
        self.assertTrue(entry_map["REQ-0.20.0-01-01"].covered)
        self.assertFalse(entry_map["REQ-0.20.0-01-03"].covered)

    def test_covering_tests_listed(self):
        known = self._make_known_reqs()
        linkages = self._make_linkage_records()
        report = compute_coverage(known, linkages)

        entry_map = {e.req_id: e for e in report.entries}
        self.assertEqual(len(entry_map["REQ-0.15.0-03-01"].covering_tests), 1)
        self.assertEqual(len(entry_map["REQ-0.15.0-03-03"].covering_tests), 0)

    def test_empty_inputs(self):
        report = compute_coverage([], [])

        self.assertEqual(report.summary.total_reqs, 0)
        self.assertEqual(report.summary.coverage_percent, 0.0)
        self.assertEqual(report.entries, [])

    def test_no_linkages(self):
        known = self._make_known_reqs()
        report = compute_coverage(known, [])

        self.assertEqual(report.summary.total_reqs, 8)
        self.assertEqual(report.summary.covered_reqs, 0)
        self.assertEqual(report.summary.coverage_percent, 0.0)

    def test_deterministic_computation(self):
        known = self._make_known_reqs()
        linkages = self._make_linkage_records()
        r1 = compute_coverage(known, linkages)
        r2 = compute_coverage(known, linkages)

        self.assertEqual(r1.summary, r2.summary)
        self.assertEqual(len(r1.entries), len(r2.entries))
        for e1, e2 in zip(r1.entries, r2.entries, strict=True):
            self.assertEqual(e1.req_id, e2.req_id)
            self.assertEqual(e1.covered, e2.covered)

    def test_report_is_pydantic_model(self):
        known = self._make_known_reqs()
        linkages = self._make_linkage_records()
        report = compute_coverage(known, linkages)

        self.assertIsInstance(report, CoverageReport)
        json_str = report.model_dump_json()
        self.assertIn("by_adr", json_str)
        self.assertIn("by_obpi", json_str)

    @covers("REQ-0.21.0-02-04")
    def test_doc_kind_excluded_by_default(self):
        from gzkit.triangle import ReqEntity, ReqId, ReqKind, ReqStatus

        doc_req = DiscoveredReq(
            entity=ReqEntity(
                id=ReqId(semver="0.9.0", obpi_item="01", criterion_index="01"),
                description="doc-only criterion",
                status=ReqStatus.CHECKED,
                parent_obpi="OBPI-0.9.0-01",
                kind=ReqKind.DOC,
            ),
            source_path="test_brief.md",
        )
        report = compute_coverage([doc_req], [])
        self.assertEqual(report.summary.total_reqs, 0)
        self.assertEqual([a.identifier for a in report.by_adr], [])

    @covers("REQ-0.21.0-02-05")
    def test_doc_kind_included_when_flag_set(self):
        from gzkit.triangle import ReqEntity, ReqId, ReqKind, ReqStatus

        doc_req = DiscoveredReq(
            entity=ReqEntity(
                id=ReqId(semver="0.9.0", obpi_item="01", criterion_index="01"),
                description="doc-only criterion",
                status=ReqStatus.CHECKED,
                parent_obpi="OBPI-0.9.0-01",
                kind=ReqKind.DOC,
            ),
            source_path="test_brief.md",
        )
        report = compute_coverage([doc_req], [], include_doc=True)
        self.assertEqual(report.summary.total_reqs, 1)
        self.assertEqual([a.identifier for a in report.by_adr], ["ADR-0.9.0"])


# ---------------------------------------------------------------------------
# OBPI-03: gz covers CLI tests
# ---------------------------------------------------------------------------


class TestCoversCLIHelp(unittest.TestCase):
    """gz covers --help works and shows required elements."""

    @covers("REQ-0.21.0-03-04")
    def test_help_exits_zero(self):
        import io
        from contextlib import redirect_stderr, redirect_stdout

        from gzkit.cli import main

        out = io.StringIO()
        with redirect_stdout(out), redirect_stderr(out):
            try:
                code = main(["covers", "--help"])
            except SystemExit as exc:
                code = exc.code
        self.assertEqual(code, 0)
        text = out.getvalue()
        self.assertIn("--json", text)
        self.assertIn("--plain", text)

    def test_help_shows_description(self):
        import contextlib
        import io
        from contextlib import redirect_stderr, redirect_stdout

        from gzkit.cli import main

        out = io.StringIO()
        with redirect_stdout(out), redirect_stderr(out), contextlib.suppress(SystemExit):
            main(["covers", "--help"])
        text = out.getvalue()
        self.assertIn("coverage", text.lower())

    def test_help_shows_examples(self):
        import contextlib
        import io
        from contextlib import redirect_stderr, redirect_stdout

        from gzkit.cli import main

        out = io.StringIO()
        with redirect_stdout(out), redirect_stderr(out), contextlib.suppress(SystemExit):
            main(["covers", "--help"])
        text = out.getvalue()
        self.assertIn("gz covers", text)


class TestCoversCLIOutput(unittest.TestCase):
    """gz covers produces correct output in all three modes."""

    def setUp(self):
        self._tmpdir = tempfile.TemporaryDirectory()
        self.tmp = Path(self._tmpdir.name)
        self.adr_dir = self.tmp / "design" / "adr" / "pre-release" / "ADR-0.15.0"
        self.adr_dir.mkdir(parents=True)
        obpis = self.adr_dir / "obpis"
        obpis.mkdir()
        (obpis / "OBPI-0.15.0-03-demo.md").write_text(
            "---\n"
            "id: OBPI-0.15.0-03-demo\n"
            "parent: ADR-0.15.0\n"
            "item: 3\n"
            "lane: Lite\n"
            "status: Accepted\n"
            "---\n\n"
            "# OBPI-0.15.0-03: Demo\n\n"
            "## Acceptance Criteria\n\n"
            "- [ ] REQ-0.15.0-03-01: First criterion.\n"
            "- [ ] REQ-0.15.0-03-02: Second criterion.\n",
            encoding="utf-8",
        )

        self.test_dir = self.tmp / "tests"
        self.test_dir.mkdir()
        (self.test_dir / "test_demo.py").write_text(
            textwrap.dedent("""\
                from gzkit.traceability import covers

                @covers("REQ-0.15.0-03-01")
                def test_one():
                    pass
            """),
            encoding="utf-8",
        )

    def tearDown(self):
        self._tmpdir.cleanup()

    def _run_covers(self, extra_args: list[str] | None = None) -> tuple[int, str]:
        import io
        from contextlib import redirect_stderr, redirect_stdout

        from gzkit.cli import main

        args = [
            "covers",
            "--adr-dir",
            str(self.adr_dir.parent),
            "--test-dir",
            str(self.test_dir),
        ]
        if extra_args:
            args.extend(extra_args)
        out = io.StringIO()
        with redirect_stdout(out), redirect_stderr(out):
            try:
                code = main(args)
            except SystemExit as exc:
                code = exc.code
        return 0 if code is None else int(code), out.getvalue()

    def test_exit_code_always_zero(self):
        code, _ = self._run_covers()
        self.assertEqual(code, 0)

    @covers("REQ-0.21.0-03-03")
    def test_json_output_valid(self):
        import json

        code, output = self._run_covers(["--json"])
        self.assertEqual(code, 0)
        data = json.loads(output)
        self.assertIn("by_adr", data)
        self.assertIn("by_obpi", data)
        self.assertIn("entries", data)
        self.assertIn("summary", data)

    def test_json_shows_coverage_data(self):
        import json

        _, output = self._run_covers(["--json"])
        data = json.loads(output)
        self.assertEqual(data["summary"]["total_reqs"], 2)
        self.assertEqual(data["summary"]["covered_reqs"], 1)

    def test_plain_one_record_per_line(self):
        _, output = self._run_covers(["--plain"])
        lines = output.strip().splitlines()
        self.assertEqual(len(lines), 2)
        self.assertIn("covered", lines[0])
        self.assertIn("uncovered", lines[1])

    @covers("REQ-0.21.0-03-01")
    def test_human_shows_summary(self):
        code, output = self._run_covers()
        self.assertEqual(code, 0)
        self.assertIn("Summary", output)

    @covers("REQ-0.21.0-03-02")
    def test_filter_by_adr(self):
        import json

        _, output = self._run_covers(["ADR-0.15.0", "--json"])
        data = json.loads(output)
        self.assertEqual(data["summary"]["total_reqs"], 2)

    def test_filter_by_obpi(self):
        import json

        _, output = self._run_covers(["OBPI-0.15.0-03", "--json"])
        data = json.loads(output)
        self.assertEqual(data["summary"]["total_reqs"], 2)

    def test_filter_nonexistent_adr_returns_empty(self):
        import json

        _, output = self._run_covers(["ADR-9.9.9", "--json"])
        data = json.loads(output)
        self.assertEqual(data["summary"]["total_reqs"], 0)

    @covers("REQ-0.21.0-02-05")
    def test_include_doc_flag_surfaces_doc_only_obpi(self):
        import json

        # Add a doc-only OBPI alongside the existing one.
        doc_obpi = self.adr_dir / "obpis" / "OBPI-0.15.0-04-doc-only.md"
        doc_obpi.write_text(
            "---\n"
            "id: OBPI-0.15.0-04-doc-only\n"
            "parent: ADR-0.15.0\n"
            "item: 4\n"
            "lane: Lite\n"
            "status: Accepted\n"
            "---\n\n"
            "# OBPI-0.15.0-04: Doc-only\n\n"
            "## Acceptance Criteria\n\n"
            "- [x] REQ-0.15.0-04-01: [doc] Documentation-only criterion.\n",
            encoding="utf-8",
        )

        _, default_output = self._run_covers(["--json"])
        default_data = json.loads(default_output)
        self.assertEqual(default_data["summary"]["total_reqs"], 2)

        _, included_output = self._run_covers(["--include-doc", "--json"])
        included_data = json.loads(included_output)
        self.assertEqual(included_data["summary"]["total_reqs"], 3)
        obpi_ids = {o["identifier"] for o in included_data["by_obpi"]}
        self.assertIn("OBPI-0.15.0-04", obpi_ids)


# ---------------------------------------------------------------------------
# OBPI-04: ADR audit integration tests
# ---------------------------------------------------------------------------


class TestExtractAdrSemver(unittest.TestCase):
    """_extract_adr_semver extracts X.Y.Z from ADR identifiers."""

    def test_simple_adr_id(self):
        from gzkit.commands.adr_audit import _extract_adr_semver

        self.assertEqual(_extract_adr_semver("ADR-0.20.0"), "0.20.0")

    def test_adr_with_suffix(self):
        from gzkit.commands.adr_audit import _extract_adr_semver

        self.assertEqual(_extract_adr_semver("ADR-0.21.0-tests-for-spec"), "0.21.0")

    def test_invalid_format_returns_none(self):
        from gzkit.commands.adr_audit import _extract_adr_semver

        self.assertIsNone(_extract_adr_semver("INVALID"))

    def test_pool_adr_returns_none(self):
        from gzkit.commands.adr_audit import _extract_adr_semver

        self.assertIsNone(_extract_adr_semver("ADR-pool.my-feature"))


class TestComputeAdrCoverage(unittest.TestCase):
    """_compute_adr_coverage returns scoped coverage data for an ADR."""

    def setUp(self):
        self._tmpdir = tempfile.TemporaryDirectory()
        self.root = Path(self._tmpdir.name)
        self.adr_dir = self.root / "docs" / "design" / "adr"

        # Create ADR/OBPI brief structure with 2 REQs
        adr_pkg = self.adr_dir / "ADR-0.15.0"
        obpis = adr_pkg / "obpis"
        obpis.mkdir(parents=True)
        (obpis / "OBPI-0.15.0-03-demo.md").write_text(
            "---\n"
            "id: OBPI-0.15.0-03-demo\n"
            "parent: ADR-0.15.0\n"
            "item: 3\n"
            "lane: Lite\n"
            "status: Accepted\n"
            "---\n\n"
            "# OBPI-0.15.0-03: Demo\n\n"
            "## Acceptance Criteria\n\n"
            "- [ ] REQ-0.15.0-03-01: First criterion.\n"
            "- [ ] REQ-0.15.0-03-02: Second criterion.\n",
            encoding="utf-8",
        )

        # Create test file covering only REQ-01
        tests_dir = self.root / "tests"
        tests_dir.mkdir()
        (tests_dir / "test_sample.py").write_text(
            textwrap.dedent("""\
                from gzkit.traceability import covers

                @covers("REQ-0.15.0-03-01")
                def test_one():
                    pass
            """),
            encoding="utf-8",
        )

    def tearDown(self):
        self._tmpdir.cleanup()

    @covers("REQ-0.21.0-04-01")
    def test_returns_coverage_for_adr(self):
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-0.15.0", self.adr_dir)
        self.assertEqual(result["total_reqs"], 2)
        self.assertEqual(result["covered_reqs"], 1)
        self.assertEqual(result["uncovered_reqs"], 1)
        self.assertEqual(result["coverage_percent"], 50.0)

    @covers("REQ-0.21.0-04-03")
    def test_includes_per_obpi_data(self):
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-0.15.0", self.adr_dir)
        self.assertEqual(len(result["by_obpi"]), 1)
        obpi = result["by_obpi"][0]
        self.assertEqual(obpi["obpi"], "OBPI-0.15.0-03")
        self.assertEqual(obpi["total_reqs"], 2)
        self.assertEqual(obpi["covered_reqs"], 1)

    @covers("REQ-0.21.0-04-02")
    def test_uncovered_reqs_listed(self):
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-0.15.0", self.adr_dir)
        self.assertEqual(len(result["uncovered"]), 1)
        self.assertEqual(result["uncovered"][0]["req_id"], "REQ-0.15.0-03-02")
        self.assertEqual(result["uncovered"][0]["severity"], "advisory")

    def test_nonexistent_adr_returns_empty(self):
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-9.9.9", self.adr_dir)
        self.assertEqual(result["total_reqs"], 0)
        self.assertEqual(result["by_obpi"], [])

    def test_invalid_adr_returns_empty(self):
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "INVALID", self.adr_dir)
        self.assertEqual(result["total_reqs"], 0)

    def test_coverage_does_not_block_audit(self):
        """Coverage is advisory — uncovered REQs never make passed=False."""
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-0.15.0", self.adr_dir)
        # Coverage data has uncovered REQs but no "passed" key —
        # pass/fail is determined by findings, not coverage
        self.assertNotIn("passed", result)
        self.assertEqual(result["uncovered_reqs"], 1)

    def test_all_covered_returns_empty_uncovered(self):
        """When all REQs are covered, uncovered list is empty."""
        (self.root / "tests" / "test_sample2.py").write_text(
            textwrap.dedent("""\
                from gzkit.traceability import covers

                @covers("REQ-0.15.0-03-02")
                def test_two():
                    pass
            """),
            encoding="utf-8",
        )
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-0.15.0", self.adr_dir)
        self.assertEqual(result["covered_reqs"], 2)
        self.assertEqual(result["uncovered"], [])

    def test_bdd_feature_tag_counts_as_coverage(self):
        """@covers GHI #165 — a BDD @REQ scenario tag covers a REQ without @covers decorator."""
        features_dir = self.root / "features"
        features_dir.mkdir()
        (features_dir / "demo.feature").write_text(
            textwrap.dedent("""\
                Feature: Demo

                  @REQ-0.15.0-03-02
                  Scenario: behavioral proof
                    Given something
                    Then it works
            """),
            encoding="utf-8",
        )
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-0.15.0", self.adr_dir)
        self.assertEqual(result["covered_reqs"], 2)
        self.assertEqual(result["uncovered"], [])


class TestComputeAdrCoverageDocProofChannels(unittest.TestCase):
    """@covers GHI #165 — doc-kind REQs are covered by non-@covers proof channels."""

    def setUp(self):
        self._tmpdir = tempfile.TemporaryDirectory()
        self.root = Path(self._tmpdir.name)
        self.adr_dir = self.root / "docs" / "design" / "adr"
        adr_pkg = self.adr_dir / "ADR-0.15.0"
        self.obpis = adr_pkg / "obpis"
        self.obpis.mkdir(parents=True)

    def tearDown(self):
        self._tmpdir.cleanup()

    def _write_obpi(self, path: Path, body: str, *, with_decision: bool = False) -> None:
        decision = (
            "\n## Decision\n\n**Absorb** the pattern because it is proven.\n"
            if with_decision
            else ""
        )
        path.write_text(
            "---\n"
            "id: OBPI-0.15.0-03-demo\n"
            "parent: ADR-0.15.0\n"
            "item: 3\n"
            "lane: Lite\n"
            "status: Accepted\n"
            "---\n\n"
            "# OBPI-0.15.0-03: Demo\n\n"
            f"{decision}"
            "## Acceptance Criteria\n\n"
            f"{body}",
            encoding="utf-8",
        )

    def test_decision_doc_covers_doc_reqs_without_at_covers(self):
        """A [doc] REQ in an OBPI with Decision text is covered via decision-doc proof."""
        self._write_obpi(
            self.obpis / "OBPI-0.15.0-03-demo.md",
            "- [ ] REQ-0.15.0-03-01: [doc] Decision recorded.\n",
            with_decision=True,
        )
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-0.15.0", self.adr_dir)
        self.assertEqual(result["total_reqs"], 1)
        self.assertEqual(result["covered_reqs"], 1)
        self.assertEqual(result["uncovered"], [])

    def test_doc_req_without_any_proof_is_uncovered(self):
        """A [doc] REQ with no decision/product proof is surfaced as uncovered (no don't-look)."""
        self._write_obpi(
            self.obpis / "OBPI-0.15.0-03-demo.md",
            "- [ ] REQ-0.15.0-03-01: [doc] Decision recorded.\n",
            with_decision=False,
        )
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-0.15.0", self.adr_dir)
        self.assertEqual(result["total_reqs"], 1)
        self.assertEqual(result["covered_reqs"], 0)
        self.assertEqual(result["uncovered"][0]["req_id"], "REQ-0.15.0-03-01")

    def test_product_proof_command_doc_covers_doc_reqs(self):
        """A [doc] REQ whose OBPI lists a substantive command doc is covered via product proof."""
        brief = self.obpis / "OBPI-0.15.0-03-demo.md"
        brief.write_text(
            "---\n"
            "id: OBPI-0.15.0-03-demo\n"
            "parent: ADR-0.15.0\n"
            "item: 3\n"
            "lane: Lite\n"
            "status: Accepted\n"
            "---\n\n"
            "# OBPI-0.15.0-03: Demo\n\n"
            "## ALLOWED PATHS\n\n"
            "- `docs/user/manpages/demo.md`\n\n"
            "## Acceptance Criteria\n\n"
            "- [ ] REQ-0.15.0-03-01: [doc] Command documented.\n",
            encoding="utf-8",
        )
        cmd_doc = self.root / "docs" / "user" / "manpages" / "demo.md"
        cmd_doc.parent.mkdir(parents=True, exist_ok=True)
        cmd_doc.write_text("# gz demo\n\n" + ("Substantive content. " * 20), encoding="utf-8")
        from gzkit.commands.adr_audit import _compute_adr_coverage

        result = _compute_adr_coverage(self.root, "ADR-0.15.0", self.adr_dir)
        self.assertEqual(result["total_reqs"], 1)
        self.assertEqual(result["covered_reqs"], 1)


class TestScanFeatureTree(unittest.TestCase):
    """Tests for scan_feature_tree — GHI #185 behave @REQ-tag scanner."""

    def _write_feature(self, dirpath: Path, name: str, body: str) -> Path:
        dirpath.mkdir(parents=True, exist_ok=True)
        path = dirpath / f"{name}.feature"
        path.write_text(textwrap.dedent(body).lstrip("\n"), encoding="utf-8")
        return path

    def test_scenario_level_req_tags_are_discovered(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            features = Path(tmp)
            self._write_feature(
                features,
                "example",
                """
                Feature: Example
                  Narrative text

                  @REQ-0.0.16-01-05
                  Scenario: drift is reported
                    Given something
                    When I run it
                    Then it passes
                """,
            )
            records = scan_feature_tree(features)
            self.assertEqual(len(records), 1)
            rec = records[0]
            self.assertEqual(rec.edge_type, EdgeType.COVERS)
            self.assertEqual(rec.target.identifier, "REQ-0.0.16-01-05")
            self.assertEqual(rec.source.vertex_type, VertexType.TEST)
            self.assertEqual(rec.source.identifier, "Example::drift is reported")

    def test_multiple_req_tags_on_one_scenario(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            features = Path(tmp)
            self._write_feature(
                features,
                "multi",
                """
                Feature: Multi

                  @REQ-0.0.16-01-05
                  @REQ-0.0.16-01-06
                  Scenario: covers two REQs
                    Given X
                """,
            )
            records = scan_feature_tree(features)
            req_ids = sorted(r.target.identifier for r in records)
            self.assertEqual(req_ids, ["REQ-0.0.16-01-05", "REQ-0.0.16-01-06"])
            scenarios = {r.source.identifier for r in records}
            self.assertEqual(scenarios, {"Multi::covers two REQs"})

    def test_feature_level_req_tag_attaches_to_feature(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            features = Path(tmp)
            self._write_feature(
                features,
                "feat",
                """
                @REQ-0.0.16-01-07
                Feature: Top-level coverage
                  Scenario: unscoped
                    Given anything
                """,
            )
            records = scan_feature_tree(features)
            self.assertEqual(len(records), 1)
            self.assertEqual(records[0].target.identifier, "REQ-0.0.16-01-07")
            self.assertEqual(records[0].source.identifier, "Top-level coverage")

    def test_untagged_scenarios_produce_no_records(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            features = Path(tmp)
            self._write_feature(
                features,
                "plain",
                """
                Feature: Plain
                  Scenario: nothing tagged
                    Given nothing
                """,
            )
            self.assertEqual(scan_feature_tree(features), [])

    def test_missing_directory_returns_empty(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            self.assertEqual(scan_feature_tree(Path(tmp) / "nonexistent"), [])

    def test_compute_coverage_merges_unit_and_behave(self) -> None:
        """gz covers-style integration: unit @covers + behave @REQ tags unify."""
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            tests_dir = root / "tests"
            features_dir = root / "features"
            tests_dir.mkdir()
            (tests_dir / "test_unit.py").write_text(
                textwrap.dedent("""
                    from gzkit.traceability import covers

                    @covers("REQ-0.0.16-01-05")
                    def test_unit_case():
                        pass
                """).lstrip("\n"),
                encoding="utf-8",
            )
            self._write_feature(
                features_dir,
                "behave",
                """
                Feature: Behave cover

                  @REQ-0.0.16-01-05
                  Scenario: behave path
                    Given Y
                """,
            )
            known = [
                DiscoveredReq(
                    entity=ReqEntity(
                        id=ReqId(semver="0.0.16", obpi_item="01", criterion_index="05"),
                        description="x",
                        status=ReqStatus.UNCHECKED,
                        parent_obpi="OBPI-0.0.16-01",
                    ),
                    source_path="brief.md",
                )
            ]
            linkage = scan_test_tree(tests_dir) + scan_feature_tree(features_dir)
            report = compute_coverage(known, linkage)
            self.assertEqual(report.summary.covered_reqs, 1)
            # covering_tests should carry both the unit test identifier and
            # the feature-scenario identifier.
            entry = report.entries[0]
            self.assertIn("test_unit_case", entry.covering_tests)
            self.assertIn("Behave cover::behave path", entry.covering_tests)


if __name__ == "__main__":
    unittest.main()
