from __future__ import annotations

import json
from pathlib import Path
from types import SimpleNamespace

import pytest
from typer.testing import CliRunner

from worai.commands import graph as graph_cmd
from worai.errors import UsageError


def _make_url_result(url: str, errors: list | None = None, warnings: list | None = None) -> SimpleNamespace:
    errors = errors or []
    warnings = warnings or []
    return SimpleNamespace(
        url=url,
        errors=[SimpleNamespace(message=e, focus_node="urn:focus") for e in errors],
        warnings=[SimpleNamespace(message=w, focus_node="urn:focus") for w in warnings],
        error_count=len(errors),
        warning_count=len(warnings),
        google_merchant=SimpleNamespace(eligible=len(errors) == 0),
    )


class _FakeReport:
    def __init__(
        self,
        load_errors: list | None = None,
        text: str = "OK",
        data: dict | None = None,
        url_results: list | None = None,
    ) -> None:
        self.load_errors = load_errors or []
        self._text = text
        self._data = data or {"summary": "ok"}
        self.schema_compliance = SimpleNamespace(by_url=url_results or [])

    def to_text(self) -> str:
        return self._text

    def to_dict(self) -> dict:
        return self._data


class _FakeAuditor:
    def __init__(self, report: _FakeReport) -> None:
        self._report = report

    def audit(self, path: str, options: object) -> _FakeReport:
        return self._report


def _patch_sdk(monkeypatch, report: _FakeReport | None = None, auditor_cls=None) -> None:
    if report is None:
        report = _FakeReport()
    if auditor_cls is None:
        _auditor = _FakeAuditor(report)

        class _Cls:
            def __call__(self):
                return _auditor

        auditor_cls = _Cls()

    monkeypatch.setattr(graph_cmd, "_GraphAuditor", auditor_cls)
    monkeypatch.setattr(graph_cmd, "_AuditOptions", SimpleNamespace)


# ---------------------------------------------------------------------------
# _render_audit_text — failures-only schema compliance
# ---------------------------------------------------------------------------

_COMPLIANCE_SECTION = "--- Schema Compliance ---"


def _report_with_urls(ok_urls: list[str], fail_urls: list[str]) -> _FakeReport:
    url_results = [_make_url_result(u) for u in ok_urls] + [
        _make_url_result(u, errors=["Missing required field"]) for u in fail_urls
    ]
    body = f"=== Graph Audit Report ===\n\n{_COMPLIANCE_SECTION}\n"
    return _FakeReport(text=body, url_results=url_results)


def _compliance_report(ok_urls: list[str], fail_urls: list[str]) -> _FakeReport:
    url_results = [_make_url_result(u) for u in ok_urls] + [
        _make_url_result(u, errors=["Missing required field"]) for u in fail_urls
    ]
    body = f"=== Graph Audit Report ===\n\n{_COMPLIANCE_SECTION}\n"
    return _FakeReport(text=body, url_results=url_results)


def test_render_audit_text_default_shows_summary_only() -> None:
    report = _compliance_report(ok_urls=["https://example.com/ok"], fail_urls=["https://example.com/fail"])
    rendered = graph_cmd._render_audit_text(report)
    assert "URLs checked : 2" in rendered
    assert "URLs failing : 1" in rendered
    assert "https://example.com/ok" not in rendered
    assert "https://example.com/fail" not in rendered
    assert "[FAIL]" not in rendered


def test_render_audit_text_show_url_violations_lists_failing_urls() -> None:
    report = _compliance_report(ok_urls=["https://example.com/ok"], fail_urls=["https://example.com/fail"])
    rendered = graph_cmd._render_audit_text(report, show_url_violations=True)
    assert "https://example.com/ok" not in rendered
    assert "https://example.com/fail" in rendered
    assert "[FAIL]" in rendered
    assert "[OK]" not in rendered


def test_render_audit_text_no_failures_shows_zero_count() -> None:
    report = _compliance_report(ok_urls=["https://example.com/a", "https://example.com/b"], fail_urls=[])
    rendered = graph_cmd._render_audit_text(report)
    assert "URLs checked : 2" in rendered
    assert "URLs failing : 0" in rendered
    assert "[FAIL]" not in rendered


def test_render_audit_text_preserves_pre_section_content() -> None:
    report = _compliance_report(ok_urls=[], fail_urls=["https://example.com/fail"])
    report._text = f"=== Graph Audit Report ===\n\n--- Totals ---\n  Entities: 5\n\n{_COMPLIANCE_SECTION}\n"
    rendered = graph_cmd._render_audit_text(report, show_url_violations=True)
    assert "--- Totals ---" in rendered
    assert "Entities: 5" in rendered
    assert "https://example.com/fail" in rendered


def test_render_audit_text_includes_error_messages() -> None:
    url_result = _make_url_result("https://example.com/page", errors=["Missing schema:name"])
    report = _FakeReport(
        text=f"=== Graph Audit Report ===\n\n{_COMPLIANCE_SECTION}\n",
        url_results=[url_result],
    )
    rendered = graph_cmd._render_audit_text(report, show_url_violations=True)
    assert "Missing schema:name" in rendered
    assert "ERROR:" in rendered


def test_render_audit_text_deduplicates_repeated_errors() -> None:
    url_result = SimpleNamespace(
        url="https://example.com/page",
        errors=[
            SimpleNamespace(message="Value does not have class schema:Question", focus_node=f"urn:focus{i}")
            for i in range(3)
        ],
        warnings=[],
        error_count=3,
        warning_count=0,
        google_merchant=SimpleNamespace(eligible=False),
    )
    report = _FakeReport(
        text=f"=== Graph Audit Report ===\n\n{_COMPLIANCE_SECTION}\n",
        url_results=[url_result],
    )
    rendered = graph_cmd._render_audit_text(report, show_url_violations=True)
    assert rendered.count("Value does not have class schema:Question") == 1
    assert "×3" in rendered


def test_render_audit_text_single_error_shows_focus_node() -> None:
    url_result = SimpleNamespace(
        url="https://example.com/page",
        errors=[SimpleNamespace(message="Missing name", focus_node="urn:focus1")],
        warnings=[],
        error_count=1,
        warning_count=0,
        google_merchant=SimpleNamespace(eligible=False),
    )
    report = _FakeReport(
        text=f"=== Graph Audit Report ===\n\n{_COMPLIANCE_SECTION}\n",
        url_results=[url_result],
    )
    rendered = graph_cmd._render_audit_text(report, show_url_violations=True)
    assert "focus=urn:focus1" in rendered
    assert "×" not in rendered


def test_render_audit_text_no_marker_returns_full_text() -> None:
    report = _FakeReport(text="No compliance section here.", url_results=[])
    assert graph_cmd._render_audit_text(report) == "No compliance section here."


# ---------------------------------------------------------------------------
# Basic invocation
# ---------------------------------------------------------------------------


def test_audit_text_output_exit_0(monkeypatch) -> None:
    report = _FakeReport(text="Audit OK\n")
    _patch_sdk(monkeypatch, report)

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl"])

    assert result.exit_code == 0
    assert "Audit OK" in result.output


def test_audit_json_output_exit_0(monkeypatch) -> None:
    report = _FakeReport(data={"kpis": []})
    _patch_sdk(monkeypatch, report)

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl", "--format", "json"])

    assert result.exit_code == 0
    payload = json.loads(result.output)
    assert payload == {"kpis": []}


def test_audit_exit_1_on_load_errors(monkeypatch) -> None:
    report = _FakeReport(load_errors=["parse error"], text="FAIL\n")
    _patch_sdk(monkeypatch, report)

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl"])

    assert result.exit_code == 1
    assert "FAIL" in result.output


# ---------------------------------------------------------------------------
# Option forwarding
# ---------------------------------------------------------------------------


def test_audit_options_forwarded(monkeypatch) -> None:
    seen: dict = {}

    class _CapturingAuditor:
        def audit(self, path, options):
            seen["path"] = path
            seen["options"] = options
            return _FakeReport()

    class _AuditorCls:
        def __call__(self):
            return _CapturingAuditor()

    monkeypatch.setattr(graph_cmd, "_GraphAuditor", _AuditorCls())
    monkeypatch.setattr(graph_cmd, "_AuditOptions", lambda **kw: SimpleNamespace(**kw))

    result = CliRunner().invoke(
        graph_cmd.app,
        [
            "audit",
            "my_graph.ttl",
            "--depth",
            "3",
            "--list-isolated-iris",
            "--rich-snippets-granularity",
            "entities",
            "--workers",
            "4",
        ],
    )

    assert result.exit_code == 0
    assert seen["path"] == "my_graph.ttl"
    opts = seen["options"]
    assert opts.subgraph_depth == 3
    assert opts.list_isolated_components is True
    assert opts.rich_snippets_granularity == "entities"
    assert opts.max_workers == 4
    assert opts.profile is None


def test_audit_shape_options_forwarded(monkeypatch) -> None:
    seen: dict = {}

    class _Cap:
        def audit(self, path, options):
            seen["options"] = options
            return _FakeReport()

    class _Cls:
        def __call__(self):
            return _Cap()

    monkeypatch.setattr(graph_cmd, "_GraphAuditor", _Cls())
    monkeypatch.setattr(graph_cmd, "_AuditOptions", lambda **kw: SimpleNamespace(**kw))

    result = CliRunner().invoke(
        graph_cmd.app,
        [
            "audit",
            "graph.ttl",
            "--builtin-shape",
            "google-article.ttl",
            "--builtin-shape",
            "google-faq.ttl",
            "--shape",
            "./custom.ttl",
            "--issue-level",
            "error",
        ],
    )

    assert result.exit_code == 0
    opts = seen["options"]
    assert opts.builtin_shapes == ["google-article.ttl", "google-faq.ttl"]
    assert opts.exclude_builtin_shapes is None
    assert opts.extra_shapes == ["./custom.ttl"]
    assert opts.issue_level == "error"


def test_audit_exclude_builtin_shapes_forwarded(monkeypatch) -> None:
    seen: dict = {}

    class _Cap:
        def audit(self, path, options):
            seen["options"] = options
            return _FakeReport()

    class _Cls:
        def __call__(self):
            return _Cap()

    monkeypatch.setattr(graph_cmd, "_GraphAuditor", _Cls())
    monkeypatch.setattr(graph_cmd, "_AuditOptions", lambda **kw: SimpleNamespace(**kw))

    result = CliRunner().invoke(
        graph_cmd.app,
        ["audit", "graph.ttl", "--exclude-builtin-shape", "google-article.ttl"],
    )

    assert result.exit_code == 0
    opts = seen["options"]
    assert opts.builtin_shapes is None
    assert opts.exclude_builtin_shapes == ["google-article.ttl"]


def test_audit_builtin_and_exclude_are_mutually_exclusive(monkeypatch) -> None:
    _patch_sdk(monkeypatch)

    result = CliRunner().invoke(
        graph_cmd.app,
        [
            "audit",
            "graph.ttl",
            "--builtin-shape",
            "a.ttl",
            "--exclude-builtin-shape",
            "b.ttl",
        ],
    )

    assert result.exit_code == 1
    assert isinstance(result.exception, UsageError)
    assert "mutually exclusive" in str(result.exception)


def test_audit_no_shapes_passes_none(monkeypatch) -> None:
    seen: dict = {}

    class _Cap:
        def audit(self, path, options):
            seen["options"] = options
            return _FakeReport()

    class _Cls:
        def __call__(self):
            return _Cap()

    monkeypatch.setattr(graph_cmd, "_GraphAuditor", _Cls())
    monkeypatch.setattr(graph_cmd, "_AuditOptions", lambda **kw: SimpleNamespace(**kw))

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl"])

    assert result.exit_code == 0
    opts = seen["options"]
    assert opts.builtin_shapes is None
    assert opts.exclude_builtin_shapes is None
    assert opts.extra_shapes is None
    assert opts.issue_level == "warning"


# ---------------------------------------------------------------------------
# Profile resolution — global switch
# ---------------------------------------------------------------------------


def test_audit_no_profile_passes_none(monkeypatch) -> None:
    seen: dict = {}

    class _Cap:
        def audit(self, path, options):
            seen["profile"] = options.profile
            return _FakeReport()

    class _Cls:
        def __call__(self):
            return _Cap()

    monkeypatch.setattr(graph_cmd, "_GraphAuditor", _Cls())
    monkeypatch.setattr(graph_cmd, "_AuditOptions", lambda **kw: SimpleNamespace(**kw))

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl"], obj={"profile": None})

    assert result.exit_code == 0
    assert seen["profile"] is None


def test_audit_profile_loaded_from_global_switch(monkeypatch) -> None:
    fake_profile = SimpleNamespace(api_key="wl_test")
    seen: dict = {}

    monkeypatch.setattr(
        graph_cmd,
        "load_profile",
        lambda ctx: (fake_profile, "acme", Path("worai.toml")),
    )

    class _Cap:
        def audit(self, path, options):
            seen["profile"] = options.profile
            return _FakeReport()

    class _Cls:
        def __call__(self):
            return _Cap()

    monkeypatch.setattr(graph_cmd, "_GraphAuditor", _Cls())
    monkeypatch.setattr(graph_cmd, "_AuditOptions", lambda **kw: SimpleNamespace(**kw))

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl"], obj={"profile": "acme"})

    assert result.exit_code == 0
    assert seen["profile"] is fake_profile


def test_audit_profile_load_error_raises_usage_error(monkeypatch) -> None:
    monkeypatch.setattr(
        graph_cmd,
        "load_profile",
        lambda ctx: (_ for _ in ()).throw(ValueError("missing profile")),
    )
    monkeypatch.setattr(graph_cmd, "_GraphAuditor", object())
    monkeypatch.setattr(graph_cmd, "_AuditOptions", SimpleNamespace)

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl"], obj={"profile": "missing"})

    assert result.exit_code == 1
    assert isinstance(result.exception, UsageError)
    assert "missing profile" in str(result.exception)


# ---------------------------------------------------------------------------
# SDK unavailable
# ---------------------------------------------------------------------------


def test_audit_raises_usage_error_when_sdk_unavailable(monkeypatch) -> None:
    monkeypatch.setattr(graph_cmd, "_GraphAuditor", None)
    monkeypatch.setattr(graph_cmd, "_AuditOptions", None)

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl"])

    assert result.exit_code == 1
    assert isinstance(result.exception, UsageError)
    assert "wordlift-sdk>=6.11.0" in str(result.exception)


# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------


def test_normalize_rich_snippets_granularity() -> None:
    assert graph_cmd._normalize_rich_snippets_granularity("counts") == "counts"
    assert graph_cmd._normalize_rich_snippets_granularity("Entities") == "entities"
    with pytest.raises(UsageError, match="counts.*entities"):
        graph_cmd._normalize_rich_snippets_granularity("bad")


def test_audit_invalid_format(monkeypatch) -> None:
    _patch_sdk(monkeypatch)

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl", "--format", "xml"])

    assert result.exit_code == 1
    assert isinstance(result.exception, UsageError)


def test_audit_invalid_rich_snippets_granularity(monkeypatch) -> None:
    _patch_sdk(monkeypatch)

    result = CliRunner().invoke(
        graph_cmd.app, ["audit", "graph.ttl", "--rich-snippets-granularity", "bad"]
    )

    assert result.exit_code == 1
    assert isinstance(result.exception, UsageError)


# ---------------------------------------------------------------------------
# Auditor exception wrapping
# ---------------------------------------------------------------------------


def test_audit_unknown_builtin_shape_lists_available(monkeypatch) -> None:
    class _BrokenAuditor:
        def audit(self, path, options):
            raise ValueError("Unknown excluded builtin shape value: merchant")

    class _Cls:
        def __call__(self):
            return _BrokenAuditor()

    monkeypatch.setattr(graph_cmd, "_GraphAuditor", _Cls())
    monkeypatch.setattr(graph_cmd, "_AuditOptions", lambda **kw: SimpleNamespace(**kw))
    monkeypatch.setattr(
        graph_cmd,
        "_shape_specs_for_profile",
        lambda _p: ["google-merchant-listing.ttl", "google-article.ttl"],
    )

    result = CliRunner().invoke(
        graph_cmd.app, ["audit", "graph.ttl", "--exclude-builtin-shape", "merchant"]
    )

    assert result.exit_code == 1
    assert isinstance(result.exception, UsageError)
    error_msg = str(result.exception)
    assert "Unknown excluded builtin shape value: merchant" in error_msg
    assert "google-merchant-listing.ttl" in error_msg
    assert "google-article.ttl" in error_msg


def test_audit_wraps_auditor_exception(monkeypatch) -> None:
    class _BrokenAuditor:
        def audit(self, path, options):
            raise RuntimeError("parse failed")

    class _Cls:
        def __call__(self):
            return _BrokenAuditor()

    monkeypatch.setattr(graph_cmd, "_GraphAuditor", _Cls())
    monkeypatch.setattr(graph_cmd, "_AuditOptions", lambda **kw: SimpleNamespace(**kw))

    result = CliRunner().invoke(graph_cmd.app, ["audit", "graph.ttl"])

    assert result.exit_code == 1
    assert isinstance(result.exception, UsageError)
    assert "parse failed" in str(result.exception)
