"""Tests for src.gui.tools_registry — the per-tool manifest.

The registry is loaded at import time by the home page sidebar nav,
the home grid, and the findings panel's "Open Tool" links. A broken
entry would surface as a sidebar disappearance, a missing card, or a
``KeyError`` in the findings rendering. We pin the invariants those
call sites rely on:

- Every page_slug points at a file that actually exists.
- Every tool_id is unique (the analyzer keys findings on it).
- Every section is one of the declared literals.
- ``tool_by_id`` round-trips, ``display_name`` falls back gracefully.
- ``section_label`` resolves localized labels.
"""

from __future__ import annotations

from pathlib import Path
from typing import get_args

import pytest

from src.gui.tools_registry import (
    SECTION_LABELS,
    TOOLS,
    Section,
    Tier,
    Tool,
    display_name,
    section_label,
    tool_by_id,
    tool_description,
    tool_name,
    tools_for_tier,
    tools_in_section,
)

PAGES_DIR = Path(__file__).resolve().parent.parent / "src" / "gui" / "pages"


class TestRegistryInvariants:
    def test_all_tool_ids_are_unique(self):
        ids = [t.tool_id for t in TOOLS]
        assert len(ids) == len(set(ids)), (
            f"duplicate tool_id in TOOLS: {sorted(ids)}"
        )

    def test_all_page_slugs_point_at_real_files(self):
        for tool in TOOLS:
            page_file = PAGES_DIR / f"{tool.page_slug}.py"
            assert page_file.exists(), (
                f"{tool.tool_id} → {tool.page_slug}.py does not exist"
            )

    def test_all_sections_are_declared_literals(self):
        valid = set(get_args(Section))
        for tool in TOOLS:
            assert tool.section in valid, (
                f"{tool.tool_id} has unknown section {tool.section!r}; "
                f"valid: {sorted(valid)}"
            )

    def test_all_tiers_are_declared_literals(self):
        valid = set(get_args(Tier))
        for tool in TOOLS:
            assert tool.tier in valid, (
                f"{tool.tool_id} has unknown tier {tool.tier!r}; "
                f"valid: {sorted(valid)}"
            )

    def test_every_section_has_a_display_label(self):
        for section in get_args(Section):
            assert section in SECTION_LABELS, (
                f"section {section!r} has no SECTION_LABELS entry"
            )

    def test_no_orphan_section_labels(self):
        # The other direction: a SECTION_LABELS key that isn't a
        # declared Section literal is dead config.
        valid = set(get_args(Section))
        for key in SECTION_LABELS:
            assert key in valid, (
                f"SECTION_LABELS has stray key {key!r} not in Section"
            )


class TestToolLookups:
    def test_tool_by_id_round_trips_every_entry(self):
        for tool in TOOLS:
            found = tool_by_id(tool.tool_id)
            assert found is tool, (
                f"tool_by_id({tool.tool_id!r}) returned {found!r}"
            )

    def test_tool_by_id_returns_none_for_unknown(self):
        assert tool_by_id("not_a_real_tool_id") is None

    def test_display_name_falls_back_to_id(self):
        # Documented behavior: unknown id returns the id itself so the
        # bug is visible in the UI rather than crashing.
        assert display_name("not_a_real_tool_id") == "not_a_real_tool_id"

    def test_display_name_resolves_known_tool(self):
        # Pick a tool we know ships in every build.
        assert display_name("02_text_cleaner") == "Clean Text"


class TestTierAndSectionFilters:
    def test_tools_for_tier_empty_returns_all(self):
        assert tools_for_tier() == list(TOOLS)

    def test_tools_for_tier_filters(self):
        # Every tool is tier="core" today, so an explicit core filter
        # should still match the full set. A "pro"-only call should
        # return an empty list.
        assert tools_for_tier("core") == list(TOOLS)
        assert tools_for_tier("pro") == []

    def test_tools_in_section_preserves_registry_order(self):
        cleaners = tools_in_section("cleaners")
        in_full_order = [t for t in TOOLS if t.section == "cleaners"]
        assert cleaners == in_full_order

    @pytest.mark.parametrize("section", list(get_args(Section)))
    def test_every_section_has_at_least_one_tool(self, section):
        assert tools_in_section(section), (
            f"section {section!r} has zero tools — sidebar group would be empty"
        )


class TestLocalizedAccessors:
    def test_tool_name_falls_back_to_registry_default(self):
        # An unknown tool id should return the literal id, not crash.
        assert tool_name("not_a_real_tool_id") == "not_a_real_tool_id"

    def test_tool_name_returns_localized_when_pack_has_key(self):
        # The lang packs ship a "tools.{id}.name" key for every shipped
        # tool. We don't assert the exact translation here (the lang
        # pack parity test pins that); we just check the helper returns
        # something non-empty and not the literal lookup key.
        name = tool_name("02_text_cleaner")
        assert name and name != "tools.02_text_cleaner.name"

    def test_tool_description_returns_localized_or_fallback(self):
        desc = tool_description("02_text_cleaner")
        assert desc and desc != "tools.02_text_cleaner.description"

    def test_tool_description_for_unknown_returns_empty(self):
        # Unknown ids return the registry fallback (""), not a
        # lookup-key string. The home grid avoids rendering empty
        # descriptions, so this contract matters.
        assert tool_description("not_a_real_tool_id") == ""

    @pytest.mark.parametrize("section", list(get_args(Section)))
    def test_section_label_returns_non_empty(self, section):
        label = section_label(section)
        assert label and label != f"nav.section_{section}"


class TestDescriptionCopy:
    """The post-jargon-strip descriptions are intentionally tight one-
    liners. Pin them so future drift toward bloated marketing copy
    (or an accidentally-empty string) is caught by CI."""

    # Roomy upper bound; the tightest description today is ~60 chars
    # and the longest is just over 90. ~120 leaves headroom for minor
    # copy tweaks without inviting paragraph-length card bodies.
    _MAX_DESCRIPTION_CHARS = 120

    def test_every_description_is_non_empty(self):
        empty = [t.tool_id for t in TOOLS if not t.description.strip()]
        assert not empty, f"tools with empty descriptions: {empty}"

    def test_every_description_under_max_chars(self):
        too_long = [
            (t.tool_id, len(t.description))
            for t in TOOLS
            if len(t.description) > self._MAX_DESCRIPTION_CHARS
        ]
        assert not too_long, (
            f"tool descriptions exceed {self._MAX_DESCRIPTION_CHARS} chars: "
            f"{too_long}"
        )


class TestRenderToolHeaderSmoke:
    """``render_tool_header`` is the helper every tool page now calls in
    place of ``st.title(...) + st.caption(...)``. We can't render it
    without a Streamlit script context, but we CAN verify it imports
    cleanly via the public ``src.gui.components`` surface and resolves
    the expected i18n keys for a known tool id."""

    def test_importable_from_public_components_package(self):
        from src.gui.components import render_tool_header

        assert callable(render_tool_header)

    def test_listed_in_public_all(self):
        # The public ``__all__`` is what per-tool builds key off; a
        # removal here would silently break tool pages that import
        # from ``src.gui.components`` directly.
        from src.gui import components as components_pkg

        assert "render_tool_header" in components_pkg.__all__

    def test_resolves_expected_i18n_keys_for_known_tool(self):
        # The helper reads four pack keys per render:
        # ``tools.<id>.page_title``, ``tools.<id>.page_caption``,
        # ``tools.<id>.help_md``, plus shared ``help.button_label`` /
        # ``help.missing_body``. We don't invoke the helper (no script
        # context) — we verify the keys it would touch resolve to
        # non-empty strings in both packs.
        from src.i18n import t as _t

        tool_id = "02_text_cleaner"
        for lang in ("en", "es"):
            for suffix in ("page_title", "page_caption", "help_md"):
                key = f"tools.{tool_id}.{suffix}"
                value = _t(key, lang)
                assert value and value != key, (
                    f"render_tool_header({tool_id!r}) "
                    f"would render the literal key {key!r} in {lang!r}"
                )
            for key in ("help.button_label", "help.missing_body"):
                value = _t(key, lang)
                assert value and value != key, (
                    f"render_tool_header would render the literal key "
                    f"{key!r} in {lang!r}"
                )


class TestReconcilerAndPdfArePresent:
    """The two newest pages were the most likely to be forgotten in
    the registry — pin them explicitly so a regression flagging
    "Ready" tools as missing from nav is loud."""

    def test_pdf_extractor_present(self):
        tool = tool_by_id("10_pdf_extractor")
        assert tool is not None
        assert tool.page_slug == "10_PDF_Extractor"
        assert tool.status == "Ready"
        # PDF to CSV + Reconcile live in the "Finance" group (outside the
        # cleaning flow) per DECISIONS.md 2026-06-08.
        assert tool.section == "finance"

    def test_reconciler_present(self):
        tool = tool_by_id("11_reconciler")
        assert tool is not None
        assert tool.page_slug == "11_Reconciler"
        assert tool.status == "Ready"
        # Reconcile sits in the "Finance" group (see DECISIONS.md
        # 2026-06-08); if that section disappears the sidebar goes empty.
        assert tool.section == "finance"
