"""Tests for diff logic and scoring."""
import pytest
from scanner.differ import (
    compare_snapshots,
    diff_link_set,
    diff_metadata,
    diff_text,
    normalize_text,
    score_diff,
    text_hash,
)

BASE_CFG = {
    "scoring": {
        "new_external_domain": 50,
        "new_internal_url": 30,
        "missing_internal_url": 20,
        "hidden_content": 40,
        "unexpected_canonical": 35,
        "meta_refresh": 40,
        "unexpected_jsonld": 35,
        "large_text_addition": 20,
        "new_inline_script_suspicious": 30,
        "comment_links": 25,
        "noscript_links": 25,
        "missing_security_header": 5,
        "suspicious_content_pattern": 50,
    },
    "thresholds": {"yellow": 20, "red": 60, "large_text_block_chars": 200},
    "normalization": {"collapse_whitespace": True, "ignore_patterns": []},
}


class TestNormalizeText:
    def test_collapses_whitespace(self):
        result = normalize_text("Hello   World\n\nFoo", BASE_CFG)
        assert result == "Hello World Foo"

    def test_applies_ignore_patterns(self):
        cfg = dict(BASE_CFG)
        cfg["normalization"] = {
            "collapse_whitespace": True,
            "ignore_patterns": [r"\d{2}\.\d{2}\.\d{4}"],
        }
        result = normalize_text("Stand: 12.06.2026 Text", cfg)
        assert "[IGNORED]" in result
        assert "12.06.2026" not in result


class TestDiffText:
    def test_identical_texts_produce_empty_diff(self):
        assert diff_text("Hello", "Hello") == []

    def test_addition_shows_plus_line(self):
        lines = diff_text("Hello", "Hello\nWorld")
        assert any(l.startswith("+") and "World" in l for l in lines)

    def test_removal_shows_minus_line(self):
        lines = diff_text("Hello\nWorld", "Hello")
        assert any(l.startswith("-") and "World" in l for l in lines)


class TestDiffLinkSet:
    def test_detects_added_link(self):
        old = [{"url": "https://example.com/a"}]
        new = [{"url": "https://example.com/a"}, {"url": "https://example.com/b"}]
        result = diff_link_set(old, new)
        assert "https://example.com/b" in result["added"]
        assert result["removed"] == []

    def test_detects_removed_link(self):
        old = [{"url": "https://example.com/a"}, {"url": "https://example.com/b"}]
        new = [{"url": "https://example.com/a"}]
        result = diff_link_set(old, new)
        assert "https://example.com/b" in result["removed"]

    def test_no_change(self):
        links = [{"url": "https://example.com/a"}]
        result = diff_link_set(links, links)
        assert result == {"added": [], "removed": []}

    def test_truly_new_url_detected(self):
        # Cache-buster stripping happens at extraction time; diff_link_set is a
        # plain set comparison on already-normalised URLs.
        old = [{"url": "https://example.com/style.css"}]
        new = [{"url": "https://example.com/style.css"},
               {"url": "https://example.com/extra.js"}]
        result = diff_link_set(old, new)
        assert "https://example.com/extra.js" in result["added"]


class TestDiffMetadata:
    def test_detects_title_change(self):
        old = {"title": "Alte Seite", "h1": [], "h2": []}
        new = {"title": "Neue Seite", "h1": [], "h2": []}
        result = diff_metadata(old, new)
        assert "title" in result
        assert result["title"]["old"] == "Alte Seite"

    def test_no_change(self):
        meta = {"title": "Seite", "description": "Desc", "h1": [], "h2": []}
        assert diff_metadata(meta, meta) == {}

    def test_detects_canonical_change(self):
        old = {"canonical": "https://bredelar.info/", "h1": [], "h2": []}
        new = {"canonical": "https://spam.example.com/", "h1": [], "h2": []}
        result = diff_metadata(old, new)
        assert "canonical" in result


class TestCompareSnapshots:
    def _make_page(self, url, text="Inhalt", links=None, hidden=None):
        return {
            "url": url,
            "text": text,
            "links": links or {"a": [], "script": [], "iframe": [], "link_rel": [], "form": [], "meta_refresh": [], "canonical": None},
            "metadata": {"title": "T", "h1": [], "h2": []},
            "hidden_content": hidden or [],
            "inline_scripts": [],
            "jsonld": [],
            "comment_links": [],
        }

    def test_new_url_detected(self):
        baseline = {"https://b.info/": self._make_page("https://b.info/")}
        current = {
            "https://b.info/": self._make_page("https://b.info/"),
            "https://b.info/new/": self._make_page("https://b.info/new/"),
        }
        result = compare_snapshots(baseline, current, BASE_CFG)
        assert "https://b.info/new/" in result["new_internal_urls"]

    def test_missing_url_detected(self):
        baseline = {
            "https://b.info/": self._make_page("https://b.info/"),
            "https://b.info/old/": self._make_page("https://b.info/old/"),
        }
        current = {"https://b.info/": self._make_page("https://b.info/")}
        result = compare_snapshots(baseline, current, BASE_CFG)
        assert "https://b.info/old/" in result["missing_internal_urls"]

    def test_unchanged_page_not_in_diffs(self):
        page = self._make_page("https://b.info/", "Gleicher Inhalt")
        result = compare_snapshots({"https://b.info/": page}, {"https://b.info/": page}, BASE_CFG)
        assert result["changed_pages"] == 0

    def test_text_change_detected(self):
        old = self._make_page("https://b.info/", "Alter Text")
        new = self._make_page("https://b.info/", "Alter Text plus neuer Spam-Block im SEO-Kontext")
        result = compare_snapshots({"https://b.info/": old}, {"https://b.info/": new}, BASE_CFG)
        assert result["changed_pages"] == 1

    def test_new_external_domain_detected(self):
        def _page_with_ext(url, ext_url):
            p = self._make_page(url)
            p["links"]["a"] = [{"url": ext_url, "class": "external", "text": "x", "rel": ""}]
            return p

        baseline = {"https://b.info/": self._make_page("https://b.info/")}
        current = {"https://b.info/": _page_with_ext("https://b.info/", "https://spam.example.com/")}
        result = compare_snapshots(baseline, current, BASE_CFG)
        assert "spam.example.com" in result["new_external_domains"]


class TestUnexpectedJsonLd:
    CFG = {**BASE_CFG, "allowed_jsonld_types": ["Organization", "WebPage"]}

    def _page(self, url, jsonld):
        return {
            "url": url, "status": 200, "text": "Inhalt",
            "links": {"a": [], "script": [], "iframe": [], "link_rel": [], "form": [], "meta_refresh": [], "canonical": None},
            "metadata": {"title": "T", "h1": [], "h2": []},
            "hidden_content": [], "inline_scripts": [], "jsonld": jsonld, "comment_links": [],
        }

    def test_unexpected_type_flagged_without_page_diff(self):
        # Page is otherwise unchanged (same text/links) but carries a bad @type.
        base = {"https://b.info/": self._page("https://b.info/", [{"type": "Organization"}])}
        cur = {"https://b.info/": self._page("https://b.info/", [{"type": "Pharmacy"}])}
        result = compare_snapshots(base, cur, self.CFG)
        types = [e["type"] for e in result["unexpected_jsonld"]]
        assert "Pharmacy" in types

    def test_allowed_type_not_flagged(self):
        base = {"https://b.info/": self._page("https://b.info/", [])}
        cur = {"https://b.info/": self._page("https://b.info/", [{"type": "WebPage"}])}
        result = compare_snapshots(base, cur, self.CFG)
        assert result["unexpected_jsonld"] == []

    def test_preexisting_unexpected_type_not_realerted(self):
        # Bad type already in baseline → approved → should not re-alert.
        base = {"https://b.info/": self._page("https://b.info/", [{"type": "Pharmacy"}])}
        cur = {"https://b.info/": self._page("https://b.info/", [{"type": "Pharmacy"}])}
        result = compare_snapshots(base, cur, self.CFG)
        assert result["unexpected_jsonld"] == []

    def test_unexpected_type_scores(self):
        result = score_diff({
            "new_internal_urls": [], "missing_internal_urls": [],
            "new_external_domains": [], "page_diffs": [], "new_pages_analysis": [],
            "unexpected_jsonld": [{"url": "https://b.info/", "type": "Pharmacy"}],
        }, BASE_CFG)
        assert result["score"] == 35
        assert result["level"] == "yellow"


class TestSuspiciousFilenames:
    def test_suspicious_filename_scores_red(self):
        result = score_diff({
            "new_internal_urls": [], "missing_internal_urls": [],
            "new_external_domains": [], "page_diffs": [], "new_pages_analysis": [],
            "suspicious_filenames": [{"page": "https://b.info/", "url": "https://b.info/shell.php"}],
        }, BASE_CFG)
        assert result["score"] == 60
        assert result["level"] == "red"


class TestBrokenLinks:
    def _make_page(self, url, text="Inhalt", status=200):
        return {
            "url": url, "status": status, "text": text,
            "links": {"a": [], "script": [], "iframe": [], "link_rel": [], "form": [], "meta_refresh": [], "canonical": None},
            "metadata": {"title": "T", "h1": [], "h2": []},
            "hidden_content": [], "inline_scripts": [], "jsonld": [], "comment_links": [],
        }

    def test_new_broken_url_detected(self):
        baseline = {"https://b.info/": self._make_page("https://b.info/")}
        current = {
            "https://b.info/": self._make_page("https://b.info/"),
            "https://b.info/missing/": self._make_page("https://b.info/missing/", status=404),
        }
        result = compare_snapshots(baseline, current, BASE_CFG)
        assert len(result["new_broken_urls"]) == 1
        assert result["new_broken_urls"][0]["url"] == "https://b.info/missing/"
        assert result["new_broken_urls"][0]["status"] == 404
        assert "pages" in result["new_broken_urls"][0]

    def test_broken_url_includes_source_pages(self):
        """broken entry lists the pages that link to the broken URL."""
        home = self._make_page("https://b.info/")
        home["links"]["a"] = [{"url": "https://b.info/missing/", "class": "internal"}]
        baseline = {"https://b.info/": self._make_page("https://b.info/")}
        current = {
            "https://b.info/": home,
            "https://b.info/missing/": self._make_page("https://b.info/missing/", status=404),
        }
        result = compare_snapshots(baseline, current, BASE_CFG)
        entry = result["new_broken_urls"][0]
        assert "https://b.info/" in entry["pages"]

    def test_known_broken_not_in_new(self):
        broken_page = {"https://b.info/old/": self._make_page("https://b.info/old/", status=404)}
        baseline = {**{"https://b.info/": self._make_page("https://b.info/")}, **broken_page}
        current = {**{"https://b.info/": self._make_page("https://b.info/")}, **broken_page}
        result = compare_snapshots(baseline, current, BASE_CFG)
        assert result["new_broken_urls"] == []
        assert len(result["known_broken_urls"]) == 1
        assert "pages" in result["known_broken_urls"][0]

    def test_broken_url_scores(self):
        result = score_diff({
            "new_internal_urls": [], "missing_internal_urls": [],
            "new_external_domains": [], "page_diffs": [], "new_pages_analysis": [],
            "new_broken_urls": [{"url": "https://b.info/gone/", "status": 404}],
            "known_broken_urls": [],
        }, BASE_CFG)
        assert result["score"] == 20
        assert result["level"] == "yellow"

    def test_known_broken_not_scored(self):
        result = score_diff({
            "new_internal_urls": [], "missing_internal_urls": [],
            "new_external_domains": [], "page_diffs": [], "new_pages_analysis": [],
            "new_broken_urls": [],
            "known_broken_urls": [{"url": "https://b.info/old/", "status": 404}],
        }, BASE_CFG)
        assert result["score"] == 0
        assert result["level"] == "green"


class TestExcludePaths:
    def _page(self, url, status=200):
        return {
            "url": url, "status": status, "text": "x",
            "links": {"a": [], "script": [], "iframe": [], "link_rel": [], "form": [],
                      "meta_refresh": [], "canonical": None},
            "metadata": {"title": "T", "h1": [], "h2": []},
            "hidden_content": [], "inline_scripts": [], "jsonld": [], "comment_links": [],
        }

    def _cfg(self, exclude):
        return {**BASE_CFG, "crawl": {"exclude_paths": exclude}}

    def test_new_url_under_excluded_path_not_reported(self):
        baseline = {"https://b.info/": self._page("https://b.info/")}
        current = {
            "https://b.info/": self._page("https://b.info/"),
            "https://b.info/rss/feed.xml": self._page("https://b.info/rss/feed.xml"),
        }
        result = compare_snapshots(baseline, current, self._cfg(["/rss/"]))
        assert "https://b.info/rss/feed.xml" not in result["new_internal_urls"]

    def test_missing_url_under_excluded_path_not_reported(self):
        baseline = {
            "https://b.info/": self._page("https://b.info/"),
            "https://b.info/rss/feed.xml": self._page("https://b.info/rss/feed.xml"),
        }
        current = {"https://b.info/": self._page("https://b.info/")}
        result = compare_snapshots(baseline, current, self._cfg(["/rss/"]))
        assert "https://b.info/rss/feed.xml" not in result["missing_internal_urls"]

    def test_non_excluded_path_still_reported(self):
        baseline = {"https://b.info/": self._page("https://b.info/")}
        current = {
            "https://b.info/": self._page("https://b.info/"),
            "https://b.info/news/artikel": self._page("https://b.info/news/artikel"),
        }
        result = compare_snapshots(baseline, current, self._cfg(["/rss/"]))
        assert "https://b.info/news/artikel" in result["new_internal_urls"]


class TestScoreDiff:
    def test_green_for_no_changes(self):
        result = score_diff({
            "new_internal_urls": [],
            "missing_internal_urls": [],
            "new_external_domains": [],
            "page_diffs": [],
            "new_pages_analysis": [],
        }, BASE_CFG)
        assert result["level"] == "green"
        assert result["exit_code"] == 0

    def test_yellow_for_new_url(self):
        result = score_diff({
            "new_internal_urls": ["https://b.info/new/"],
            "missing_internal_urls": [],
            "new_external_domains": [],
            "page_diffs": [],
            "new_pages_analysis": [],
        }, BASE_CFG)
        assert result["level"] == "yellow"
        assert result["exit_code"] == 1

    def test_yellow_for_single_new_external_domain(self):
        # One new external domain = 50 pts → yellow (red threshold is 60)
        result = score_diff({
            "new_internal_urls": [],
            "missing_internal_urls": [],
            "new_external_domains": ["pharmacy.example.com"],
            "page_diffs": [],
            "new_pages_analysis": [],
        }, BASE_CFG)
        assert result["level"] == "yellow"
        assert result["exit_code"] == 1

    def test_red_for_new_external_domain_plus_hidden_content(self):
        # External domain (50) + hidden content (40) = 90 pts → red
        result = score_diff({
            "new_internal_urls": [],
            "missing_internal_urls": [],
            "new_external_domains": ["pharmacy.example.com"],
            "page_diffs": [{
                "url": "https://b.info/",
                "text_diff": [],
                "added_chars": 0,
                "link_diff": {},
                "meta_diff": {},
                "new_hidden_content": [{"type": "inline_style_hidden", "indicators": ["display:none"]}],
                "new_inline_scripts": [],
                "new_jsonld": [],
                "new_meta_refresh": [],
                "canonical_changed": False,
                "new_comment_links": [],
                "old_canonical": None,
                "new_canonical": None,
            }],
            "new_pages_analysis": [],
        }, BASE_CFG)
        assert result["level"] == "red"
        assert result["exit_code"] == 2

    def test_yellow_for_hidden_content_alone(self):
        # One hidden content finding = 40 pts → yellow (below red threshold of 60)
        result = score_diff({
            "new_internal_urls": [],
            "missing_internal_urls": [],
            "new_external_domains": [],
            "page_diffs": [{
                "url": "https://b.info/",
                "text_diff": [],
                "added_chars": 0,
                "link_diff": {},
                "meta_diff": {},
                "new_hidden_content": [{"type": "inline_style_hidden", "indicators": ["display:none"]}],
                "new_inline_scripts": [],
                "new_jsonld": [],
                "new_meta_refresh": [],
                "canonical_changed": False,
                "new_comment_links": [],
                "old_canonical": None,
                "new_canonical": None,
            }],
            "new_pages_analysis": [],
        }, BASE_CFG)
        assert result["level"] == "yellow"

    def test_reasons_include_domain(self):
        result = score_diff({
            "new_internal_urls": [],
            "missing_internal_urls": [],
            "new_external_domains": ["spam.com"],
            "page_diffs": [],
            "new_pages_analysis": [],
        }, BASE_CFG)
        assert any("spam.com" in r for r in result["reasons"])
