"""
Banner text legal checks — extracted from consent_scanner.py.

11 checks for cookie banner legal compliance:
1. Impressum link accessible (§5 TMG)
2. DSE link in banner (Art. 13 DSGVO)
3. Wrong DSE consent wording (Art. 13 DSGVO)
4. Reject button visible (§25 TDDDG)
5. Pre-ticked checkboxes (Planet49)
6. Dark pattern button size (EDPB 05/2020)
7. Cookie wall (Phase B check)
8. Re-access to settings (Art. 7(3) DSGVO)
9. Third-party DSE link (Art. 13 DSGVO)
10. Dark-pattern language (EDPB 05/2020)
11. Non-modal dismiss = consent (Planet49)
"""

import logging

from services.script_analyzer import Violation
from services.banner_advanced_checks import run_advanced_checks
from services.banner_dom_walkers import (
    SHADOW_BANNER_WALKER_JS,
    FOOTER_LABELS_WALKER_JS,
)

logger = logging.getLogger(__name__)


# ── Cookieless Opt-out-Erkennung (sehr untypischer Sonderfall) ──────────
# Manche Sites nutzen cookie-FREIE Analyse mit einem reinen Opt-out-Hinweis
# statt eines Consent-Banners (z.B. bayshore.ai: "Privacy-friendly, cookie-free
# analytics are currently enabled. You can change your choice at any time."
# + ein einziger Button "Disable"). Cookieless = kein Zugriff aufs Endgeraet →
# §25 Abs.1 TDDDG/ePrivacy verlangt KEINE Einwilligung → Opt-out (default an,
# abschaltbar) ist zulaessig statt Opt-in. Die Standard-Opt-in-Checks (granulare
# Kategorien, Accept/Reject-Balance, Impressum-im-Banner, Dark-Pattern-Buttons)
# treffen hier NICHT zu → sonst False Positives.
_CL_SIGNAL = (
    "cookie-free", "cookieless", "cookie free", "without cookies", "no cookies",
    "ohne cookies", "cookielos", "privacy-friendly analytics",
    "privacy friendly analytics", "cookie-freie analyse", "cookiefreie analyse",
    "datenschutzfreundliche analyse",
)
_CL_OPTOUT = (
    "disable", "deaktivieren", "opt-out", "opt out", "abschalten", "ausschalten",
    "currently enabled", "widersprechen", "change your choice",
)
# Wenn ein echtes Consent-Banner-Signal da ist, ist es KEIN reiner Opt-out-Hinweis.
_CL_STD_CONSENT = (
    "alle akzeptieren", "accept all", "alle annehmen", "accept cookies",
    "cookies akzeptieren", "zustimmen", "einwilligen",
)


def is_cookieless_optout(banner_lower: str) -> bool:
    """True bei cookie-freier Analyse mit reinem Opt-out-Hinweis (kein
    Consent-Banner). Pure + testbar. Standard-Opt-in-Checks treffen dann nicht zu."""
    return (
        any(s in banner_lower for s in _CL_SIGNAL)
        and any(o in banner_lower for o in _CL_OPTOUT)
        and not any(c in banner_lower for c in _CL_STD_CONSENT)
    )


def _cookieless_finding(has_dse: bool) -> Violation:
    """Vollstaendiger Erklaer-Befund (LOW) fuer den cookieless-Opt-out-Fall —
    bewusst ausfuehrlich, weil der Fall extrem untypisch ist und ein DSB/Nutzer
    die Einordnung sonst nicht versteht."""
    return Violation(
        service="Cookie-Banner",
        severity="LOW",
        text=(
            "Cookieless Analytics im Opt-out-Modell erkannt — KEIN klassisches "
            "Consent-Banner. Der Hinweis bezieht sich auf datenschutzfreundliche, "
            "cookie-freie Analyse: sie arbeitet OHNE Cookies und OHNE persistente "
            "Kennung (typisch ein taeglich zurueckgesetzter Hash aus IP+Browser), "
            "speichert keine personenbezogenen Daten und verfolgt nicht ueber "
            "Sitzungen oder Geraete hinweg. Weil dabei NICHT auf das Endgeraet "
            "zugegriffen wird, greift die Einwilligungspflicht nach §25 Abs.1 TDDDG "
            "(ePrivacy) NICHT — deshalb ist Opt-out (standardmaessig aktiv, jederzeit "
            "ueber 'Disable' abschaltbar) zulaessig statt Opt-in. Die ueblichen "
            "Opt-in-Pruefungen (granulare Kategorien, Accept/Reject-Gleichwertigkeit, "
            "Impressum-Link im Banner, Dark-Pattern-Buttons) sind hier NICHT anwendbar "
            "und werden bewusst ausgesetzt, um Falschbefunde zu vermeiden. Bewertung: "
            "wahrscheinlich konform und sogar datenschutzfreundlicher als ein Standard-"
            "Cookie-Banner. Zu pruefen bleibt nur: funktioniert der Opt-out wirklich "
            "(Analyse stoppt nach 'Disable') und ist eine Datenschutzerklaerung verlinkt"
            + ("." if has_dse else " — ein DSE-Link fehlt im Hinweis.")
        ),
        legal_ref="§25 Abs. 1 TDDDG (cookieless ohne Endgeraete-Zugriff = keine "
                  "Einwilligungspflicht), ErwGr. 30 DSGVO, EDPB Guidelines 05/2020 "
                  "(Anwendungsbereich der Einwilligung)",
    )


# ── #1/#2: Kein Banner erkannt → affirmativ einordnen (nicht still) ─────
def build_no_banner_finding(has_dse: bool) -> Violation:
    """Wenn KEIN Banner erkannt wurde UND vor Consent kein einwilligungs-
    pflichtiges Tracking lief: kein Banner noetig (statische Seite / nur
    technisch notwendige Cookies, §25 Abs.2 TDDDG) → konform. Inkl. Geo-Caveat.
    Nur aufrufen, wenn before_violations UND before_tracking leer sind."""
    return Violation(
        service="Cookie-Banner",
        severity="LOW",
        text=(
            "Kein Consent-Banner erkannt — und es ist auch keiner erforderlich: "
            "Vor einer Einwilligung wurde kein einwilligungspflichtiges Tracking "
            "geladen (keine Tracking-Skripte/-Pixel). Typisch fuer eine statische "
            "Seite oder eine Seite mit ausschliesslich technisch notwendigen Cookies "
            "(Session, CSRF, Login, Warenkorb, Sprache) — die nach §25 Abs.2 TDDDG / "
            "Art.5(3) ePrivacy KEINE Einwilligung brauchen. Bewertung: konform. Es "
            "bleibt nur die Informationspflicht — eine Datenschutzerklaerung muss "
            "erreichbar sein"
            + ("." if has_dse else " (DSE-Link wurde hier nicht gefunden — bitte pruefen).")
            + " METHODEN-HINWEIS: Der Scan nutzt ein frisches Browser-Profil — ein "
            "fehlendes Banner liegt also NICHT an einer bereits erteilten Einwilligung. "
            "Falls die Seite jedoch Geo-Targeting nutzt, sieht ein Scan ausserhalb der EU "
            "evtl. keinen Banner — den Scanner-Standort (EU-IP) bei diesem Befund mitdenken."
        ),
        legal_ref="§25 Abs. 2 TDDDG (Ausnahme technisch notwendig), Art. 5(3) ePrivacy, "
                  "EDPB Guidelines 2/2023",
    )


# ── #3: Non-Cookie-Tracking (Pixel/Fingerprinting) — §25 gilt auch ohne Cookies ──
# Domain-Signaturen, die auf Script-URLs zuverlaessig matchen.
_NON_COOKIE_TRACKERS = {
    "Meta-Pixel (Facebook)": ("connect.facebook.net", "facebook.com/tr"),
    "TikTok-Pixel": ("analytics.tiktok.com",),
    "LinkedIn Insight Tag": ("snap.licdn.com",),
    "Pinterest-Tag": ("s.pinimg.com/ct",),
    "Microsoft Clarity": ("clarity.ms",),
    "Fingerprinting (FingerprintJS)": ("fingerprintjs", "fpjs.io", "fpcdn.io"),
    "Hotjar": ("static.hotjar.com",),
    "Reddit-Pixel": ("redditstatic.com/ads",),
    "Snapchat-Pixel": ("sc-static.net", "tr.snapchat.com"),
}


def detect_non_cookie_tracking(scripts: list) -> list:
    """Erkennt cookieloses/script-basiertes Tracking (Pixel/Fingerprinting) in
    den geladenen Skript-URLs. Pure + testbar. §25 Abs.1 TDDDG/Art.5(3) ist
    technologieneutral → gilt auch ohne Cookies."""
    blob = " ".join(s.lower() for s in (scripts or []) if s)
    return [name for name, sigs in _NON_COOKIE_TRACKERS.items()
            if any(sig in blob for sig in sigs)]


def build_non_cookie_tracking_finding(detected: list) -> Violation:
    return Violation(
        service="Cookie-Banner",
        severity="MEDIUM",
        text=(
            "Non-Cookie-Tracking erkannt: " + ", ".join(detected) + ". "
            "§25 Abs.1 TDDDG / Art.5(3) ePrivacy ist technologieneutral — die "
            "Einwilligungspflicht gilt AUCH ohne Cookies (Pixel, Web-Beacons, "
            "Fingerprinting). Ein reiner Cookie-Check uebersieht das: '0 Cookies' "
            "heisst NICHT 'kein einwilligungspflichtiges Tracking'. Diese Techniken "
            "vor jeder Einwilligung pruefen. (Hinweis: Google Consent Mode v2 ist "
            "KEINE gueltige Einwilligung, sondern sendet nur modellierte Signale.)"
        ),
        legal_ref="§25 Abs.1 TDDDG, Art.5(3) ePrivacy, EDPB Guidelines 2/2023 "
                  "(Pixel/Fingerprinting/URL-Tracking einwilligungspflichtig)",
    )


# ── #4: "Consent or Pay" / "Pay or OK" (EDPB Opinion 08/2024, umstritten) ──
_PAY_SIGNALS = (
    "pur-abo", "pur abo", "consent or pay", "pay or consent", "accept or subscribe",
    "zustimmen oder abonnieren", "zustimmen oder bezahlen", "kostenpflichtiges abo",
    "werbefreies abo", "ohne werbung weiterlesen", "mit abo werbefrei",
    "subscribe to continue", "jetzt abonnieren", "pro monat", "/monat", "im monat",
)
_PAY_CONTEXT = ("cookie", "werbung", "tracking", "einwillig", "consent", "zustimm",
                "akzeptier", "advert")


def detect_consent_or_pay(banner_lower: str) -> bool:
    """Consent-or-Pay-Modell im Banner (Einwilligung ODER Bezahl-Abo). Pure + testbar."""
    return (any(p in banner_lower for p in _PAY_SIGNALS)
            and any(c in banner_lower for c in _PAY_CONTEXT))


def build_consent_or_pay_finding() -> Violation:
    return Violation(
        service="Cookie-Banner", severity="MEDIUM",
        text=("'Consent or Pay'-Modell erkannt (Einwilligung ODER Bezahl-Abo statt "
              "echter Wahl). Rechtlich stark umstritten: Die EDPB-Opinion 08/2024 haelt "
              "es bei grossen Plattformen meist fuer NICHT DSGVO-konform, weil die "
              "Einwilligung dann nicht 'freiwillig' i.S.v. Art. 4(11)/7(4) DSGVO ist "
              "(Koppelung). Gesondert juristisch pruefen — kein Standard-Banner."),
        legal_ref="EDPB Opinion 08/2024 (Consent or Pay), Art. 7(4) + 4(11) DSGVO (Freiwilligkeit)",
    )


# JS: Google Consent Mode (v2) erkennen — dataLayer-consent-Events / inline gtag('consent').
CONSENT_MODE_JS = """
() => {
  try {
    const dl = window.dataLayer || [];
    const ev = dl.some(e => Array.isArray(e) && e[0] === 'consent');
    const inline = [...document.scripts].some(
      s => !s.src && /gtag\\(\\s*['"]consent['"]/.test(s.textContent || ''));
    return ev || inline;
  } catch (e) { return false; }
}
"""


def build_consent_mode_finding() -> Violation:
    return Violation(
        service="Cookie-Banner", severity="MEDIUM",
        text=("Google Consent Mode (v2) erkannt. WICHTIG: Consent Mode ist KEINE "
              "gueltige Einwilligung — bei Ablehnung sendet es nur modellierte, "
              "cookielose Signale an Google. Es ersetzt NICHT die Einwilligung nach "
              "Art. 5(3) ePrivacy/§25 TDDDG. Pruefen: werden Tags wirklich erst NACH "
              "echter Einwilligung gesetzt (Consent Mode allein genuegt nicht)."),
        legal_ref="Art. 5(3) ePrivacy, §25 Abs.1 TDDDG, EDPB Guidelines 2/2023",
    )


# ── #6: CNAME-Cloaking — First-Party-Subdomain zeigt per DNS auf Tracker-Infra ──
_CNAME_CLOAK_TARGETS = (
    "eulerian.net", "wt-eu02.net", "webtrekk.net", "mapp.com", "2o7.net",
    "omtrdc.net", "adobedc.net", "demdex.net", "everesttech.net", "krxd.net",
    "agkn.com", "pardot.com", "act-on.net", "tagcommander.com", "commander1.com",
    "acxiom-online.com", "keros.io",
)


def detect_cname_cloaking(scripts: list, site_domain: str) -> list:
    """First-Party-aussehende Subdomains, deren DNS-CNAME auf bekannte Tracker-Infra
    zeigt (umgeht Safari ITP — faktisch Drittanbieter-Tracking). socket statt
    dnspython. Best-effort: kurze Timeouts, max 8 Lookups."""
    import socket
    from urllib.parse import urlparse
    site_domain = (site_domain or "").lower().lstrip(".")
    if not site_domain:
        return []
    hosts = set()
    for s in (scripts or []):
        try:
            h = (urlparse(s).hostname or "").lower()
        except Exception:
            continue
        if h and h.endswith("." + site_domain) and h != site_domain:
            hosts.add(h)
    found = []
    old_to = socket.getdefaulttimeout()
    socket.setdefaulttimeout(1.5)
    try:
        for h in list(hosts)[:8]:
            try:
                _n, aliases, _ip = socket.gethostbyname_ex(h)
            except Exception:
                continue
            chain = " ".join(aliases).lower()
            for t in _CNAME_CLOAK_TARGETS:
                if t in chain:
                    found.append((h, t))
                    break
    finally:
        socket.setdefaulttimeout(old_to)
    return found


def build_cname_cloaking_finding(found: list) -> Violation:
    pairs = ", ".join(f"{h} -> {t}" for h, t in found[:4])
    return Violation(
        service="Cookie-Banner", severity="HIGH",
        text=("CNAME-Cloaking erkannt: " + pairs + ". Eine First-Party-aussehende "
              "Subdomain zeigt per DNS-CNAME auf die Infrastruktur eines Tracking-"
              "Anbieters. Der Browser behandelt deren Cookies als First-Party (umgeht "
              "Safari ITP / Cookie-Blocker) — faktisch ist es Drittanbieter-Tracking und "
              "muss als solches behandelt werden: Einwilligung + Drittland/Auftrags-"
              "verarbeitung pruefen. 'First-Party' ist hier eine technische Tarnung."),
        legal_ref="§25 TDDDG, Art. 5(3) ePrivacy, Art. 13/28/44 DSGVO",
    )


async def _footer_link_reachable(page, selector: str) -> bool:
    """True, wenn ein passender Link (Impressum/DSE im Footer) trotz offenem
    Banner anklickbar ist — das Banner ueberlagert, blockiert die Seite aber
    nicht. Playwright-trial-Klick = Aktionsbarkeits-Pruefung OHNE echten Klick;
    schlaegt fehl, wenn ein blockierendes Overlay den Link abfaengt."""
    try:
        loc = page.locator(selector).first
        if await loc.count() == 0:
            return False
        await loc.scroll_into_view_if_needed(timeout=1500)
        await loc.click(trial=True, timeout=1500)
        return True
    except Exception:
        return False


async def check_banner_text(page) -> dict:
    """Check cookie banner text for legal issues.

    1. Impressum link must be accessible even with banner overlay (§5 TMG)
    2. DSE link must be accessible from banner
    3. "Zustimmung zur Datenschutzerklärung" is WRONG — DSE is an information
       obligation (Art. 13 DSGVO), not something users "agree" to
    """
    violations = []
    has_impressum = False
    has_dse = False

    try:
        # Get banner text and links
        banner_text = ""
        banner_links = []

        # Try common banner container selectors
        for selector in [
            "#CybotCookiebotDialog", "#onetrust-banner-sdk", "#didomi-host",
            "#usercentrics-root", ".cky-consent-container", "#cmpbox",
            '[class*="cookie-banner"]', '[class*="consent-banner"]',
            '[class*="cookie-notice"]', '[role="dialog"]',
        ]:
            try:
                el = page.locator(selector).first
                if await el.count() > 0:
                    banner_text = (await el.text_content() or "").strip()
                    # Get links inside banner
                    links = await el.locator("a[href]").all()
                    for link in links:
                        href = await link.get_attribute("href") or ""
                        text = (await link.text_content() or "").strip()
                        banner_links.append({"href": href.lower(), "text": text.lower()})
                    if banner_text:
                        break
            except Exception:
                continue

        # P28a + P63: Shadow-DOM Web Component CMPs (Mercedes cmm-cookie-banner,
        # BMW cookie-consent-banner). Walker pierces shadow tree + extracts
        # label-based legal links (wb7-link/button/role=link). See
        # banner_dom_walkers.SHADOW_BANNER_WALKER_JS.
        if not banner_text or not banner_links:
            try:
                shadow_data = await page.evaluate(SHADOW_BANNER_WALKER_JS)
                if shadow_data and isinstance(shadow_data, dict):
                    if shadow_data.get("text"):
                        banner_text = (banner_text + " " + shadow_data["text"]).strip()
                    if shadow_data.get("links"):
                        banner_links.extend(shadow_data["links"])
            except Exception:
                pass

        if not banner_text:
            return {"violations": violations, "has_impressum": False, "has_dse": False}

        banner_lower = banner_text.lower()

        # Sonderfall cookieless Opt-out (sehr untypisch): erkennen, vollstaendig
        # erklaeren und die Opt-in-Checks aussetzen — statt Falsch-HIGHs zu erzeugen.
        if is_cookieless_optout(banner_lower):
            has_dse = any(
                "datenschutz" in l["href"] or "datenschutz" in l["text"]
                or "privacy" in l["href"] or "privacy" in l["text"]
                or "dsgvo" in l["href"]
                for l in banner_links
            )
            return {"violations": [_cookieless_finding(has_dse)],
                    "has_impressum": False, "has_dse": has_dse,
                    "cookieless_optout": True}

        # #4: Consent-or-Pay-Modell (zusaetzlicher Befund — bleibt ein Consent-Banner).
        if detect_consent_or_pay(banner_lower):
            violations.append(build_consent_or_pay_finding())

        # #5: Google Consent Mode (v2) — KEINE gueltige Einwilligung.
        try:
            if await page.evaluate(CONSENT_MODE_JS):
                violations.append(build_consent_mode_finding())
        except Exception:
            pass

        # Check 1: Impressum link in or accessible through banner
        has_impressum = any(
            "impressum" in l["href"] or "impressum" in l["text"] or
            "imprint" in l["href"] or "legal notice" in l["text"]
            for l in banner_links
        )
        # Also check if impressum is visible behind/around banner
        if not has_impressum:
            try:
                imp_visible = await page.locator('a[href*="impressum"], a[href*="imprint"]').first
                if await imp_visible.count() > 0 and await imp_visible.is_visible():
                    has_impressum = True
            except Exception:
                pass

        if not has_impressum:
            # Entscheidend: blockiert das Banner den Footer-Zugriff wirklich?
            # Wenn der Footer-Impressum-Link trotz offenem Banner anklickbar ist
            # (Banner ueberlagert, blockiert aber nicht), ist der fehlende
            # In-Banner-Link nur Best Practice (LOW), kein Verstoss.
            if await _footer_link_reachable(
                    page, 'a[href*="impressum"], a[href*="imprint"]'):
                violations.append(Violation(
                    service="Cookie-Banner", severity="LOW",
                    text="Impressum nicht direkt im Cookie-Banner verlinkt — es ist "
                         "jedoch ueber den Footer erreichbar (das Banner blockiert "
                         "die Seite nicht). Best Practice: Impressum- und DSE-Link "
                         "zusaetzlich direkt im Banner anbieten (Borlabs ermoeglicht "
                         "zudem eine sichtbare Einwilligungs-Historie).",
                    legal_ref="Best Practice (§5 TMG via Footer erfuellt)",
                ))
            else:
                violations.append(Violation(
                    service="Cookie-Banner", severity="HIGH",
                    text="Impressum nicht erreichbar: kein Link im Banner UND das "
                         "ueberlagernde Banner blockiert den Footer-Zugriff. Dann "
                         "muss ein Impressum-Link im Banner vorhanden sein (§5 TMG).",
                    legal_ref="§5 TMG, LG Rostock Az. 3 O 22/19",
                ))

        # Check 2: DSE link in banner
        has_dse = any(
            "datenschutz" in l["href"] or "datenschutz" in l["text"] or
            "privacy" in l["href"] or "privacy" in l["text"] or
            "dsgvo" in l["href"]
            for l in banner_links
        )
        if not has_dse:
            if await _footer_link_reachable(
                    page, 'a[href*="datenschutz"], a[href*="privacy"], a[href*="dsgvo"]'):
                violations.append(Violation(
                    service="Cookie-Banner", severity="LOW",
                    text="Datenschutzerklaerung nicht direkt im Banner verlinkt — sie "
                         "ist jedoch ueber den Footer erreichbar (Banner blockiert "
                         "nicht). Best Practice: DSE-Link direkt im Banner anbieten, "
                         "damit Nutzer vor der Einwilligung informiert entscheiden.",
                    legal_ref="Best Practice (Art. 13 DSGVO via Footer erfuellt)",
                ))
            else:
                violations.append(Violation(
                    service="Cookie-Banner", severity="MEDIUM",
                    text="Kein Link zur Datenschutzerklaerung im Cookie-Banner und das "
                         "Banner blockiert den Footer. Nutzer koennen vor der "
                         "Einwilligung die DSE nicht einsehen.",
                    legal_ref="Art. 13 DSGVO, ErwGr. 42 DSGVO (informierte Einwilligung)",
                ))

        # Check 3: Wrong wording — "Zustimmung zur Datenschutzerklärung"
        wrong_dse_consent_patterns = [
            "stimme der datenschutz",
            "stimme den datenschutz",
            "akzeptiere die datenschutz",
            "akzeptiere die privacy",
            "agree to the privacy policy",
            "accept the privacy",
            "datenschutzerklaerung zustimmen",
            "datenschutzrichtlinie akzeptieren",
            "datenschutzrichtlinie zustimmen",
            "i agree to the privacy",
            "i accept the privacy",
        ]
        for pattern in wrong_dse_consent_patterns:
            if pattern in banner_lower:
                violations.append(Violation(
                    service="Cookie-Banner",
                    severity="HIGH",
                    text=f"Falsche Formulierung im Banner: 'Zustimmung zur Datenschutzerklaerung'. "
                         f"Die DSE ist eine Informationspflicht (Art. 13 DSGVO) — man kann sie "
                         f"nur zur Kenntnis nehmen, nicht 'zustimmen'. "
                         f"Korrekt: 'Ich habe die Datenschutzinformationen zur Kenntnis genommen'.",
                    legal_ref="Art. 13 DSGVO, ErwGr. 42 (informierte Einwilligung ≠ Zustimmung zur DSE)",
                ))
                break

        # P28b Check 4: Reject mechanism present + explicit-labeled?
        #   HIGH   = no reject mechanism at all
        #   MEDIUM = reject available but not labeled "Ablehnen"/"Reject"
        #            (e.g. only "Nur technisch Notwendige" — semantically
        #            a reject but EDPB 5/2020 + DSK-OH 2024 prefer explicit
        #            labeling so users recognize it as the reject option)
        # P93: EDPB 5/2020 schreibt kein bestimmtes Wort vor — Reject-
        # Mechanismus muss gleichwertig zur Annahme sein. BMW nutzt
        # "Cookies verbieten", andere Sites "Tracking ablehnen" o.ae. —
        # alle rechtlich gleichwertig.
        explicit_reject_texts = [
            "ablehnen", "reject", "alle ablehnen",
            "decline", "alles ablehnen",
            "cookies verbieten", "cookies blockieren",
            "tracking ablehnen", "tracking verbieten",
            "zurueckweisen", "block all", "deny all",
            "alle verweigern", "verweigern",
        ]
        implicit_reject_texts = ["nur notwendige", "nur technisch", "nur essenzielle",
                                  "nur essentielle", "notwendige akzeptieren",
                                  "essential only", "only necessary",
                                  "nur erforderliche"]
        has_explicit_reject = any(t in banner_lower for t in explicit_reject_texts)
        has_implicit_reject = any(t in banner_lower for t in implicit_reject_texts)
        if not has_explicit_reject and not has_implicit_reject:
            violations.append(Violation(
                service="Cookie-Banner",
                severity="HIGH",
                text="Kein 'Ablehnen'-Mechanismus im Banner erkannt. "
                     "Die Ablehnung muss ebenso einfach sein wie die Zustimmung.",
                legal_ref="§25 Abs. 1 TDDDG, EDPB Guidelines 05/2020 (Consent)",
            ))
        elif not has_explicit_reject and has_implicit_reject:
            violations.append(Violation(
                service="Cookie-Banner",
                severity="MEDIUM",
                text="Reject-Moeglichkeit vorhanden ('Nur technisch Notwendige' o.ae.), "
                     "aber nicht als 'Ablehnen' beschriftet. Nutzer erkennen 'Ablehnen' "
                     "schneller als sprachlich umschriebene Varianten. "
                     "Empfehlung: zusaetzlich 'Ablehnen' als Button-Label.",
                legal_ref="EDPB 5/2020 (Consent) + DSK-OH 2024 (Telemedien)",
            ))

        # P100: Granular-Wahl-Pruefung — "Anpassen"/"Einstellungen"-Button
        # im Initial-Banner. Wenn er FEHLT (VW-Pattern), ist die granulare
        # Cookie-Wahl erst nach Akzeptanz/Ablehnung moeglich — faktische
        # Manipulation Richtung "Alle akzeptieren". EDPB 5/2020 §82.
        granular_button_texts = [
            "anpassen", "einstellungen", "cookie-einstellungen",
            "cookies verwalten", "manage cookies", "customize",
            "weitere optionen", "more options", "settings",
            "individuell", "detaillierte einstellungen",
            "praeferenzen", "preferences",
        ]
        has_granular_button = any(t in banner_lower for t in granular_button_texts)
        if not has_granular_button:
            violations.append(Violation(
                service="Cookie-Banner",
                severity="HIGH",
                text="Granulare Cookie-Auswahl im Initial-Banner nicht "
                     "moeglich (kein 'Anpassen'/'Einstellungen'-Button). "
                     "Nutzer koennen nur 'Alle akzeptieren' oder 'Nur "
                     "technisch notwendige' waehlen — Detailwahl pro "
                     "Kategorie erst nach Akzeptanz/Ablehnung. Das ist "
                     "faktische Manipulation Richtung Pauschal-Akzeptanz.",
                legal_ref="EDPB Guidelines 5/2020 §82 (granular consent), "
                          "§25 Abs. 1 TDDDG, Art. 4(11) DSGVO (informierte "
                          "Einwilligung)",
            ))

        # Check 5: Pre-ticked checkboxes (EuGH Planet49)
        try:
            pre_checked = await page.evaluate("""
                () => {
                    const banner = document.querySelector(
                        '#CybotCookiebotDialog, #onetrust-banner-sdk, #didomi-host, '
                        + '#usercentrics-root, .cky-consent-container, #cmpbox, '
                        + '[class*="cookie-banner"], [class*="consent-banner"], [role="dialog"]'
                    );
                    if (!banner) return [];
                    const checked = banner.querySelectorAll(
                        'input[type="checkbox"]:checked:not([disabled])'
                    );
                    return [...checked]
                        .filter(cb => {
                            const label = cb.closest('label')?.textContent || cb.getAttribute('aria-label') || '';
                            const isNecessary = /notwendig|necessary|essential|erforderlich/i.test(label);
                            return !isNecessary;
                        })
                        .map(cb => cb.closest('label')?.textContent?.trim() || cb.id || 'unknown');
                }
            """)
            if pre_checked:
                violations.append(Violation(
                    service="Cookie-Banner",
                    severity="HIGH",
                    text=f"Vorausgewaehlte Checkboxen im Banner: {', '.join(pre_checked[:3])}. "
                         f"Einwilligung muss durch aktive Handlung erfolgen — vorausgefuellte "
                         f"Checkboxen sind ungueltig.",
                    legal_ref="Art. 4(11) DSGVO, EuGH C-673/17 (Planet49)",
                ))
        except Exception:
            pass

        # Check 6: Dark Pattern — button size/prominence comparison
        try:
            button_info = await page.evaluate("""
                () => {
                    const banner = document.querySelector(
                        '#CybotCookiebotDialog, #onetrust-banner-sdk, #didomi-host, '
                        + '#usercentrics-root, .cky-consent-container, #cmpbox, '
                        + '[class*="cookie-banner"], [class*="consent-banner"], [role="dialog"]'
                    );
                    if (!banner) return null;
                    const buttons = [...banner.querySelectorAll('button, a[role="button"], [class*="btn"]')];
                    return buttons.slice(0, 6).map(b => {
                        const style = window.getComputedStyle(b);
                        const rect = b.getBoundingClientRect();
                        return {
                            text: b.textContent?.trim()?.substring(0, 40) || '',
                            width: rect.width,
                            height: rect.height,
                            area: rect.width * rect.height,
                            bgColor: style.backgroundColor,
                            fontSize: parseFloat(style.fontSize),
                            visible: rect.width > 0 && rect.height > 0,
                        };
                    });
                }
            """)
            if button_info and len(button_info) >= 2:
                accept_btn = None
                reject_btn = None
                accept_kw = ["akzeptieren", "accept", "zustimmen", "agree", "einverstanden", "ok"]
                reject_kw = ["ablehnen", "reject", "notwendige", "decline", "nein",
                              "technisch", "essenzielle", "essential", "erforderliche"]

                for btn in button_info:
                    text_lower = btn["text"].lower()
                    if any(k in text_lower for k in accept_kw):
                        accept_btn = btn
                    elif any(k in text_lower for k in reject_kw):
                        reject_btn = btn

                if accept_btn and reject_btn:
                    area_ratio = accept_btn["area"] / max(reject_btn["area"], 1)
                    if area_ratio > 2.5:
                        violations.append(Violation(
                            service="Cookie-Banner",
                            severity="MEDIUM",
                            text=f"Dark Pattern: 'Akzeptieren'-Button ist {area_ratio:.1f}x groesser als "
                                 f"'Ablehnen'-Button. Beide Optionen muessen gleichwertig dargestellt werden.",
                            legal_ref="EDPB Guidelines 05/2020, §25 TDDDG, DSK Orientierungshilfe Telemedien",
                        ))
                    size_ratio = accept_btn["fontSize"] / max(reject_btn["fontSize"], 1)
                    if size_ratio > 1.5:
                        violations.append(Violation(
                            service="Cookie-Banner",
                            severity="MEDIUM",
                            text=f"Dark Pattern: Schriftgroesse 'Akzeptieren' ({accept_btn['fontSize']:.0f}px) "
                                 f"vs. 'Ablehnen' ({reject_btn['fontSize']:.0f}px). "
                                 f"Unterschiedliche Schriftgroessen sind ein Dark Pattern.",
                            legal_ref="EDPB Guidelines 05/2020 (gleichwertige Darstellung)",
                        ))
        except Exception:
            pass

        # Check 7: Cookie Wall — does rejecting block the site?
        # (This is checked in Phase B — if after reject the page is not navigable)

        # P29 Check 8: Re-access to cookie settings (Art. 7(3) DSGVO).
        # Three quality tiers:
        #   OK     = persistent floating cookie icon OR explicit-labeled
        #            footer link ("Cookie-Einstellungen", "Cookie-Richtlinie",
        #            "Cookies verwalten", etc.)
        #   MEDIUM = re-access only via ambiguous label (e.g. "Einstellungen"
        #            alone — could mean theme/language) OR only via
        #            cookies.html doc link (not a settings dialog)
        #   HIGH   = no re-access mechanism found at all
        try:
            has_floating_icon = False
            floating_selectors = [
                ".cky-btn-revisit", "#ot-sdk-btn", "#ot-sdk-btn-floating",
                "[class*='ot-floating']", "[class*='cookie-floating']",
                "[id*='cookiebot-renew']", "[class*='cmp-floating']",
                "[id*='cmplz-cookiebanner-status']", ".uc-cookie-settings-trigger",
                "[class*='consent-floating']", "[data-testid*='cookie-revisit']",
            ]
            for sel in floating_selectors:
                try:
                    if await page.locator(sel).count() > 0:
                        has_floating_icon = True
                        break
                except Exception:
                    continue

            # Footer label inspection — distinguish explicit vs ambiguous
            # P64: OEM design-systems (Mercedes wb7-footer, BMW b-footer) don't
            # use <footer>. Scan via evaluate() with multiple candidate roots
            # including page-bottom region as last-ditch fallback.
            footer_labels: list[str] = []
            try:
                footer_labels = await page.evaluate(FOOTER_LABELS_WALKER_JS) or []
            except Exception:
                pass

            # Explicit, unambiguous cookie/consent labels
            explicit_patterns = [
                "cookie-einstellungen", "cookie einstellungen",
                "cookie-richtlinie", "cookie richtlinie",
                "cookie-praeferenzen", "cookie preferences",
                "cookies verwalten", "manage cookies",
                "datenschutz-einstellungen", "privacy preferences",
                "datenschutzeinstellungen", "datenschutz einstellungen",
                "cookie consent", "consent settings",
                "cookie-banner", "cookies anpassen",
                # P64: OEM-typical labels
                "tracking-einstellungen", "tracking einstellungen",
                "cookie-zustimmung", "consent verwalten",
            ]
            has_explicit_footer = any(
                any(p in lbl for p in explicit_patterns)
                for lbl in footer_labels
            )
            # Ambiguous labels — "Einstellungen" alone, generic "Cookies"
            ambiguous_patterns = ["einstellungen", "settings", "cookies"]
            has_ambiguous_footer = (not has_explicit_footer) and any(
                lbl.strip() in ambiguous_patterns
                or any(lbl.strip() == p for p in ambiguous_patterns)
                for lbl in footer_labels
            )

            if has_floating_icon or has_explicit_footer:
                pass  # OK — no violation
            elif has_ambiguous_footer:
                violations.append(Violation(
                    service="Cookie-Banner",
                    severity="MEDIUM",
                    text="Re-Zugang zu Cookie-Einstellungen nur ueber mehrdeutiges "
                         "Footer-Label (z.B. 'Einstellungen' oder 'Cookies'). "
                         "Empfehlung: persistenten Cookie-Icon-Button (Floating) "
                         "oder explizites Footer-Label 'Cookie-Einstellungen', "
                         "'Cookie-Richtlinie' o.ae. damit Nutzer den Widerruf "
                         "ohne Suchen finden.",
                    legal_ref="Art. 7 Abs. 3 DSGVO + EDPB 5/2020 (informierte, "
                              "leicht widerrufbare Einwilligung)",
                ))
            else:
                violations.append(Violation(
                    service="Cookie-Banner",
                    severity="HIGH",
                    text="Kein erneuter Zugang zu Cookie-Einstellungen gefunden "
                         "(weder Floating-Icon noch Footer-Link). Widerruf muss "
                         "ebenso einfach sein wie Erteilung.",
                    legal_ref="Art. 7 Abs. 3 DSGVO (Widerruf so einfach wie Einwilligung)",
                ))
        except Exception:
            pass

        # Check 9: Third-party DSE link — consent links to external domain DSE
        try:
            page_domain = page.url.split("/")[2].replace("www.", "")
            for link in banner_links:
                href = link["href"]
                if not href.startswith("http"):
                    continue
                link_domain = href.split("/")[2].replace("www.", "") if len(href.split("/")) > 2 else ""
                if not link_domain:
                    continue
                is_dse_link = any(kw in link["text"] for kw in [
                    "datenschutz", "privacy", "dsgvo", "data protection",
                ])
                if is_dse_link and link_domain != page_domain:
                    violations.append(Violation(
                        service="Cookie-Banner",
                        severity="HIGH",
                        text=f"Consent verweist auf Datenschutzerklaerung von {link_domain} "
                             f"statt auf eigene DSE. Der Verantwortliche muss eine eigene "
                             f"Datenschutzerklaerung bereitstellen (Art. 13 DSGVO). "
                             f"Ein Verweis auf die DSE eines Drittanbieters/Auftragsverarbeiters "
                             f"reicht nicht aus.",
                        legal_ref="Art. 13 DSGVO (Informationspflichten), Art. 26 DSGVO (gemeinsame Verantwortlichkeit)",
                    ))
                    break
        except Exception:
            pass

        # Check 10: Dark-Pattern language — "muessen/erforderlich" for non-essential
        dark_pattern_phrases = [
            ("muessen heruntergeladen werden", "heruntergeladen"),
            ("muessen akzeptiert werden", "akzeptiert"),
            ("muessen gesetzt werden", "gesetzt"),
            ("cookies sind erforderlich", "erforderlich"),
            ("cookies are required", "required"),
            ("must be downloaded", "downloaded"),
            ("must be accepted", "accepted"),
            ("sind zwingend notwendig", "zwingend"),
            ("unbedingt erforderlich", "unbedingt"),
        ]
        for phrase, keyword in dark_pattern_phrases:
            if phrase in banner_lower:
                # Check if context is about non-essential cookies
                context_essential = any(kw in banner_lower for kw in [
                    "technisch notwendig", "essential", "strictly necessary",
                    "unbedingt erforderlich fuer den betrieb",
                ])
                if not context_essential:
                    violations.append(Violation(
                        service="Cookie-Banner",
                        severity="MEDIUM",
                        text=f"Dark-Pattern-Sprache: '{phrase}' suggeriert technische "
                             f"Notwendigkeit fuer nicht-essentielle Cookies. Nutzer koennten "
                             f"den Eindruck gewinnen, eine Zustimmung sei alternativlos.",
                        legal_ref="EDPB Guidelines 05/2020 Rn. 70, Art. 7(4) DSGVO (freiwillige Einwilligung)",
                    ))
                    break

        # Check 11: Modal dismiss = consent (click outside closes + sets consent)
        try:
            dismiss_is_consent = await page.evaluate("""
                () => {
                    const dialog = document.querySelector(
                        '#CybotCookiebotDialog, #onetrust-banner-sdk, #didomi-host, '
                        + '#usercentrics-root, .cky-consent-container, #cmpbox, '
                        + '[class*="cookie-banner"], [class*="consent-banner"], [role="dialog"]'
                    );
                    if (!dialog) return { hasOverlay: false, overlayCloses: false };
                    // Check for overlay/backdrop elements
                    const overlays = document.querySelectorAll(
                        '.overlay, .backdrop, .modal-backdrop, '
                        + '[class*="overlay"], [class*="backdrop"], '
                        + '[class*="dimmer"], .cdk-overlay-backdrop'
                    );
                    let overlayHasClick = false;
                    for (const ov of overlays) {
                        const listeners = getEventListeners ? getEventListeners(ov) : {};
                        if (listeners.click && listeners.click.length > 0) {
                            overlayHasClick = true;
                        }
                    }
                    // Alternative: check if dialog is non-modal (no inert on background)
                    const isModal = dialog.getAttribute('aria-modal') === 'true' ||
                                   dialog.hasAttribute('open');
                    return {
                        hasOverlay: overlays.length > 0,
                        overlayHasClick: overlayHasClick,
                        isModal: isModal,
                        dialogRole: dialog.getAttribute('role'),
                    };
                }
            """)
            if dismiss_is_consent and dismiss_is_consent.get("hasOverlay") and not dismiss_is_consent.get("isModal"):
                violations.append(Violation(
                    service="Cookie-Banner",
                    severity="HIGH",
                    text="Consent-Dialog ist nicht modal — Klick auf den Hintergrund kann "
                         "das Fenster schliessen und als Einwilligung gewertet werden. "
                         "Ein versehentlicher Klick ist keine aktive Einwilligung. "
                         "Der Dialog muss modal sein (nur explizite Buttons als Optionen).",
                    legal_ref="EuGH C-673/17 Planet49 (aktive Handlung), Art. 7(1) DSGVO (Nachweispflicht), "
                              "EDPB Guidelines 05/2020 Rn. 77 (silence/inactivity ≠ consent)",
                ))
        except Exception:
            pass

        # ── Checks 12-20: Advanced checks ──────────────────────
        try:
            advanced = await run_advanced_checks(page, banner_text)
            violations.extend(advanced)
        except Exception as e:
            logger.warning("Advanced banner checks failed: %s", e)

    except Exception as e:
        logger.warning("Banner text check failed: %s", e)

    return {"violations": violations, "has_impressum": has_impressum, "has_dse": has_dse}
