"""UI drift detection — deterministic, no AI.

Checks whether selectors stored in Maeris testcase steps still exist
in the frontend source code.

A selector is considered "drifted" when its identifying token(s) cannot
be found in any UI source file under the scan root. Detection is static
and string-based (it does not evaluate a running DOM or execute tests).
"""

from __future__ import annotations

import re
from pathlib import Path

from .models import DriftIssue, DriftKind, DriftReport, DriftSeverity

# Source file extensions to scan for selector identifiers
_UI_EXTENSIONS: frozenset[str] = frozenset({
    ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte",
    ".html", ".htm", ".py", ".erb", ".jinja", ".jinja2",
})

# Directories whose subtrees are never scanned
_SKIP_DIRS: frozenset[str] = frozenset({
    "node_modules", "dist", "build", ".next", ".nuxt", ".output",
    "coverage", "vendor", ".git", "__pycache__", ".cache",
    ".turbo", ".vercel", ".svelte-kit",
})


def _extract_identifier(selector: str) -> str | None:
    """Extract the meaningful identifier from a CSS selector.

    NOTE: This is a fallback only. Drift detection should prefer exact
    attribute matches (id, class, data-*, etc.) to avoid false negatives.
    """
    # data-* attribute value: [data-testid="value"] or [data-automation-id='value']
    m = re.search(r'\[data-[\w-]+=[\'"]([\w-]+)[\'"]', selector)
    if m:
        return m.group(1)

    # Generic attribute value: [name="foo"] or [id="foo"]
    m = re.search(r'\[(?:name|id|class|aria-label|placeholder|title)=[\'"]([^\'"]+)[\'"]', selector)
    if m:
        return m.group(1)

    # ID selector: #foo
    m = re.search(r'#([a-zA-Z][\w-]*)', selector)
    if m:
        return m.group(1)

    # Class selector: .foo or button.foo
    m = re.search(r'\.([a-zA-Z][\w-]*)', selector)
    if m:
        return m.group(1)

    # Bare word (tag name or custom element) — skip tags, keep custom elements
    stripped = selector.strip()
    if re.fullmatch(r'[a-zA-Z][\w-]*', stripped) and '-' in stripped:
        return stripped

    return None


def _looks_like_url(value: str) -> bool:
    v = (value or "").strip().lower()
    return v.startswith("http://") or v.startswith("https://") or v.startswith("/")


def _strip_playwright_pseudos(selector: str) -> str:
    """Drop Playwright-specific pseudo filters like :has-text("...")."""
    s = selector
    # Frame separator: everything after >> belongs to a child frame — keep only the outermost part.
    if ">>" in s:
        s = s.split(">>")[0].strip()
    # Common pseudo filters that can be safely truncated at the token boundary.
    for token in (
        ":has-text(", ":text(", ":has(",
        ":visible", ":enabled", ":disabled", ":nth-match(",
    ):
        idx = s.find(token)
        if idx != -1:
            s = s[:idx].strip()
    return s


def _unescape_css_value(value: str) -> str:
    """Best-effort unescape for CSS selector literals.

    Examples:
      you\\@example\\.com  -> you@example.com
      Enter\\ your\\ password -> Enter your password
    """
    if not value:
        return value
    # Remove backslash escaping a single next char.
    return re.sub(r"\\(.)", r"\1", value)


def _playwright_text_literal(selector: str) -> str | None:
    """Extract visible text from Playwright selectors like text=... or role=...[name="..."]."""
    s = (selector or "").strip()
    if s.lower().startswith("text="):
        val = s[5:].strip()
        # Strip surrounding quotes if present
        if len(val) >= 2 and val[0] in "\"'" and val[-1] == val[0]:
            val = val[1:-1]
        return _unescape_css_value(val).strip() or None
    # role=button[name="Sign in"] style
    m = re.search(r"""\bname\s*=\s*["']([^"']+)["']""", s, re.IGNORECASE)
    if m:
        return _unescape_css_value(m.group(1)).strip() or None
    return None


def _looks_like_css_module_hashed_class(selector: str) -> bool:
    """Heuristic: CSS Modules build output often contains class tokens like foo__a1B2c."""
    # Match a class token containing a double-underscore hash suffix.
    # Examples: auth_submitButton__6wwx4, pageWrapper__a6yMl
    return bool(re.search(r"\b[a-zA-Z0-9_-]+__[\w-]{4,}\b", selector))


def _is_complex_dom_path_selector(selector: str) -> bool:
    """Return True for brittle DOM-path selectors (combinators, nth-child, etc.)."""
    s = (selector or "").lower()
    return any(tok in s for tok in (
        "nth-child", "nth-of-type", ":nth-", ":first-child", ":last-child",
    )) or bool(re.search(r"\s>\s|\s\+\s|\s~\s", selector))


_FRAMEWORK_CONTAINER_IDS: frozenset[str] = frozenset({"__next", "root", "app"})


def _has_stable_selector_hints(selector: str) -> bool:
    """Return True if selector includes stable attribute hints worth validating."""
    return bool(re.search(
        r"\[data-[\w-]+="                                  # data-*
        r"|\[(?:name|id|aria-label|placeholder|title)=",    # common attrs
        selector,
        re.IGNORECASE,
    ))


def _has_non_framework_id(selector: str) -> bool:
    """Return True if selector contains an id token other than known framework containers."""
    ids = re.findall(r"#([a-zA-Z][\w-]*)", selector or "")
    return any(i.lower() not in _FRAMEWORK_CONTAINER_IDS for i in ids)


def _selector_in_source(selector: str, scan_root: Path) -> bool:
    """Return True if the selector can be found in any UI source file.

    Uses strict attribute matching for id/class/data-* to avoid false negatives
    (e.g. '#submit' should only pass if id='submit' exists).
    """
    verdict = _selector_verdict(
        selector,
        scan_root,
        context_tokens=None,
    )
    # Backwards-compatible boolean: treat "unverifiable" as present.
    return verdict["present"]


def _tokenize(text: str) -> set[str]:
    return {t for t in re.findall(r"[a-zA-Z0-9_]+", (text or "").lower()) if len(t) > 2}


def _context_tokens_for_step(tc_name: str, step: dict) -> set[str]:
    """Best-effort tokens to scope selector validation to the likely UI area.

    Drift detection is static; scoping prevents false negatives where a selector
    exists somewhere in the repo but not in the page/area the testcase claims to
    interact with.
    """
    tokens: set[str] = set()

    # Page URL is the strongest hint (e.g. ".../login" -> {"login"}).
    page_url = (step.get("page_url") or step.get("pageUrl") or step.get("url") or "").strip()
    if page_url:
        # Extract path segments and query values without importing urllib.
        # Keep only meaningful tokens.
        # Examples:
        #   https://x/y/login -> login
        #   /onboarding/select-app -> onboarding, select, app
        parts = re.split(r"[/?#&=:_\-]+", page_url)
        tokens |= {t.lower() for t in parts if t and len(t) > 2 and not t.lower().startswith("http")}

    # Testcase name is a useful secondary hint (e.g. "Login - Happy Path" -> login, happy, path).
    tokens |= _tokenize(tc_name)

    return tokens


def _iter_ui_files(scan_root: Path) -> list[Path]:
    files: list[Path] = []
    for p in scan_root.rglob("*"):
        if not p.is_file():
            continue
        if p.suffix not in _UI_EXTENSIONS:
            continue
        if any(part in _SKIP_DIRS for part in p.parts):
            continue
        files.append(p)
    return files


def _rank_files_by_tokens(files: list[Path], scan_root: Path, tokens: set[str], limit: int = 60) -> list[Path]:
    if not tokens:
        return []
    scored: list[tuple[int, Path]] = []
    for p in files:
        rel = str(p.relative_to(scan_root)).replace("\\", "/").lower()
        score = 0
        for t in tokens:
            if t in rel:
                score += 3
            elif t in p.stem.lower():
                score += 2
        if score:
            scored.append((score, p))
    scored.sort(key=lambda x: x[0], reverse=True)
    return [p for _, p in scored[:limit]]


def _selector_verdict(
    selector: str,
    scan_root: Path,
    context_tokens: set[str] | None,
    *,
    files: list[Path] | None = None,
    content_cache: dict[Path, str] | None = None,
) -> dict:
    """Return a richer selector check verdict.

    Returns a dict containing:
      - present: bool (True means "do not flag as drift")
      - verdict: "present" | "missing" | "unverifiable" | "present_global_only"
      - matched_files: optional list of relative file paths (small sample)
    """
    selector = (selector or "").strip()
    if not selector:
        return {"present": True, "verdict": "present", "matched_files": []}

    # Some Maeris components store go_to URLs in css_selector. Those are not
    # selectors and should not participate in selector drift checks.
    if _looks_like_url(selector):
        return {"present": True, "verdict": "present", "matched_files": []}

    # Playwright engine-prefixed selectors use engine-specific syntax that cannot
    # be meaningfully validated against source code via string search.
    _ENGINE_PREFIXES = ("_react=", "_vue=", "_angular=", "pierce/", "internal:")
    if any(selector.startswith(p) for p in _ENGINE_PREFIXES):
        return {"present": True, "verdict": "unverifiable", "matched_files": []}

    selector = _strip_playwright_pseudos(selector)

    # DOM-path selectors (e.g. '#__next > ... > div:nth-child(3) > input') can be
    # valid in the live DOM but are not reliably traceable to source code.
    # Unless they include stable attributes (data-*, name=, aria-label, etc.) or a
    # non-framework id, treat them as "unverifiable" rather than "missing".
    if _is_complex_dom_path_selector(selector):
        if not _has_stable_selector_hints(selector) and not _has_non_framework_id(selector):
            return {"present": True, "verdict": "unverifiable", "matched_files": []}

    # CSS Modules hashed classnames (e.g. foo__a1B2c) are runtime/build artifacts and
    # often do not appear verbatim in source code. They can be correct selectors at
    # runtime but are not statically verifiable via string search in the repo.
    #
    # If a selector relies only on hashed classes (no stable attributes like data-*, id,
    # name, aria-label, placeholder, etc.), treat it as "unverifiable" rather than
    # "missing" to avoid false drift reports.
    if _looks_like_css_module_hashed_class(selector):
        has_stable_attr = _has_stable_selector_hints(selector)
        has_stable_id = _has_non_framework_id(selector)
        if not (has_stable_attr or has_stable_id):
            return {"present": True, "verdict": "unverifiable", "matched_files": []}

    patterns: list[re.Pattern] = []

    # Playwright text/role selectors: treat "present" if the visible label exists in source.
    txt = _playwright_text_literal(selector)
    if txt:
        patterns.append(re.compile(re.escape(txt), re.IGNORECASE))

    # data-* attribute
    m = re.search(r'\[data-([\w-]+)=[\'"]([^\'"]+)[\'"]\]', selector)
    if m:
        attr = m.group(1)
        val = re.escape(_unescape_css_value(m.group(2)))
        patterns.append(re.compile(rf'\bdata-{re.escape(attr)}\s*=\s*["\']{val}["\']', re.IGNORECASE))

    # generic attribute selectors: [name="x"], [id="x"], [class="x"], [aria-label="x"], [placeholder="x"], [title="x"]
    m = re.search(r'\[(name|id|class|aria-label|placeholder|title)=[\'"]([^\'"]+)[\'"]\]', selector, re.IGNORECASE)
    if m:
        attr = m.group(1)
        val = re.escape(_unescape_css_value(m.group(2)))
        patterns.append(re.compile(rf'\b{re.escape(attr)}\s*=\s*["\']{val}["\']', re.IGNORECASE))

    # ID selector: #foo
    m = re.search(r'#([a-zA-Z][\w-]*)', selector)
    if m:
        val = re.escape(m.group(1))
        # id="foo" or id={'foo'} / id={"foo"}
        patterns.append(re.compile(rf'\bid\s*=\s*["\']{val}["\']', re.IGNORECASE))
        patterns.append(re.compile(rf'\bid\s*=\s*\{{\s*["\']{val}["\']\s*\}}', re.IGNORECASE))

    # Class selector: .foo
    m = re.search(r'\.([a-zA-Z][\w-]*)', selector)
    if m:
        val = re.escape(m.group(1))
        patterns.append(re.compile(rf'\bclass(Name)?\s*=\s*["\'][^"\']*\b{val}\b[^"\']*["\']', re.IGNORECASE))
        patterns.append(re.compile(rf'\bclass(Name)?\s*=\s*\{{\s*["\'][^"\']*\b{val}\b[^"\']*["\']\s*\}}', re.IGNORECASE))

    # If we couldn't build any strict patterns, fall back to identifier token
    identifier = _extract_identifier(selector)
    if not patterns and identifier:
        patterns.append(re.compile(re.escape(identifier), re.IGNORECASE))

    # If still no patterns (e.g. complex selector), treat as present to avoid false positives
    if not patterns:
        return {"present": True, "verdict": "unverifiable", "matched_files": []}

    files = files if files is not None else _iter_ui_files(scan_root)
    content_cache = content_cache if content_cache is not None else {}

    def _read(p: Path) -> str:
        if p in content_cache:
            return content_cache[p]
        try:
            txt = p.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            txt = ""
        content_cache[p] = txt
        return txt

    def _find_matches(candidates: list[Path], max_hits: int = 3) -> list[str]:
        hits: list[str] = []
        for p in candidates:
            content = _read(p)
            if not content:
                continue
            if any(rx.search(content) for rx in patterns):
                hits.append(str(p.relative_to(scan_root)).replace("\\", "/"))
                if len(hits) >= max_hits:
                    break
        return hits

    # First pass: scoped search to files likely related to the testcase/page.
    candidates = _rank_files_by_tokens(files, scan_root, context_tokens or set())
    if candidates:
        hits = _find_matches(candidates)
        if hits:
            return {"present": True, "verdict": "present", "matched_files": hits}

    # Second pass: global search (fallback). If we only find the selector here,
    # it likely exists in the repo but not in the UI area this testcase targets.
    hits_global = _find_matches(files)
    if hits_global:
        return {"present": False, "verdict": "present_global_only", "matched_files": hits_global}

    return {"present": False, "verdict": "missing", "matched_files": []}


def _xpath_verdict(
    xpath: str,
    scan_root: Path,
    context_tokens: set[str] | None,
    *,
    files: list[Path],
    content_cache: dict[Path, str],
) -> dict:
    """Check whether an XPath selector's attribute anchors exist in source.

    XPath cannot be matched as-is against source text, but attribute values
    embedded in `@id`, `@name`, `@class`, or `@data-*` predicates can be.
    If no verifiable anchors are present, returns "unverifiable".
    """
    xpath = (xpath or "").strip()
    if not xpath:
        return {"present": True, "verdict": "present", "matched_files": []}

    # Extract attribute values from common XPath predicates:
    # @id="foo", @name="bar", @data-testid="baz"
    attr_values = re.findall(
        r'@(?:id|class|name|data-[\w-]+)\s*=\s*["\']([^"\']+)["\']',
        xpath,
    )
    if not attr_values:
        return {"present": True, "verdict": "unverifiable", "matched_files": []}

    patterns = [re.compile(re.escape(v), re.IGNORECASE) for v in attr_values]

    def _read(p: Path) -> str:
        if p in content_cache:
            return content_cache[p]
        try:
            txt = p.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            txt = ""
        content_cache[p] = txt
        return txt

    candidates = _rank_files_by_tokens(files, scan_root, context_tokens or set())
    for p in (candidates or files):
        content = _read(p)
        if not content:
            continue
        if any(rx.search(content) for rx in patterns):
            return {
                "present": True,
                "verdict": "present",
                "matched_files": [str(p.relative_to(scan_root)).replace("\\", "/")],
            }

    return {"present": False, "verdict": "missing", "matched_files": []}


async def detect_ui_drift(client, scan_root: Path) -> DriftReport:
    """Deterministic UI drift detection.

    Fetches all testcases from Maeris, then checks each step's selector
    against the source tree. No AI involved.

    Args:
        client: An active MaerisClient instance.
        scan_root: Root of the frontend source to scan.

    Returns:
        DriftReport with SELECTOR_MISSING issues for stale selectors.
    """
    testcases = await client.get_testcases()
    issues: list[DriftIssue] = []
    steps_checked = 0
    any_steps_seen = False

    ui_files = _iter_ui_files(scan_root)
    source_cache: dict[Path, str] = {}

    for tc in testcases:
        tc_id = tc.get("testcase_id") or tc.get("_id") or tc.get("id") or ""
        tc_name = tc.get("name") or tc.get("testcase_name") or tc_id

        steps: list[dict] = tc.get("steps") or []
        if steps:
            any_steps_seen = True
        for idx, step in enumerate(steps):
            selector = (
                step.get("selector")
                or step.get("cssSelector")
                or step.get("css_selector")
                or ""
            ).strip()
            if not selector:
                continue

            steps_checked += 1

            verdict = _selector_verdict(
                selector,
                scan_root,
                context_tokens=_context_tokens_for_step(tc_name, step),
                files=ui_files,
                content_cache=source_cache,
            )
            if verdict["verdict"] == "unverifiable" or verdict["present"]:
                continue

            severity = DriftSeverity.HIGH
            desc_suffix = ""
            if verdict["verdict"] == "present_global_only":
                severity = DriftSeverity.MEDIUM
                desc_suffix = " (found elsewhere in repo, but not in the likely page/area for this testcase)"

            if not verdict["present"]:
                issues.append(DriftIssue(
                    kind=DriftKind.SELECTOR_MISSING,
                    severity=severity,
                    description=(
                        f"Selector '{selector}' from step {idx + 1} of testcase "
                        f"'{tc_name}' was not found in source.{desc_suffix}"
                    ),
                    resource_type="ui",
                    resource_id=tc_id,
                    resource_name=tc_name,
                    detail={
                        "selector": selector,
                        "step_index": idx,
                        "step": step,
                        "scan_root": str(scan_root),
                        "source": "testcase_step",
                        "selector_verdict": verdict,
                    },
                ))

        # Also check XPath selectors when present.
        for idx, step in enumerate(steps):
            xpath = (
                step.get("xpath")
                or step.get("xpathSelector")
                or step.get("xpath_selector")
                or ""
            ).strip()
            if not xpath or _looks_like_url(xpath):
                continue
            ctx = _context_tokens_for_step(tc_name, step)
            xpath_v = _xpath_verdict(
                xpath, scan_root, ctx,
                files=ui_files, content_cache=source_cache,
            )
            if xpath_v["verdict"] == "unverifiable" or xpath_v["present"]:
                continue
            steps_checked += 1
            issues.append(DriftIssue(
                kind=DriftKind.SELECTOR_MISSING,
                severity=DriftSeverity.MEDIUM,
                description=(
                    f"XPath '{xpath}' from step {idx + 1} of testcase "
                    f"'{tc_name}' was not found in source."
                ),
                resource_type="ui",
                resource_id=tc_id,
                resource_name=tc_name,
                detail={
                    "selector": xpath,
                    "selector_type": "xpath",
                    "step_index": idx,
                    "step": step,
                    "scan_root": str(scan_root),
                    "source": "testcase_step",
                    "selector_verdict": xpath_v,
                },
            ))

    # Fallback: some Maeris deployments do not include step payloads on get_testcases().
    # In that case, drift detection would incorrectly check 0 selectors and report no drift.
    # Use the components-by-testcase-ids endpoint as an alternate selector source.
    if testcases and not any_steps_seen:
        try:
            testcase_ids = [
                tc.get("testcase_id") or tc.get("_id") or tc.get("id")
                for tc in testcases
                if tc.get("testcase_id") or tc.get("_id") or tc.get("id")
            ]
            component_data = await client.get_components_for_testcases(testcase_ids)
            tc_map = {
                m.get("testcase_id"): (m.get("component_ids") or [])
                for m in (component_data.get("testcase_component_maps") or [])
                if isinstance(m, dict)
            }
            comp_map = {
                (c.get("component_id") or c.get("id")): c
                for c in (component_data.get("unique_components") or [])
                if isinstance(c, dict) and (c.get("component_id") or c.get("id"))
            }
        except Exception:
            tc_map = {}
            comp_map = {}

        for tc in testcases:
            tc_id = tc.get("testcase_id") or tc.get("_id") or tc.get("id") or ""
            tc_name = tc.get("name") or tc.get("testcase_name") or tc_id
            for comp_id in tc_map.get(tc_id, []):
                comp = comp_map.get(comp_id) or {}
                selector = (
                    (comp.get("css_selector") or comp.get("cssSelector") or comp.get("selector") or "")
                ).strip()
                if not selector:
                    continue
                steps_checked += 1
                verdict = _selector_verdict(
                    selector,
                    scan_root,
                    context_tokens=_tokenize(tc_name),
                    files=ui_files,
                    content_cache=source_cache,
                )
                if verdict["verdict"] == "unverifiable" or verdict["present"]:
                    continue
                issues.append(DriftIssue(
                    kind=DriftKind.SELECTOR_MISSING,
                    severity=DriftSeverity.HIGH,
                    description=(
                        f"Selector '{selector}' from testcase '{tc_name}' "
                        f"(component {comp_id}) was not found in source."
                    ),
                    resource_type="ui",
                    resource_id=tc_id,
                    resource_name=tc_name,
                    detail={
                        "selector": selector,
                        "component_id": comp_id,
                        "component": comp,
                        "scan_root": str(scan_root),
                        "source": "component",
                        "selector_verdict": verdict,
                    },
                ))

                # Also check XPath selector from the component.
                xpath = (
                    comp.get("xpath") or comp.get("xpathSelector") or comp.get("xpath_selector") or ""
                ).strip()
                if xpath and not _looks_like_url(xpath):
                    xpath_v = _xpath_verdict(
                        xpath, scan_root, _tokenize(tc_name),
                        files=ui_files, content_cache=source_cache,
                    )
                    if not xpath_v["present"] and xpath_v["verdict"] != "unverifiable":
                        issues.append(DriftIssue(
                            kind=DriftKind.SELECTOR_MISSING,
                            severity=DriftSeverity.MEDIUM,
                            description=(
                                f"XPath '{xpath}' from testcase '{tc_name}' "
                                f"(component {comp_id}) was not found in source."
                            ),
                            resource_type="ui",
                            resource_id=tc_id,
                            resource_name=tc_name,
                            detail={
                                "selector": xpath,
                                "selector_type": "xpath",
                                "component_id": comp_id,
                                "component": comp,
                                "scan_root": str(scan_root),
                                "source": "component",
                                "selector_verdict": xpath_v,
                            },
                        ))

    return DriftReport(
        domain="ui",
        issues=issues,
        meta={
            "scan_root": str(scan_root),
            "testcases_checked": len(testcases),
            "steps_checked": steps_checked,
            "used_fallback_components": bool(testcases and not any_steps_seen),
        },
    )
