"""
Per-repo feedback cache. Stores patterns extracted from passed test cases
so future test generations can leverage proven selectors, navigation sequences,
and assertion patterns.

Cache location: ~/.maeris/repos/<repo_hash>/feedback_cache_<app_hash>.json
"""
import hashlib
import json
import os
import time
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional

import structlog

logger = structlog.get_logger()

_CACHE_VERSION = 1
FEEDBACK_CACHE_MAX_AGE_HOURS = 72  # 3 days — re-analyze after this period


@dataclass
class FeedbackCacheEntry:
    version: int = _CACHE_VERSION
    repo_hash: str = ""
    app_id: str = ""
    last_analyzed: float = 0.0  # unix timestamp
    last_result_created_at: str = ""  # ISO timestamp of newest result seen
    testcase_count_at_analysis: int = 0
    codemap_hash: str = ""  # SHA256 of codemap.json content

    # Extracted patterns
    proven_selectors: list = field(default_factory=list)
    # Each: {selector, action, page_url, pass_count, last_seen}

    proven_nav_sequences: list = field(default_factory=list)
    # Each: {from_page, to_page, steps: [{action, selector}], pass_count}

    page_pass_rates: dict = field(default_factory=dict)
    # {page_url: {total, passed, rate}}

    assertion_patterns: list = field(default_factory=list)
    # Each: {page_url, assertion_type, assertion_value, pass_count}

    flaky_selectors: list = field(default_factory=list)
    # Each: {selector, page_url, pass_count, fail_count, flakiness_score}


class RepoFeedbackCache:
    def __init__(self, repo_root: str, app_id: str):
        self.repo_root = os.path.abspath(repo_root)
        self.app_id = app_id
        self._repo_hash = hashlib.sha256(self.repo_root.encode()).hexdigest()[:16]
        self._app_hash = hashlib.sha256(app_id.encode()).hexdigest()[:8]
        self._cache_path = (
            Path.home()
            / ".maeris"
            / "repos"
            / self._repo_hash
            / f"feedback_cache_{self._app_hash}.json"
        )
        self._entry: Optional[FeedbackCacheEntry] = None

    def load(self) -> FeedbackCacheEntry:
        if self._entry is not None:
            return self._entry
        try:
            if self._cache_path.exists():
                data = json.loads(self._cache_path.read_text(encoding="utf-8"))
                if data.get("version") == _CACHE_VERSION:
                    self._entry = FeedbackCacheEntry(
                        version=data["version"],
                        repo_hash=data.get("repo_hash", ""),
                        app_id=data.get("app_id", ""),
                        last_analyzed=data.get("last_analyzed", 0.0),
                        last_result_created_at=data.get("last_result_created_at", ""),
                        testcase_count_at_analysis=data.get("testcase_count_at_analysis", 0),
                        codemap_hash=data.get("codemap_hash", ""),
                        proven_selectors=data.get("proven_selectors", []),
                        proven_nav_sequences=data.get("proven_nav_sequences", []),
                        page_pass_rates=data.get("page_pass_rates", {}),
                        assertion_patterns=data.get("assertion_patterns", []),
                        flaky_selectors=data.get("flaky_selectors", []),
                    )
                    logger.info(
                        "feedback_cache.loaded",
                        selectors=len(self._entry.proven_selectors),
                        nav_sequences=len(self._entry.proven_nav_sequences),
                    )
                    return self._entry
        except Exception as e:
            logger.warning("feedback_cache.load_failed", error=str(e))
        self._entry = FeedbackCacheEntry(
            version=_CACHE_VERSION,
            repo_hash=self._repo_hash,
            app_id=self.app_id,
        )
        return self._entry

    def save(self) -> None:
        if self._entry is None:
            return
        try:
            self._cache_path.parent.mkdir(parents=True, exist_ok=True)
            self._cache_path.write_text(
                json.dumps(asdict(self._entry), indent=2), encoding="utf-8"
            )
            logger.info(
                "feedback_cache.saved",
                selectors=len(self._entry.proven_selectors),
                nav_sequences=len(self._entry.proven_nav_sequences),
            )
        except Exception as e:
            logger.warning("feedback_cache.save_failed", error=str(e))

    def is_stale(
        self,
        current_tc_count: int,
        current_codemap_hash: str,
    ) -> bool:
        """Check if the cache needs a full re-analysis."""
        entry = self.load()

        # No analysis done yet
        if entry.last_analyzed == 0.0:
            return True

        # Cache older than FEEDBACK_CACHE_MAX_AGE_HOURS
        age_hours = (time.time() - entry.last_analyzed) / 3600
        if age_hours > FEEDBACK_CACHE_MAX_AGE_HOURS:
            logger.info("feedback_cache.stale_age", age_hours=round(age_hours, 1))
            return True

        # Testcase count changed (tests added or deleted)
        if entry.testcase_count_at_analysis != current_tc_count:
            logger.info(
                "feedback_cache.stale_tc_count",
                cached=entry.testcase_count_at_analysis,
                current=current_tc_count,
            )
            return True

        # Codemap changed (codebase changed)
        if entry.codemap_hash and current_codemap_hash and entry.codemap_hash != current_codemap_hash:
            logger.info("feedback_cache.stale_codemap")
            return True

        return False

    def needs_incremental_update(self, latest_result_ts: str) -> bool:
        """Check if new test runs happened since last analysis."""
        entry = self.load()
        if not entry.last_result_created_at or not latest_result_ts:
            return bool(latest_result_ts)
        return latest_result_ts > entry.last_result_created_at

    def get_prompt_block(self) -> str:
        """Format analysis into an LLM-injectable text block."""
        entry = self.load()
        if not entry.proven_selectors and not entry.proven_nav_sequences and not entry.flaky_selectors:
            return ""

        lines = [
            "=== PROVEN PATTERNS FROM PASSING TESTS ===",
            "Prefer these selectors and sequences — they are confirmed working.",
            "",
        ]

        # Proven selectors grouped by page
        if entry.proven_selectors:
            lines.append("--- Proven Selectors (by page) ---")
            by_page: dict[str, list[dict]] = {}
            for sel in entry.proven_selectors:
                page = sel.get("page_url", "unknown")
                by_page.setdefault(page, []).append(sel)
            for page, sels in sorted(by_page.items()):
                lines.append(f"  Page: {page}")
                for s in sorted(sels, key=lambda x: x.get("pass_count", 0), reverse=True):
                    selector = s.get("selector", "")
                    action = s.get("action", "")
                    pass_count = s.get("pass_count", 0)
                    last_seen = s.get("last_seen", "")
                    line = f"    {selector}  →  {action}  [{pass_count} passes"
                    if last_seen:
                        line += f", last: {last_seen}"
                    line += "]"
                    lines.append(line)
            lines.append("")

        # Proven navigation sequences
        if entry.proven_nav_sequences:
            lines.append("--- Proven Navigation Sequences ---")
            for nav in sorted(entry.proven_nav_sequences, key=lambda x: x.get("pass_count", 0), reverse=True):
                from_page = nav.get("from_page", "")
                to_page = nav.get("to_page", "")
                steps = nav.get("steps", [])
                pass_count = nav.get("pass_count", 0)
                step_desc = " → ".join(
                    f"{s.get('action', '')} {s.get('selector', '')}".strip()
                    for s in steps
                )
                lines.append(f"  {from_page} → {to_page}: {step_desc}  [{pass_count} passes]")
            lines.append("")

        # Page reliability
        if entry.page_pass_rates:
            lines.append("--- Page Reliability ---")
            for page, rates in sorted(
                entry.page_pass_rates.items(),
                key=lambda x: x[1].get("rate", 0),
                reverse=True,
            ):
                total = rates.get("total", 0)
                passed = rates.get("passed", 0)
                rate = rates.get("rate", 0)
                lines.append(f"  {page}: {rate:.0f}% ({passed}/{total})")
            lines.append("")

        # Flaky selectors
        if entry.flaky_selectors:
            lines.append("--- Flaky Selectors (avoid if alternatives exist) ---")
            for flaky in sorted(entry.flaky_selectors, key=lambda x: x.get("flakiness_score", 0), reverse=True):
                selector = flaky.get("selector", "")
                page = flaky.get("page_url", "")
                pc = flaky.get("pass_count", 0)
                fc = flaky.get("fail_count", 0)
                score = flaky.get("flakiness_score", 0)
                lines.append(
                    f"  {selector} @ {page}  [{pc} pass, {fc} fail — {score:.0f}% flaky]"
                )
            lines.append("")

        lines.append("=== END PROVEN PATTERNS ===")
        return "\n".join(lines)

    @staticmethod
    def _url_to_path(url: str) -> str:
        """Extract the path from a URL. '/login' stays '/login', 'https://x.com/login' becomes '/login'."""
        if url.startswith(("http://", "https://")):
            from urllib.parse import urlparse
            return urlparse(url).path or "/"
        return url

    @staticmethod
    def _normalize_selector(sel: str) -> str:
        """Normalize a selector for comparison — strip escapes, quotes, whitespace."""
        import re
        s = sel.strip()
        # Remove backslash escapes (e.g., \\@ → @, \\ → space)
        s = s.replace("\\@", "@").replace("\\.com", ".com")
        s = s.replace("\\.", ".").replace("\\ ", " ")
        # Normalize quotes
        s = s.replace('"', "'")
        # Remove :has-text(...) and similar pseudo-selectors for comparison
        s = re.sub(r':has-text\([^)]*\)', '', s)
        # Strip extra whitespace
        s = re.sub(r'\s+', ' ', s).strip()
        return s.lower()

    @staticmethod
    def _semantic_similarity(text_a: str, text_b: str) -> float:
        """Compute a simple semantic similarity score between two strings.

        Uses token overlap (Jaccard-like) on words extracted from the strings.
        Returns 0.0 (no match) to 1.0 (identical tokens).
        """
        import re

        def _tokenize(s: str) -> set[str]:
            # Split on non-alphanumeric, lowercase, filter short tokens
            return {t for t in re.split(r'[^a-z0-9]+', s.lower()) if len(t) >= 2}

        tokens_a = _tokenize(text_a)
        tokens_b = _tokenize(text_b)

        if not tokens_a or not tokens_b:
            return 0.0

        intersection = tokens_a & tokens_b
        union = tokens_a | tokens_b
        return len(intersection) / len(union) if union else 0.0

    @staticmethod
    def _match_score(
        el_label: str,
        el_selector: str,
        el_description: str,
        p_name: str,
        p_component_repr: str,
        p_selector: str,
        p_mock_input: str,
        normalize_fn,
    ) -> float:
        """Score how well a codemap element matches a proven selector.

        Uses multiple signals:
        1. Selector similarity (normalized)
        2. FLU name / component_repr ↔ element label/description (semantic)
        3. Mock input patterns ↔ element label

        Returns 0.0 (no match) to 1.0+ (strong match).
        """
        score = 0.0

        # --- Signal 1: Selector similarity ---
        el_norm = normalize_fn(el_selector)
        p_norm = normalize_fn(p_selector)

        if el_norm and p_norm:
            if el_norm == p_norm:
                score = max(score, 1.0)
            elif el_norm in p_norm or p_norm in el_norm:
                score = max(score, 0.85)
            else:
                # Check if attribute values overlap
                import re
                el_vals = set(re.findall(r"['\"]([^'\"]+)['\"]", el_selector.lower()))
                p_vals = set(re.findall(r"['\"]([^'\"]+)['\"]", p_selector.lower()))
                if el_vals and p_vals and (el_vals & p_vals):
                    score = max(score, 0.8)

        # --- Signal 2: FLU name / component_repr ↔ element label/description ---
        el_text = f"{el_label} {el_description}".strip()
        p_text = f"{p_name} {p_component_repr}".strip()

        if el_text and p_text:
            sem_score = RepoFeedbackCache._semantic_similarity(el_text, p_text)
            # Boost if substantial overlap
            if sem_score >= 0.4:
                score = max(score, 0.5 + sem_score * 0.5)  # 0.7 - 1.0 range

        # --- Signal 3: Mock input patterns ---
        label_lower = (el_label or "").lower()
        mock_lower = (p_mock_input or "").lower()

        if "@" in mock_lower and ("email" in label_lower or "@" in label_lower or "example.com" in label_lower):
            score = max(score, 0.9)
        if mock_lower and "password" in label_lower and "@" not in mock_lower:
            score = max(score, 0.7)

        return score

    def enrich_codemap(self, codemap: dict) -> dict:
        """Match proven selectors to codemap elements and UPDATE their selectors.

        Matching criteria (per page):
        1. Selector similarity — normalized selectors match
        2. Semantic matching — FLU name / component_repr ↔ element label/description
        3. Mock input patterns — infer element identity from input data

        When matched: replaces the codemap element's selector with the proven one.
        Unmatched proven selectors are added as a separate 'proven_selectors' list.
        Also verifies edges if navigation sequences match.
        """
        entry = self.load()
        if not entry.proven_selectors and not entry.page_pass_rates and not entry.proven_nav_sequences:
            return codemap

        nodes = codemap.get("nodes", {})
        edges = codemap.get("edges", [])

        # Build path -> proven selectors lookup
        selectors_by_path: dict[str, list[dict]] = {}
        for sel in entry.proven_selectors:
            page = sel.get("page_url", "")
            if page:
                path = self._url_to_path(page)
                selectors_by_path.setdefault(path, []).append(sel)

        # Build path -> pass rates lookup
        rates_by_path: dict[str, dict] = {}
        for page, rates in entry.page_pass_rates.items():
            path = self._url_to_path(page)
            rates_by_path[path] = rates

        # --- Enrich nodes: match and replace selectors ---
        for route, node in nodes.items():
            proven_for_page = selectors_by_path.get(route, [])
            if not proven_for_page:
                if route in rates_by_path:
                    node["reliability_score"] = rates_by_path[route].get("rate", 0)
                continue

            elements = node.get("elements", [])
            description = node.get("description", "")
            matched_proven_indices: set[int] = set()

            for el in elements:
                el_selector = el.get("selector", "")
                el_label = el.get("label", "")

                best_match_idx = -1
                best_score = 0.0

                for i, proven in enumerate(proven_for_page):
                    if i in matched_proven_indices:
                        continue

                    score = self._match_score(
                        el_label=el_label,
                        el_selector=el_selector,
                        el_description=description,
                        p_name=proven.get("name", ""),
                        p_component_repr=proven.get("component_repr", ""),
                        p_selector=proven.get("selector", ""),
                        p_mock_input=proven.get("mock_input", ""),
                        normalize_fn=self._normalize_selector,
                    )

                    # Weight by pass count
                    p_pass_count = proven.get("pass_count", 0)
                    if score > 0 and p_pass_count > 1:
                        score += 0.01 * min(p_pass_count, 10)

                    if score > best_score:
                        best_score = score
                        best_match_idx = i

                # Apply match if confident enough
                if best_match_idx >= 0 and best_score >= 0.6:
                    proven = proven_for_page[best_match_idx]
                    matched_proven_indices.add(best_match_idx)
                    el["original_selector"] = el["selector"]
                    el["selector"] = proven["selector"]
                    el["selector_source"] = "proven"
                    el["selector_pass_count"] = proven.get("pass_count", 0)
                    el["selector_action"] = proven.get("action", "")

            # Add unmatched proven selectors as extra list
            unmatched = [
                {
                    "selector": proven_for_page[i]["selector"],
                    "action": proven_for_page[i]["action"],
                    "pass_count": proven_for_page[i]["pass_count"],
                    "name": proven_for_page[i].get("name", ""),
                    "confidence": "proven",
                }
                for i in range(len(proven_for_page))
                if i not in matched_proven_indices
            ]
            if unmatched:
                node["proven_selectors"] = unmatched

            # Add reliability score
            if route in rates_by_path:
                node["reliability_score"] = rates_by_path[route].get("rate", 0)

        # --- Verify edges: mark edges confirmed by passing test navigation ---
        if entry.proven_nav_sequences and edges:
            verified_edges: set[tuple[str, str]] = set()
            for nav in entry.proven_nav_sequences:
                from_path = self._url_to_path(nav.get("from_page", ""))
                to_path = self._url_to_path(nav.get("to_page", ""))
                if from_path and to_path:
                    verified_edges.add((from_path, to_path))

            for edge in edges:
                edge_from = edge.get("from", "")
                edge_to = edge.get("to", "")
                if (edge_from, edge_to) in verified_edges:
                    edge["verified_by_tests"] = True
                    # Find the nav sequence to get pass count
                    for nav in entry.proven_nav_sequences:
                        if (self._url_to_path(nav.get("from_page", "")) == edge_from
                                and self._url_to_path(nav.get("to_page", "")) == edge_to):
                            edge["verification_pass_count"] = nav.get("pass_count", 0)
                            break

        return codemap
