import json
from unittest.mock import MagicMock, patch

import pytest

from great_docs._builtin.directives import DIRECTIVES
from great_docs._lint import (
    LintIssue,
    LintResult,
    _check_cross_references,
    _check_directive_consistency,
    _check_docstring_style,
    _check_missing_docstrings,
    _detect_style_of_docstring,
    run_lint,
)


class TestLintIssue:
    def test_to_dict(self):
        issue = LintIssue(
            check="missing-docstring",
            severity="error",
            symbol="MyClass",
            message="No docstring.",
        )
        d = issue.to_dict()

        assert d == {
            "check": "missing-docstring",
            "severity": "error",
            "symbol": "MyClass",
            "message": "No docstring.",
        }


class TestLintResult:
    def test_empty_result(self):
        r = LintResult()

        assert r.status == "pass"
        assert r.errors == []
        assert r.warnings == []
        assert r.infos == []

    def test_status_fail_on_errors(self):
        r = LintResult(issues=[LintIssue("x", "error", "sym", "msg")])

        assert r.status == "fail"

    def test_status_warn_on_warnings(self):
        r = LintResult(issues=[LintIssue("x", "warning", "sym", "msg")])

        assert r.status == "warn"

    def test_status_pass_on_info_only(self):
        r = LintResult(issues=[LintIssue("x", "info", "sym", "msg")])

        assert r.status == "pass"

    def test_to_dict(self):
        r = LintResult(
            package_name="mypackage",
            exports_count=5,
            issues=[
                LintIssue("missing-docstring", "error", "func_a", "No docstring."),
                LintIssue("style-mismatch", "warning", "func_b", "Wrong style."),
            ],
        )
        d = r.to_dict()

        assert d["status"] == "fail"
        assert d["package"] == "mypackage"
        assert d["exports_checked"] == 5
        assert d["summary"]["errors"] == 1
        assert d["summary"]["warnings"] == 1
        assert d["summary"]["info"] == 0
        assert len(d["issues"]) == 2


class TestDetectStyleOfDocstring:
    def test_numpy_style(self):
        doc = """\
Short description.

Parameters
----------
x : int
    The value.
"""
        assert _detect_style_of_docstring(doc) == "numpy"

    def test_google_style(self):
        doc = """\
Short description.

Args:
    x: The value.
"""
        assert _detect_style_of_docstring(doc) == "google"

    def test_sphinx_style(self):
        doc = """\
Short description.

:param x: The value.
:returns: Something.
"""
        assert _detect_style_of_docstring(doc) == "sphinx"

    def test_no_sections(self):
        doc = "Just a short description."

        assert _detect_style_of_docstring(doc) is None

    def test_empty_string(self):
        assert _detect_style_of_docstring("") is None


def _make_griffe_obj(kind="function", docstring=None, members=None):
    """Create a mock griffe object."""
    obj = MagicMock()
    obj.kind.value = kind
    if docstring is not None:
        obj.docstring = MagicMock()
        obj.docstring.value = docstring
    else:
        obj.docstring = None
    if members is not None:
        obj.members = members
    else:
        obj.members = {}
    return obj


def _make_pkg(members_dict):
    """Create a mock griffe package with a dict-like members attribute."""
    pkg = MagicMock()
    # Use a real dict for members so __contains__ and __getitem__ work naturally
    pkg.members = members_dict
    return pkg


class TestCheckMissingDocstrings:
    def test_export_with_docstring(self):
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring="Documented function.")})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 0

    def test_export_without_docstring(self):
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=None)})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 1
        assert result.issues[0].check == "missing-docstring"
        assert result.issues[0].severity == "error"
        assert result.issues[0].symbol == "func_a"

    def test_export_with_empty_docstring(self):
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring="   ")})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 1
        assert result.issues[0].check == "missing-docstring"

    def test_class_method_without_docstring(self):
        method = _make_griffe_obj(kind="function", docstring=None)
        cls = _make_griffe_obj(
            kind="class",
            docstring="Documented class.",
            members={"do_stuff": method},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["MyClass"], result)

        assert len(result.issues) == 1
        assert result.issues[0].check == "missing-docstring"
        assert result.issues[0].severity == "warning"
        assert result.issues[0].symbol == "MyClass.do_stuff"

    def test_private_members_skipped(self):
        private_method = _make_griffe_obj(kind="function", docstring=None)
        cls = _make_griffe_obj(
            kind="class",
            docstring="Documented class.",
            members={"_private": private_method},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["MyClass"], result)

        assert len(result.issues) == 0

    def test_init_skipped(self):
        init_method = _make_griffe_obj(kind="function", docstring=None)
        cls = _make_griffe_obj(
            kind="class",
            docstring="Documented class.",
            members={"__init__": init_method},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["MyClass"], result)

        assert len(result.issues) == 0

    def test_unknown_export_skipped(self):
        pkg = _make_pkg({})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["nonexistent"], result)

        assert len(result.issues) == 0


class TestCheckCrossReferences:
    def test_valid_seealso(self):
        pkg = _make_pkg(
            {
                "func_a": _make_griffe_obj(docstring="Docs.\n\n%seealso func_b"),
                "func_b": _make_griffe_obj(docstring="Docs."),
            }
        )
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["func_a", "func_b"], result)

        assert len(result.issues) == 0

    def test_broken_seealso(self):
        pkg = _make_pkg(
            {
                "func_a": _make_griffe_obj(docstring="Docs.\n\n%seealso nonexistent_func"),
            }
        )
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 1
        assert result.issues[0].check == "broken-xref"
        assert result.issues[0].severity == "error"
        assert "nonexistent_func" in result.issues[0].message

    def test_broken_reference_in_second_seealso(self):
        pkg = _make_pkg(
            {
                "func_a": _make_griffe_obj(
                    docstring="Docs.\n\n%seealso func_b\n%seealso nonexistent_func"
                ),
                "func_b": _make_griffe_obj(docstring="Docs."),
            }
        )
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["func_a", "func_b"], result)

        assert len(result.issues) == 1
        assert result.issues[0].check == "broken-xref"
        assert "nonexistent_func" in result.issues[0].message

    def test_bare_seealso_does_not_lint_following_content(self):
        pkg = _make_pkg(
            {
                "func_a": _make_griffe_obj(docstring="Docs.\n\n%seealso\nnonexistent prose"),
            }
        )
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 0

    def test_seealso_to_class_method(self):
        method = _make_griffe_obj(kind="function", docstring="Method.")
        cls = _make_griffe_obj(
            kind="class",
            docstring="Class.\n\n%seealso MyClass.do_stuff",
            members={"do_stuff": method},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["MyClass"], result)

        assert len(result.issues) == 0

    def test_no_docstring_skipped(self):
        pkg = _make_pkg(
            {
                "func_a": _make_griffe_obj(docstring=None),
            }
        )
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 0


class TestCheckDocstringStyle:
    def test_matching_style(self):
        doc = "Short.\n\nParameters\n----------\nx : int\n"
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=doc)})
        result = LintResult()
        _check_docstring_style(pkg, "mypkg", ["func_a"], "numpy", result)

        assert len(result.issues) == 0

    def test_mismatching_style(self):
        doc = "Short.\n\nArgs:\n    x: The value.\n"
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=doc)})
        result = LintResult()
        _check_docstring_style(pkg, "mypkg", ["func_a"], "numpy", result)

        assert len(result.issues) == 1
        assert result.issues[0].check == "style-mismatch"
        assert result.issues[0].severity == "warning"

    def test_no_sections_no_issue(self):
        doc = "Just a short description."
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=doc)})
        result = LintResult()
        _check_docstring_style(pkg, "mypkg", ["func_a"], "numpy", result)

        assert len(result.issues) == 0

    def test_class_methods_checked(self):
        method_doc = "method.\n\nArgs:\n    x: value.\n"
        method = _make_griffe_obj(kind="function", docstring=method_doc)
        cls = _make_griffe_obj(
            kind="class",
            docstring="class.\n\nParameters\n----------\n",
            members={"do_stuff": method},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_docstring_style(pkg, "mypkg", ["MyClass"], "numpy", result)

        # Method has google style but config says numpy -> warning
        assert len(result.issues) == 1
        assert result.issues[0].symbol == "MyClass.do_stuff"


class TestCheckDirectiveConsistency:
    @pytest.mark.parametrize("directive", sorted(DIRECTIVES))
    def test_registered_directive_is_known(self, directive: str):
        doc = f"Short.\n\n%{directive}"
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=doc)})
        result = LintResult()
        _check_directive_consistency(pkg, "mypkg", ["func_a"], result)

        assert result.issues == []

    def test_unknown_directive(self):
        doc = "Short.\n\n%internal\n"
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=doc)})
        result = LintResult()
        _check_directive_consistency(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 1
        assert result.issues[0].check == "unknown-directive"
        assert "%internal" in result.issues[0].message

    @pytest.mark.parametrize("directive", ["WARNING", "SeeAlso", "NODOC"])
    def test_mixed_case_directive_is_unknown(self, directive: str):
        doc = f"Short.\n\n%{directive}"
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=doc)})
        result = LintResult()

        _check_directive_consistency(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 1
        assert result.issues[0].check == "unknown-directive"
        assert f"%{directive}" in result.issues[0].message

    def test_no_docstring_skipped(self):
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=None)})
        result = LintResult()
        _check_directive_consistency(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 0


class TestRunLint:
    def test_unknown_check_name(self, tmp_path):
        result = run_lint(tmp_path, checks={"bogus-check"})

        assert result.status == "fail"
        assert "Unknown check" in result.issues[0].message

    @patch("great_docs.core.GreatDocs")
    def test_no_package_detected(self, mock_gd_cls, tmp_path):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = None
        mock_gd_cls.return_value = mock_gd

        result = run_lint(tmp_path)

        assert result.status == "fail"
        assert "Could not detect package name" in result.issues[0].message

    @patch("great_docs.core.GreatDocs")
    def test_griffe_import_error(self, mock_gd_cls, tmp_path):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = "mypkg"
        mock_gd._detect_module_name.return_value = None
        mock_gd._normalize_package_name.return_value = "mypkg"
        mock_gd_cls.return_value = mock_gd

        with patch.dict("sys.modules", {"griffe": None}):
            # Simulate ImportError for griffe
            import builtins

            original_import = builtins.__import__

            def mock_import(name, *args, **kwargs):
                if name == "griffe":
                    raise ImportError("No module named 'griffe'")
                return original_import(name, *args, **kwargs)

            with patch("builtins.__import__", side_effect=mock_import):
                result = run_lint(tmp_path)

        assert result.status == "fail"
        assert any("griffe" in i.message for i in result.issues)

    @patch("griffe.load")
    @patch("great_docs.core.GreatDocs")
    def test_successful_lint_run(self, mock_gd_cls, mock_griffe_load, tmp_path):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = "mypkg"
        mock_gd._resolve_importable_name.return_value = "mypkg"
        mock_gd._get_package_exports.return_value = ["func_a", "func_b"]
        mock_gd._config.get.return_value = "numpy"
        mock_gd_cls.return_value = mock_gd

        func_a = _make_griffe_obj(docstring="Documented.\n\nParameters\n----------\nx : int\n")
        func_b = _make_griffe_obj(docstring=None)
        members = {"func_a": func_a, "func_b": func_b}

        mock_pkg = MagicMock()
        mock_pkg.members = members
        mock_griffe_load.return_value = mock_pkg

        result = run_lint(tmp_path)

        assert result.package_name == "mypkg"
        assert result.exports_count == 2

        # func_b has no docstring -> error
        assert any(i.check == "missing-docstring" and i.symbol == "func_b" for i in result.issues)

    @patch("griffe.load")
    @patch("great_docs.core.GreatDocs")
    def test_resolves_module_name_when_project_name_differs(
        self, mock_gd_cls, mock_griffe_load, tmp_path
    ):
        """Regression: run_lint loads griffe by the importable module name, not the
        dash-normalized PyPI project name, when the two diverge."""
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = "my-dist"
        mock_gd._resolve_importable_name.return_value = "actual_module"
        mock_gd._get_package_exports.return_value = ["func_a"]
        mock_gd._config.get.return_value = "numpy"
        mock_gd_cls.return_value = mock_gd

        func_a = _make_griffe_obj(docstring="Documented.\n\nParameters\n----------\nx : int\n")
        mock_pkg = MagicMock()
        mock_pkg.members = {"func_a": func_a}
        mock_griffe_load.return_value = mock_pkg

        result = run_lint(tmp_path)

        mock_gd._resolve_importable_name.assert_called_once_with("my-dist")
        mock_griffe_load.assert_called_once_with(
            "actual_module", search_paths=mock_gd._griffe_search_paths.return_value
        )
        assert result.package_name == "actual_module"

    @patch("griffe.load")
    @patch("great_docs.core.GreatDocs")
    def test_selective_checks(self, mock_gd_cls, mock_griffe_load, tmp_path):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = "mypkg"
        mock_gd._detect_module_name.return_value = None
        mock_gd._normalize_package_name.return_value = "mypkg"
        mock_gd._get_package_exports.return_value = ["func_a"]
        mock_gd._config.get.return_value = "numpy"
        mock_gd_cls.return_value = mock_gd

        # func_a has Google-style docstring (triggers style-mismatch) and no xref issues
        func_a = _make_griffe_obj(docstring="Docs.\n\nArgs:\n    x: val.\n")
        members = {"func_a": func_a}

        mock_pkg = MagicMock()
        mock_pkg.members = members
        mock_griffe_load.return_value = mock_pkg

        # Only run docstrings check — should find no issue (func_a has a docstring)
        result = run_lint(tmp_path, checks={"docstrings"})

        assert all(i.check != "style-mismatch" for i in result.issues)

        # Only run style check — should find style mismatch
        result = run_lint(tmp_path, checks={"style"})

        assert any(i.check == "style-mismatch" for i in result.issues)

    @patch("griffe.load")
    @patch("great_docs.core.GreatDocs")
    def test_no_exports(self, mock_gd_cls, mock_griffe_load, tmp_path):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = "mypkg"
        mock_gd._normalize_package_name.return_value = "mypkg"
        mock_gd._get_package_exports.return_value = None
        mock_gd._config.get.return_value = "numpy"
        mock_gd_cls.return_value = mock_gd

        mock_pkg = MagicMock()
        mock_pkg.members = {}
        mock_griffe_load.return_value = mock_pkg

        result = run_lint(tmp_path)

        assert result.status == "pass"
        assert result.exports_count == 0

    @patch("griffe.load")
    @patch("great_docs.core.GreatDocs")
    def test_griffe_load_failure(self, mock_gd_cls, mock_griffe_load, tmp_path):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = "mypkg"
        mock_gd._normalize_package_name.return_value = "mypkg"
        mock_gd_cls.return_value = mock_gd

        mock_griffe_load.side_effect = Exception("Module not found")

        result = run_lint(tmp_path)

        assert result.status == "fail"
        assert any("Could not load package" in i.message for i in result.issues)


class TestJsonOutput:
    def test_json_is_valid(self):
        r = LintResult(
            package_name="mypkg",
            exports_count=3,
            issues=[
                LintIssue("missing-docstring", "error", "func_a", "No docstring."),
                LintIssue("style-mismatch", "warning", "func_b", "Wrong style."),
                LintIssue("broken-xref", "error", "func_c", "Unknown ref."),
            ],
        )
        output = json.dumps(r.to_dict(), indent=2)
        parsed = json.loads(output)

        assert parsed["status"] == "fail"
        assert parsed["summary"]["errors"] == 2
        assert parsed["summary"]["warnings"] == 1
        assert len(parsed["issues"]) == 3


class TestRunLintQuiet:
    """Tests that exercise the quiet=True branches for stdout suppression."""

    @patch("griffe.load")
    @patch("great_docs.core.GreatDocs")
    def test_quiet_suppresses_output(self, mock_gd_cls, mock_griffe_load, tmp_path, capsys):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = "mypkg"
        mock_gd._normalize_package_name.return_value = "mypkg"
        mock_gd._get_package_exports.return_value = ["func_a"]
        mock_gd._config.get.return_value = "numpy"
        mock_gd_cls.return_value = mock_gd

        func_a = _make_griffe_obj(docstring="Documented.")
        mock_pkg = MagicMock()
        mock_pkg.members = {"func_a": func_a}
        mock_griffe_load.return_value = mock_pkg

        result = run_lint(tmp_path, quiet=True)

        assert result.status == "pass"

    @patch("great_docs.core.GreatDocs")
    def test_quiet_no_package_restores_stdout(self, mock_gd_cls, tmp_path):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = None
        mock_gd_cls.return_value = mock_gd

        import sys

        original_stdout = sys.stdout
        result = run_lint(tmp_path, quiet=True)

        # stdout must be restored after early return
        assert sys.stdout is original_stdout
        assert result.status == "fail"

    @patch("great_docs.core.GreatDocs")
    def test_quiet_griffe_import_error_restores_stdout(self, mock_gd_cls, tmp_path):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = "mypkg"
        mock_gd._normalize_package_name.return_value = "mypkg"
        mock_gd_cls.return_value = mock_gd

        import builtins
        import sys

        original_import = builtins.__import__
        original_stdout = sys.stdout

        def mock_import(name, *args, **kwargs):
            if name == "griffe":
                raise ImportError("No module named 'griffe'")
            return original_import(name, *args, **kwargs)

        with patch.dict("sys.modules", {"griffe": None}):
            with patch("builtins.__import__", side_effect=mock_import):
                result = run_lint(tmp_path, quiet=True)

        assert sys.stdout is original_stdout
        assert result.status == "fail"
        assert any("griffe" in i.message for i in result.issues)

    @patch("griffe.load")
    @patch("great_docs.core.GreatDocs")
    def test_quiet_griffe_load_failure_restores_stdout(
        self, mock_gd_cls, mock_griffe_load, tmp_path
    ):
        mock_gd = MagicMock()
        mock_gd._detect_package_name.return_value = "mypkg"
        mock_gd._normalize_package_name.return_value = "mypkg"
        mock_gd_cls.return_value = mock_gd

        mock_griffe_load.side_effect = RuntimeError("Cannot load")

        import sys

        original_stdout = sys.stdout
        result = run_lint(tmp_path, quiet=True)

        assert sys.stdout is original_stdout
        assert result.status == "fail"

    @patch("great_docs.core.GreatDocs")
    def test_quiet_constructor_exception_restores_stdout(self, mock_gd_cls, tmp_path):
        """When GreatDocs() constructor raises, stdout is restored before re-raising."""
        mock_gd_cls.side_effect = RuntimeError("constructor boom")

        import sys

        original_stdout = sys.stdout
        with pytest.raises(RuntimeError, match="constructor boom"):
            run_lint(tmp_path, quiet=True)

        assert sys.stdout is original_stdout


class TestHelperEdgeCases:
    def test_get_docstring_exception(self):
        """Test _get_docstring when accessing docstring raises an exception."""
        from great_docs._lint import _get_docstring

        obj = MagicMock()
        obj.docstring = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom")))
        # Make hasattr+access raise
        type(obj).docstring = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom")))
        result = _get_docstring(obj)

        assert result is None

    def test_iter_public_members_exception(self):
        """Test _iter_public_members when members.items() raises."""
        from great_docs._lint import _iter_public_members

        obj = MagicMock()
        obj.members.items.side_effect = RuntimeError("broken")
        members = list(_iter_public_members(obj))

        assert members == []

    def test_iter_public_members_yields_dunders(self):
        """Test that public dunders (not __init__) are yielded."""
        from great_docs._lint import _iter_public_members

        method_repr = MagicMock()
        method_init = MagicMock()
        method_public = MagicMock()
        members_dict = {
            "__repr__": method_repr,
            "__init__": method_init,
            "public_method": method_public,
        }
        obj = MagicMock()
        obj.members.items.return_value = members_dict.items()

        result = list(_iter_public_members(obj))
        names = [name for name, _ in result]

        assert "__repr__" in names
        assert "__init__" not in names
        assert "public_method" in names


class TestCheckMissingDocstringsEdgeCases:
    def test_class_member_kind_exception(self):
        """When member.kind.value raises, skip that member gracefully."""
        broken_member = MagicMock()
        broken_member.kind.value = property(
            lambda self: (_ for _ in ()).throw(RuntimeError("boom"))
        )
        # Make kind.value raise
        type(broken_member.kind).value = property(
            lambda self: (_ for _ in ()).throw(RuntimeError("boom"))
        )

        cls = _make_griffe_obj(
            kind="class",
            docstring="Documented class.",
            members={"broken": broken_member},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["MyClass"], result)

        assert all(i.symbol != "MyClass.broken" for i in result.issues)

    def test_class_member_non_function_skipped(self):
        """Attributes (non-function members) should not generate missing-docstring warnings."""
        attr = _make_griffe_obj(kind="attribute", docstring=None)
        cls = _make_griffe_obj(
            kind="class",
            docstring="Documented class.",
            members={"my_attr": attr},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["MyClass"], result)

        assert len(result.issues) == 0

    def test_class_outer_exception(self):
        """When obj.kind.value raises, the outer except catches it."""
        obj = _make_griffe_obj(docstring="Has docstring.")
        # Override kind to raise on value access
        type(obj.kind).value = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom")))
        pkg = _make_pkg({"weird_obj": obj})
        result = LintResult()
        _check_missing_docstrings(pkg, "mypkg", ["weird_obj"], result)


class TestCheckCrossReferencesEdgeCases:
    def test_export_not_in_pkg_members(self):
        """Exports not found in pkg.members should be skipped."""
        pkg = _make_pkg({})
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["missing_export"], result)

        assert len(result.issues) == 0

    def test_class_member_broken_xref(self):
        """Broken xref in a class method docstring."""
        method = _make_griffe_obj(
            kind="function",
            docstring="Method.\n\n%seealso nonexistent_thing",
        )
        cls = _make_griffe_obj(
            kind="class",
            docstring="Class.",
            members={"my_method": method},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["MyClass"], result)

        assert len(result.issues) == 1
        assert result.issues[0].symbol == "MyClass.my_method"
        assert result.issues[0].check == "broken-xref"

    def test_class_member_valid_xref(self):
        """Valid xref in a class method docstring (refers to another export)."""
        method = _make_griffe_obj(
            kind="function",
            docstring="Method.\n\n%seealso helper_func",
        )
        cls = _make_griffe_obj(
            kind="class",
            docstring="Class.",
            members={"my_method": method},
        )
        helper = _make_griffe_obj(docstring="Helper.")
        pkg = _make_pkg({"MyClass": cls, "helper_func": helper})
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["MyClass", "helper_func"], result)

        assert len(result.issues) == 0

    def test_class_method_broken_xref_no_class_xref(self):
        """Class has no xrefs but its method has a broken one."""
        method = _make_griffe_obj(
            kind="function",
            docstring="Method.\n\n%seealso ghost_func",
        )
        cls = _make_griffe_obj(
            kind="class",
            docstring="Class with no seealso.",
            members={"my_method": method},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["MyClass"], result)

        assert len(result.issues) == 1
        assert result.issues[0].check == "broken-xref"
        assert result.issues[0].symbol == "MyClass.my_method"
        assert "ghost_func" in result.issues[0].message

    def test_class_kind_exception_in_known_names(self):
        """When obj.kind.value raises during known_names building, skip gracefully."""
        obj = _make_griffe_obj(docstring="Doc.")
        type(obj.kind).value = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom")))
        pkg = _make_pkg({"broken_cls": obj})
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["broken_cls"], result)

    def test_class_outer_exception_in_method_xref(self):
        """When iterating class methods raises, the outer except catches it."""
        obj = _make_griffe_obj(docstring="Doc.\n\n%seealso something")
        # First call to kind.value works (for the top-level xref check),
        # but accessing members raises
        obj.kind.value = "class"
        obj.members = MagicMock()
        obj.members.items.side_effect = RuntimeError("boom")
        pkg = _make_pkg({"MyClass": obj, "something": _make_griffe_obj(docstring="X.")})
        result = LintResult()
        _check_cross_references(pkg, "mypkg", ["MyClass", "something"], result)


class TestDetectStyleEdgeCases:
    def test_mixed_numpy_and_sphinx(self):
        """Docstring with both numpy and sphinx markers returns numpy (first found)."""
        doc = """\
Short description.

Parameters
----------
x : int

:param y: Another param.
"""
        result = _detect_style_of_docstring(doc)
        assert result == "numpy"

    def test_mixed_google_and_sphinx(self):
        """Docstring with google and sphinx markers."""
        doc = """\
Short description.

:param x: A param.

Args:
    y: Another param.
"""
        # sphinx detected first in code order
        result = _detect_style_of_docstring(doc)

        # Both google and sphinx detected; but numpy takes precedence over google
        # and sphinx is also found, so styles_found has both
        assert result in ("google", "sphinx")


class TestCheckDocstringStyleEdgeCases:
    def test_export_not_in_pkg(self):
        """Exports not in pkg.members should be skipped."""
        pkg = _make_pkg({})
        result = LintResult()
        _check_docstring_style(pkg, "mypkg", ["missing"], "numpy", result)

        assert len(result.issues) == 0

    def test_class_method_style_exception(self):
        """When class kind raises, outer except catches it."""
        obj = _make_griffe_obj(docstring="Short.")
        type(obj.kind).value = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom")))
        pkg = _make_pkg({"broken": obj})
        result = LintResult()
        _check_docstring_style(pkg, "mypkg", ["broken"], "numpy", result)

    def test_no_docstring_skipped(self):
        """Export with None docstring is skipped without error."""
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=None)})
        result = LintResult()
        _check_docstring_style(pkg, "mypkg", ["func_a"], "numpy", result)

        assert len(result.issues) == 0


class TestCheckDirectiveConsistencyEdgeCases:
    def test_export_not_in_pkg(self):
        """Exports not in pkg.members should be skipped."""
        pkg = _make_pkg({})
        result = LintResult()
        _check_directive_consistency(pkg, "mypkg", ["missing"], result)

        assert len(result.issues) == 0

    def test_class_method_unknown_directive(self):
        """Unknown directive in a class method docstring."""
        method = _make_griffe_obj(
            kind="function",
            docstring="Method.\n\n%versionremoved 2.0\n",
        )
        cls = _make_griffe_obj(
            kind="class",
            docstring="Class.",
            members={"my_method": method},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_directive_consistency(pkg, "mypkg", ["MyClass"], result)

        assert len(result.issues) == 1
        assert result.issues[0].symbol == "MyClass.my_method"
        assert result.issues[0].check == "unknown-directive"

    def test_class_method_valid_directive(self):
        """Valid directives in class method docstrings pass without issues."""
        method = _make_griffe_obj(
            kind="function",
            docstring="Method.\n\n%seealso helper_func\n",
        )
        cls = _make_griffe_obj(
            kind="class",
            docstring="Class.",
            members={"my_method": method},
        )
        pkg = _make_pkg({"MyClass": cls})
        result = LintResult()
        _check_directive_consistency(pkg, "mypkg", ["MyClass"], result)

        assert len(result.issues) == 0

    def test_class_kind_exception(self):
        """When class kind raises, outer except catches it."""
        obj = _make_griffe_obj(docstring="Short.")
        type(obj.kind).value = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom")))
        pkg = _make_pkg({"broken": obj})
        result = LintResult()
        _check_directive_consistency(pkg, "mypkg", ["broken"], result)

    def test_no_docstring_on_export(self):
        """Export with no docstring should be skipped."""
        pkg = _make_pkg({"func_a": _make_griffe_obj(docstring=None)})
        result = LintResult()
        _check_directive_consistency(pkg, "mypkg", ["func_a"], result)

        assert len(result.issues) == 0


# ---------------------------------------------------------------------------
# Stale version annotation checks
# ---------------------------------------------------------------------------


class TestCheckStaleVersions:
    """Tests for _check_stale_versions lint check."""

    def _make_project(self, tmp_path, versions_yaml, qmd_files: dict[str, str]):
        """Create a minimal project with great-docs.yml and .qmd files."""
        config = f"versions:\n{versions_yaml}\n"
        (tmp_path / "great-docs.yml").write_text(config)
        for rel_path, content in qmd_files.items():
            p = tmp_path / rel_path
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_text(content)

    @property
    def _versions_yaml(self):
        return (
            '  - label: "0.8 (dev)"\n'
            "    tag: dev\n"
            '    version: "0.8"\n'
            "    prerelease: true\n"
            '  - label: "0.7"\n'
            '    tag: "0.7"\n'
            "    latest: true\n"
            '  - label: "0.6"\n'
            '    tag: "0.6"\n'
            '  - label: "0.5"\n'
            '    tag: "0.5"\n'
            '  - label: "0.4"\n'
            '    tag: "0.4"\n'
            '  - label: "0.3"\n'
            '    tag: "0.3"\n'
            '  - label: "0.2"\n'
            '    tag: "0.2"\n'
            '  - label: "0.1"\n'
            '    tag: "0.1"\n'
        )

    def test_stale_badge_detected(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        self._make_project(
            tmp_path,
            self._versions_yaml,
            {
                "user_guide/page.qmd": (
                    "---\ntitle: Test\n---\n\nSome text [version-badge new 0.3] here.\n"
                ),
            },
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        stale = [i for i in result.issues if i.check == "stale-badge"]
        assert len(stale) == 1
        assert "0.3" in stale[0].message
        assert "4 releases behind" in stale[0].message

    def test_fresh_badge_not_flagged(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        self._make_project(
            tmp_path,
            self._versions_yaml,
            {
                "user_guide/page.qmd": (
                    "---\ntitle: Test\n---\n\nSome text [version-badge new 0.6] here.\n"
                ),
            },
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        stale = [i for i in result.issues if i.check == "stale-badge"]
        assert len(stale) == 0

    def test_stale_callout_detected(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        self._make_project(
            tmp_path,
            self._versions_yaml,
            {
                "user_guide/page.qmd": (
                    "---\ntitle: Test\n---\n\n"
                    '::: {.version-note version="0.2"}\n'
                    "Added in 0.2.\n"
                    ":::\n"
                ),
            },
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        stale = [i for i in result.issues if i.check == "stale-callout"]
        assert len(stale) == 1
        assert "0.2" in stale[0].message
        assert stale[0].severity == "info"

    def test_fresh_callout_not_flagged(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        self._make_project(
            tmp_path,
            self._versions_yaml,
            {
                "user_guide/page.qmd": (
                    "---\ntitle: Test\n---\n\n"
                    '::: {.version-note version="0.5"}\n'
                    "Added in 0.5.\n"
                    ":::\n"
                ),
            },
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        stale = [i for i in result.issues if i.check == "stale-callout"]
        assert len(stale) == 0

    def test_stale_upcoming_detected(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        self._make_project(
            tmp_path,
            self._versions_yaml,
            {
                "user_guide/page.qmd": ('---\ntitle: Test\nupcoming: "0.5"\n---\n\nContent.\n'),
            },
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        stale = [i for i in result.issues if i.check == "stale-upcoming"]
        assert len(stale) == 1
        assert "0.5" in stale[0].message
        assert "already-released" in stale[0].message

    def test_valid_upcoming_not_flagged(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        self._make_project(
            tmp_path,
            self._versions_yaml,
            {
                "user_guide/page.qmd": ('---\ntitle: Test\nupcoming: "0.8"\n---\n\nContent.\n'),
            },
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        stale = [i for i in result.issues if i.check == "stale-upcoming"]
        assert len(stale) == 0

    def test_deprecated_callout_stale(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        self._make_project(
            tmp_path,
            self._versions_yaml,
            {
                "user_guide/page.qmd": (
                    "---\ntitle: Test\n---\n\n"
                    '::: {.version-deprecated version="0.1"}\n'
                    "Use new_func().\n"
                    ":::\n"
                ),
            },
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        stale = [i for i in result.issues if i.check == "stale-callout"]
        assert len(stale) == 1
        assert "version-deprecated" in stale[0].message

    def test_custom_thresholds(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        # Use a very high threshold so nothing is flagged
        config = (
            f"versions:\n{self._versions_yaml}\n"
            "lint:\n"
            "  stale_versions:\n"
            "    badge_threshold: 99\n"
            "    callout_threshold: 99\n"
        )
        (tmp_path / "great-docs.yml").write_text(config)
        qmd = tmp_path / "user_guide" / "page.qmd"
        qmd.parent.mkdir(parents=True)
        qmd.write_text(
            "---\ntitle: Test\n---\n\n"
            "[version-badge new 0.1]\n"
            '::: {.version-note version="0.1"}\nOld.\n:::\n'
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        assert len(result.issues) == 0

    def test_no_versions_config_skips(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        (tmp_path / "great-docs.yml").write_text("theme: default\n")
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        assert len(result.issues) == 0

    def test_skips_underscore_dirs(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        self._make_project(
            tmp_path,
            self._versions_yaml,
            {
                "_site/page.qmd": ("---\ntitle: Test\n---\n\n[version-badge new 0.1]\n"),
            },
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        assert len(result.issues) == 0

    def test_line_numbers_correct(self, tmp_path):
        from great_docs._lint import _check_stale_versions

        self._make_project(
            tmp_path,
            self._versions_yaml,
            {
                "user_guide/page.qmd": (
                    "---\ntitle: Test\n---\n\nLine 5\nLine 6\n[version-badge new 0.1]\n"
                ),
            },
        )
        result = LintResult()
        _check_stale_versions(tmp_path, result)
        stale = [i for i in result.issues if i.check == "stale-badge"]
        assert len(stale) == 1
        assert stale[0].symbol == "user_guide/page.qmd:7"
