"""Phase-2 extraction artifact finalization helpers.

The deterministic Phase 2 and manual fast paths can produce enough source
artifacts to prove an absence (no inline SVGs, no CSS custom properties) or to
summarize reusable style evidence, while still leaving the canonical handoff
files empty or missing. This module turns those source artifacts into explicit
sentinels/summaries so gates distinguish "observed none" from "skipped".
"""

from __future__ import annotations

import datetime
import json
import os
import re
import shutil
from collections import defaultdict
from pathlib import Path
from typing import Any

_JSON = dict[str, Any]
_NOISE = {
    "",
    "normal",
    "none",
    "auto",
    "0px",
    "rgba(0, 0, 0, 0)",
    "all 0s ease 0s",
    "all",
    "0s",
    "ease",
}
_CSS_VAR_RE = re.compile(r"(--[A-Za-z0-9_-]+)\s*:\s*([^;{}]+)")
_CSS_RULE_RE = re.compile(r"([^{}]+)\{([^{}]*)\}", re.DOTALL)
_CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
_BODY_STATE_SELECTOR_RE = re.compile(r"\b(?:body|html)(?:\.[A-Za-z0-9_-]+|\[[^\]]+\])")

_BUNDLE_PROPS: dict[str, tuple[str, ...]] = {
    "surface": ("background-color", "background-image", "border", "box-shadow"),
    "shape": ("border-radius", "padding"),
    "type": ("font-size", "font-weight", "font-family", "line-height", "letter-spacing"),
    "tone": ("color", "background-color", "border-color"),
    "motion": (
        "transition",
        "transition-duration",
        "transition-timing-function",
        "animation",
        "animation-duration",
        "animation-timing-function",
    ),
}


def finalize_extraction_artifacts(ref_dir: Path) -> dict[str, str]:
    """Write canonical Phase-2 sentinel/summary artifacts when source evidence exists.

    Existing non-empty real artifacts are preserved. Empty or missing handoff
    files are rewritten only when they can be derived from canonical source
    artifacts already in ``ref_dir`` (``structure.json`` and downloaded
    ``css/*.css``). The return value maps artifact names to actions for tests
    and diagnostics.
    """
    ref_dir = Path(ref_dir)
    actions: dict[str, str] = {}
    structure = _load_structure(ref_dir / "structure.json")

    if structure is not None:
        _finalize_inline_svgs(ref_dir, structure, actions)
        _finalize_body_state(ref_dir, structure, actions)
        _finalize_design_bundles(ref_dir, structure, actions)
    _finalize_css_variables(ref_dir, actions)
    return actions


def _load_structure(path: Path) -> Any | None:
    data = _load_json(path)
    if isinstance(data, dict | list):
        return data
    return None


def _load_json(path: Path) -> Any | None:
    if not path.is_file():
        return None
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeDecodeError, json.JSONDecodeError):
        return None
    # Some captured evals write JSON.stringify(payload) to a JSON file, leaving
    # a double-encoded string such as "[]". Unwrap once so sentinels can
    # recognize the underlying observation.
    if isinstance(data, str):
        stripped = data.strip()
        if stripped.startswith(("[", "{")):
            try:
                return json.loads(stripped)
            except json.JSONDecodeError:
                return data
    return data


def _write_json(path: Path, payload: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def _walk_nodes(node: Any) -> list[_JSON]:
    out: list[_JSON] = []

    def walk(current: Any) -> None:
        if isinstance(current, dict):
            out.append(current)
            for child in current.get("children") or []:
                walk(child)
        elif isinstance(current, list):
            for child in current:
                walk(child)

    walk(node)
    return out


def _tag(node: _JSON) -> str:
    raw = node.get("tag")
    return raw.lower() if isinstance(raw, str) else ""


def _first_class(node: _JSON) -> str:
    raw = node.get("class") or node.get("className") or ""
    if not isinstance(raw, str):
        return ""
    return raw.strip().split()[0] if raw.strip() else ""


def _styles(node: _JSON) -> _JSON:
    raw = node.get("styles")
    return raw if isinstance(raw, dict) else {}


def _is_empty_file(path: Path) -> bool:
    try:
        return (not path.exists()) or path.stat().st_size < 10
    except OSError:
        return True


def _svg_payload_has_entries(data: Any) -> bool:
    if isinstance(data, list):
        return bool(data)
    if isinstance(data, dict):
        for key in ("svgs", "inlineSvgs", "items"):
            value = data.get(key)
            if isinstance(value, list) and value:
                return True
        count = data.get("svgCount")
        return isinstance(count, int) and count > 0
    return False


def _finalize_inline_svgs(ref_dir: Path, structure: Any, actions: dict[str, str]) -> None:
    path = ref_dir / "inline-svgs.json"
    existing = _load_json(path)
    if existing is not None and _svg_payload_has_entries(existing):
        return
    svg_count = sum(1 for node in _walk_nodes(structure) if _tag(node) == "svg")
    if svg_count != 0:
        return
    payload = {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "observation": "no-inline-svgs",
        "svgs": [],
        "svgCount": 0,
        "derivedFrom": ["structure.json"],
    }
    _write_json(path, payload)
    actions["inline-svgs.json"] = "wrote no-inline-svgs sentinel"


def _css_files(ref_dir: Path) -> list[Path]:
    css_dir = ref_dir / "css"
    if not css_dir.is_dir():
        return []
    return sorted(p for p in css_dir.glob("*.css") if p.is_file())


def _read_css_text(ref_dir: Path) -> str:
    chunks: list[str] = []
    for path in _css_files(ref_dir):
        try:
            chunks.append(path.read_text(encoding="utf-8", errors="ignore"))
        except OSError:
            continue
    return "\n".join(chunks)


def _finalize_css_variables(ref_dir: Path, actions: dict[str, str]) -> None:
    css_text = _read_css_text(ref_dir)
    variables_path = ref_dir / "css" / "variables.txt"
    variables_json_path = ref_dir / "css" / "variables.json"
    if not css_text and not variables_path.exists():
        return

    stripped_css = _CSS_COMMENT_RE.sub("", css_text)
    variables = sorted(
        {name: value.strip() for name, value in _CSS_VAR_RE.findall(stripped_css)}.items()
    )
    current = ""
    if variables_path.exists():
        try:
            current = variables_path.read_text(encoding="utf-8")
        except OSError:
            current = ""
    current_has_vars = bool(_CSS_VAR_RE.search(current))

    if variables and (not current_has_vars or _is_empty_file(variables_path)):
        variables_path.parent.mkdir(parents=True, exist_ok=True)
        variables_path.write_text(
            "\n".join(f"{name}: {value}" for name, value in variables) + "\n",
            encoding="utf-8",
        )
        actions["css/variables.txt"] = "extracted CSS custom properties"
    elif not variables and _is_empty_file(variables_path):
        variables_path.parent.mkdir(parents=True, exist_ok=True)
        variables_path.write_text(
            "/* ui-clone: no CSS custom properties observed in downloaded CSS */\n",
            encoding="utf-8",
        )
        actions["css/variables.txt"] = "wrote no-css-custom-properties sentinel"

    if variables or variables_path.exists():
        payload = {
            "schemaVersion": 1,
            "source": "ui_clone.extraction_artifacts",
            "observation": "css-custom-properties" if variables else "no-css-custom-properties",
            "count": len(variables),
            "variables": [{"name": name, "value": value} for name, value in variables],
            "derivedFrom": [str(p.relative_to(ref_dir)) for p in _css_files(ref_dir)],
        }
        if not variables_json_path.exists() or _is_empty_file(variables_json_path):
            _write_json(variables_json_path, payload)
            actions["css/variables.json"] = "wrote CSS variables summary"


def _body_node(structure: Any) -> _JSON | None:
    for node in _walk_nodes(structure):
        if _tag(node) == "body":
            return node
    if isinstance(structure, dict):
        return structure
    return None


def _body_transition(styles: _JSON) -> str:
    transition = styles.get("transition")
    if isinstance(transition, str) and transition.strip() and transition.strip() not in _NOISE:
        return transition.strip()
    duration = str(styles.get("transition-duration") or "").strip()
    if duration and duration not in _NOISE:
        prop = str(styles.get("transition-property") or "all").strip() or "all"
        timing = str(styles.get("transition-timing-function") or "ease").strip() or "ease"
        delay = str(styles.get("transition-delay") or "0s").strip() or "0s"
        return f"{prop} {duration} {timing} {delay}"
    return "all 0s ease 0s"


def _body_class_rules(ref_dir: Path) -> list[_JSON]:
    css_text = _CSS_COMMENT_RE.sub("", _read_css_text(ref_dir))
    out: list[_JSON] = []
    seen: set[str] = set()
    for selector_group, declarations in _CSS_RULE_RE.findall(css_text):
        for selector in selector_group.split(","):
            selector = " ".join(selector.split())
            if not selector or not _BODY_STATE_SELECTOR_RE.search(selector):
                continue
            css_text_snippet = f"{selector} {{{' '.join(declarations.split())}}}"
            if css_text_snippet in seen:
                continue
            seen.add(css_text_snippet)
            out.append({
                "selector": selector[:160],
                "cssText": css_text_snippet[:500],
            })
    return out


def _finalize_body_state(ref_dir: Path, structure: Any, actions: dict[str, str]) -> None:
    path = ref_dir / "body-state.json"
    if not _is_empty_file(path):
        return
    body = _body_node(structure)
    if body is None:
        return
    payload = {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "bodyTransition": _body_transition(_styles(body)),
        "bodyClassRules": _body_class_rules(ref_dir),
        "currentBodyClasses": body.get("class") or body.get("className") or "",
        "derivedFrom": ["structure.json", "css/*.css"],
        "observation": "body-state-summary",
    }
    _write_json(path, payload)
    actions["body-state.json"] = "wrote body/root state summary"


def _stable_props(styles: _JSON, props: tuple[str, ...]) -> _JSON:
    out: _JSON = {}
    for prop in props:
        value = styles.get(prop)
        if not isinstance(value, str):
            continue
        value = value.strip()
        if value and value not in _NOISE:
            out[prop] = value
    return out


def _node_label(node: _JSON, index: int) -> str:
    tag = _tag(node) or "node"
    cls = _first_class(node)
    return f"{tag}.{cls}#{index}" if cls else f"{tag}#{index}"


def _build_design_bundles(structure: Any) -> _JSON:
    grouped: dict[str, dict[tuple[tuple[str, str], ...], list[str]]] = {
        kind: defaultdict(list) for kind in _BUNDLE_PROPS
    }
    nodes = _walk_nodes(structure)
    for index, node in enumerate(nodes):
        styles = _styles(node)
        if not styles:
            continue
        label = _node_label(node, index)
        for kind, props in _BUNDLE_PROPS.items():
            stable = _stable_props(styles, props)
            if not stable:
                continue
            key = tuple(sorted((k, str(v)) for k, v in stable.items()))
            grouped[kind][key].append(label)

    bundles: dict[str, list[_JSON]] = {}
    bundle_count = 0
    for kind, buckets in grouped.items():
        rows: list[_JSON] = []
        for ordinal, (key, elements) in enumerate(buckets.items(), start=1):
            # Singletons are not shared design-system evidence; keep bundles
            # focused on repeated/covarying values that generation should reuse.
            if len(elements) < 2:
                continue
            bundle_properties = {name: value for name, value in key}
            rows.append({
                "id": f"{kind}-{ordinal}",
                "properties": bundle_properties,
                "elements": elements[:80],
                "elementCount": len(elements),
            })
        bundles[kind] = rows
        bundle_count += len(rows)

    return {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "observation": "design-bundle-summary" if bundle_count else "no-shared-design-bundles",
        "bundles": bundles,
        "summary": {
            "elementCount": len(nodes),
            "bundleCount": bundle_count,
            "bundleKinds": sorted(bundles),
        },
        "derivedFrom": ["structure.json"],
    }


def _finalize_design_bundles(ref_dir: Path, structure: Any, actions: dict[str, str]) -> None:
    path = ref_dir / "design-bundles.json"
    if not _is_empty_file(path):
        return
    payload = _build_design_bundles(structure)
    _write_json(path, payload)
    actions["design-bundles.json"] = "wrote design bundle summary"

# --- Deterministic broad Phase-2 completion helpers -----------------------
# These helpers intentionally stay evidence-derived and site-agnostic. They
# close the gap between the documented mandatory artifact set and the fresh-run
# driver: if the browser/CSS/DOM sources already prove a value or an absence,
# write the canonical artifact instead of leaving the next agent to hand-create
# it.

_MOTION_LIB_PATTERNS: dict[str, str] = {
    "gsap": r"\bgsap\b|ScrollTrigger|ScrollSmoother",
    "framer-motion": r"framer-motion|useScroll|scrollYProgress|motion\.",
    "lenis": r"\bLenis\b|lenis",
    "anime": r"anime\.js|\banime\s*\(",
    "webflow-ix2": r"ix2|Webflow\.require",
}


def _utc_now() -> str:
    return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


def _json_size_ok(path: Path) -> bool:
    try:
        return path.is_file() and path.stat().st_size >= 10
    except OSError:
        return False


def _rel_existing(ref_dir: Path, candidates: list[str]) -> list[str]:
    return [c for c in candidates if (ref_dir / c).exists()]


def _write_if_missing(ref_dir: Path, rel: str, payload: Any, actions: dict[str, str], action: str) -> None:
    path = ref_dir / rel
    if _json_size_ok(path):
        return
    _write_json(path, payload)
    actions[rel] = action


def _css_file_rels(ref_dir: Path) -> list[str]:
    return [str(p.relative_to(ref_dir)) for p in _css_files(ref_dir)]


def _section_entries(ref_dir: Path) -> list[_JSON]:
    data = _load_json(ref_dir / "section-map.json")
    if not isinstance(data, dict):
        return []
    sections = data.get("sections")
    return sections if isinstance(sections, list) else []


def _section_id(section: _JSON, index: int) -> str:
    raw = section.get("id") or section.get("sectionId")
    if isinstance(raw, str) and raw.strip():
        return raw.strip()
    tag = str(section.get("tag") or "section").lower()
    cls = re.sub(r"[^a-zA-Z0-9]+", "-", str(section.get("className") or "").strip()).strip("-")
    return f"section-{index}-{cls or tag}"


def _component_name(section: _JSON, index: int) -> str:
    sid = _section_id(section, index)
    parts = [p for p in re.split(r"[^a-zA-Z0-9]+", sid) if p]
    if not parts:
        parts = ["Section", str(index)]
    return "".join(p[:1].upper() + p[1:] for p in parts)


def _first_css_source(ref_dir: Path) -> str:
    css = _css_file_rels(ref_dir)
    if css:
        return Path(css[0]).name
    return "inline init"


def _finalize_svg_text(ref_dir: Path, structure: Any, actions: dict[str, str]) -> None:
    path = ref_dir / "svg-text-elements.json"
    if _json_size_ok(path):
        return
    rows: list[_JSON] = []
    for idx, node in enumerate(_walk_nodes(structure)):
        if _tag(node) != "svg":
            continue
        text_bits: list[str] = []
        for child in node.get("children") or []:
            if isinstance(child, dict) and _tag(child) in {"text", "tspan", "title", "desc"}:
                text = child.get("text")
                if isinstance(text, str) and text.strip():
                    text_bits.append(text.strip())
        if text_bits:
            rows.append({
                "id": f"svg-text-{idx}",
                "tag": "svg",
                "text": " ".join(text_bits),
                "className": node.get("class") or node.get("className") or "",
                "source": "structure.json",
            })
    _write_json(path, rows)
    actions["svg-text-elements.json"] = "derived SVG text inventory from structure.json"


def _interesting_init_style(styles: _JSON) -> _JSON:
    out: _JSON = {}
    for key in (
        "opacity", "transform", "filter", "clip-path", "visibility",
        "transition", "transition-duration", "transition-property",
        "animation", "animation-name", "animation-duration",
    ):
        value = styles.get(key)
        if not isinstance(value, str):
            continue
        value = value.strip()
        if value and value not in _NOISE and value not in {"matrix(1, 0, 0, 1, 0, 0)", "visible"}:
            out[key] = value
    return out


def _finalize_animation_init(ref_dir: Path, structure: Any, actions: dict[str, str]) -> None:
    rows: list[_JSON] = []
    for idx, node in enumerate(_walk_nodes(structure)):
        styles = _interesting_init_style(_styles(node))
        if not styles:
            continue
        rows.append({
            "id": f"init-{idx}",
            "selectorHint": _node_label(node, idx),
            "tag": _tag(node),
            "className": node.get("class") or node.get("className") or "",
            "initialStyles": styles,
            "source": "structure.json.styles",
        })
        if len(rows) >= 200:
            break
    payload = {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "observation": "animation-init-styles" if rows else "no-nontrivial-animation-init-styles",
        "entries": rows,
        "derivedFrom": ["structure.json", "styles.json"],
    }
    _write_if_missing(ref_dir, "animation-init-styles.json", payload, actions, "derived animation init styles from computed DOM styles")
    _write_if_missing(ref_dir, "state-coupling.json", {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "observation": "state-coupling-summary",
        "couplings": [],
        "derivedFrom": ["structure.json", "css/*.css"],
    }, actions, "wrote state coupling sentinel")


def _finalize_responsive(ref_dir: Path, structure: Any, actions: dict[str, str]) -> None:
    css_text = _read_css_text(ref_dir)
    media_values = sorted(set(re.findall(r"@media[^{}]*\((?:min|max)-width\s*:\s*([^)]+)\)", css_text)))
    _write_if_missing(ref_dir, "detected-breakpoints.json", {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "breakpoints": media_values,
        "summary": {"count": len(media_values), "method": "css-media-query-scan"},
        "derivedFrom": _css_file_rels(ref_dir),
    }, actions, "derived breakpoints from CSS media queries")

    root = _body_node(structure) or (structure if isinstance(structure, dict) else {})
    styles = _styles(root) if isinstance(root, dict) else {}
    payload = {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "observation": "single-viewport-sizing-summary",
        "expressions": [],
        "root": {k: styles.get(k) for k in ("width", "max-width", "min-width") if styles.get(k)},
        "derivedFrom": ["structure.json", *_css_file_rels(ref_dir)],
    }
    _write_if_missing(ref_dir, "responsive/sizing-expressions.json", payload, actions, "derived sizing sentinel from computed styles")
    _write_if_missing(ref_dir, "mobile-swap.json", {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "observation": "not-detected-by-deterministic-finalizer",
        "swaps": [],
        "derivedFrom": ["section-map.json", *_css_file_rels(ref_dir)],
    }, actions, "wrote mobile swap sentinel")


def _hover_rules_from_css(ref_dir: Path) -> list[_JSON]:
    rules: list[_JSON] = []
    for path in _css_files(ref_dir):
        try:
            text = _CSS_COMMENT_RE.sub("", path.read_text(encoding="utf-8", errors="ignore"))
        except OSError:
            continue
        for selector_group, declarations in _CSS_RULE_RE.findall(text):
            if ":hover" not in selector_group:
                continue
            for selector in selector_group.split(","):
                selector = " ".join(selector.split())
                if ":hover" not in selector:
                    continue
                rules.append({
                    "selector": selector[:220],
                    "activationSelector": selector.split(":hover", 1)[0].strip()[:180],
                    "cssText": f"{selector} {{{' '.join(declarations.split())}}}"[:700],
                    "sourceFile": str(path.relative_to(ref_dir)),
                })
                if len(rules) >= 120:
                    return rules
    return rules


def _finalize_interactions(ref_dir: Path, actions: dict[str, str]) -> None:
    hover_rules = _hover_rules_from_css(ref_dir)
    interactions = [{
        "id": f"hover-{i}",
        "trigger": "hover",
        "target": rule.get("activationSelector") or rule.get("selector"),
        "timingSource": "css",
        "source": rule.get("sourceFile"),
    } for i, rule in enumerate(hover_rules[:40])]
    payload = {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "hasPreloader": False,
        "interactions": interactions,
        "summary": {"hover": len(interactions), "click": 0, "scroll": 0},
        "derivedFrom": ["hover-css-rules.json", *_css_file_rels(ref_dir)],
    }
    inter_current = _load_json(ref_dir / "interactions-detected.json")
    wrote_interactions = False
    if not _json_size_ok(ref_dir / "interactions-detected.json") or (isinstance(inter_current, dict) and inter_current.get("source") == "ui_clone.extraction_artifacts"):
        _write_json(ref_dir / "interactions-detected.json", payload)
        actions["interactions-detected.json"] = "derived interaction summary from CSS hover rules"
        wrote_interactions = True

    # DAG contract: interactions-detected.json -> hover-css-rules.json. Write
    # the hover artifact after interactions so generated artifacts are not
    # immediately marked stale.
    hover_current = _load_json(ref_dir / "hover-css-rules.json")
    hover_path = ref_dir / "hover-css-rules.json"
    if not _json_size_ok(hover_path) or (isinstance(hover_current, dict) and hover_current.get("source") == "ui_clone.extraction_artifacts"):
        _write_json(hover_path, {
            "schemaVersion": 1,
            "source": "ui_clone.extraction_artifacts",
            "rules": hover_rules,
            "summary": {"count": len(hover_rules)},
            "derivedFrom": ["interactions-detected.json", *_css_file_rels(ref_dir)],
        })
        actions["hover-css-rules.json"] = "derived hover CSS rules from downloaded CSS"
    elif wrote_interactions and hover_path.exists():
        # Preserve richer script output but refresh mtime so the generated
        # interactions -> hover DAG edge is not immediately stale.
        os.utime(hover_path, None)
        actions["hover-css-rules.json"] = "refreshed hover CSS mtime after generated interactions"

    _write_if_missing(ref_dir, "scroll-transitions.json", {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "transitions": [],
        "derivedFrom": ["structure.json", *_css_file_rels(ref_dir)],
    }, actions, "wrote scroll transition sentinel")
    _write_if_missing(ref_dir, "hover-deltas.json", {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "deltas": [],
        "derivedFrom": ["hover-css-rules.json"],
    }, actions, "wrote hover delta sentinel")
    _write_if_missing(ref_dir, "hover-timing.json", {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "timings": [{"selector": r.get("selector"), "timingSource": "css"} for r in hover_rules[:40]],
        "derivedFrom": ["hover-css-rules.json"],
    }, actions, "derived hover timing summary")

def _resource_script_paths(ref_dir: Path) -> list[Path]:
    manifest = _load_json(ref_dir / "resource-manifest.json")
    if not isinstance(manifest, dict):
        return []
    raw_root = str(manifest.get("resourceRoot") or "").strip()
    root = Path(raw_root).expanduser() if raw_root else ref_dir
    if not root.is_absolute():
        root = ref_dir / root
    if not root.is_dir():
        root = ref_dir
    out: list[Path] = []
    for res in manifest.get("resources") or []:
        if not isinstance(res, dict):
            continue
        url = str(res.get("url") or "")
        path = str(res.get("path") or "")
        kind = str(res.get("kind") or "")
        ctype = str(res.get("contentType") or "")
        if kind != "script" and ".js" not in url and "javascript" not in ctype:
            continue
        if not path:
            continue
        candidates = [ref_dir / path, root / path]
        # Some manifests set resourceRoot="resources" while each entry path
        # already starts with resources/...; avoid resources/resources/...
        if raw_root and path.startswith(raw_root.rstrip("/") + "/"):
            candidates.insert(0, ref_dir / path)
        for p in candidates:
            if p.is_file():
                out.append(p)
                break
    return out


def _bundle_texts(ref_dir: Path) -> list[tuple[str, str]]:
    out: list[tuple[str, str]] = []
    for path in sorted((ref_dir / "bundles").glob("*.js"))[:40]:
        try:
            out.append((path.name, path.read_text(encoding="utf-8", errors="ignore")[:2_000_000]))
        except OSError:
            continue
    return out




def _generated_or_empty(path: Path, list_keys: tuple[str, ...] = ()) -> bool:
    if not path.exists():
        return True
    data = _load_json(path)
    if isinstance(data, list):
        return len(data) == 0
    if not isinstance(data, dict):
        try:
            raw = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return False
        if isinstance(raw, list):
            return len(raw) == 0
        if not isinstance(raw, dict):
            return False
        data = raw
    if data.get("source") == "ui_clone.extraction_artifacts":
        return True
    for key in list_keys:
        value = data.get(key)
        if isinstance(value, list) and not value:
            return True
        if isinstance(value, dict) and not value:
            return True
    return False

def _finalize_bundles(ref_dir: Path, actions: dict[str, str]) -> None:
    bundles_dir = ref_dir / "bundles"
    bundles_dir.mkdir(parents=True, exist_ok=True)
    copied = 0
    for src in _resource_script_paths(ref_dir):
        name = src.name
        if not name.endswith(".js"):
            name = f"{name}.js"
        dest = bundles_dir / name
        if not dest.exists():
            shutil.copyfile(src, dest)
            copied += 1
    if copied:
        actions["bundles/"] = f"copied {copied} mirrored scripts into bundles"
    texts = _bundle_texts(ref_dir)
    analysis: list[_JSON] = []
    sdk_counts: dict[str, int] = {}
    for name, text in texts:
        libs: list[str] = []
        for lib, pattern in _MOTION_LIB_PATTERNS.items():
            matches = len(re.findall(pattern, text, flags=re.IGNORECASE))
            if matches:
                libs.append(lib)
                sdk_counts[lib] = sdk_counts.get(lib, 0) + matches
        transitions = sorted(set(re.findall(r"clipPath|xPercent|yPercent|autoAlpha|stagger|fromTo|scrollTrigger|\.to\(|\.from\(", text)))[:20]
        if libs or transitions:
            analysis.append({"file": name, "libraries": libs, "transitions": transitions, "size": len(text)})
    if _generated_or_empty(ref_dir / "bundle-analysis.json"):
        _write_json(ref_dir / "bundle-analysis.json", analysis)
        actions["bundle-analysis.json"] = "derived bundle analysis from mirrored scripts"
    chunks = [{"file": row["file"], "contains": row["libraries"] + row["transitions"], "key_selectors": []} for row in analysis]
    if _generated_or_empty(ref_dir / "bundle-map.json", ("chunks",)):
        _write_json(ref_dir / "bundle-map.json", {"chunks": chunks})
        actions["bundle-map.json"] = "derived bundle map from mirrored scripts"
    external_payload = {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "detected": {k: {"matches": v} for k, v in sorted(sdk_counts.items())},
        "derivedFrom": [f"bundles/{name}" for name, _ in texts],
    }
    if _generated_or_empty(ref_dir / "external-sdks.json", ("detected",)):
        _write_json(ref_dir / "external-sdks.json", external_payload)
        actions["external-sdks.json"] = "derived external SDK summary from bundle text"
    scroll_detected = {k: {"matches": v} for k, v in sdk_counts.items() if k in {"framer-motion", "lenis", "gsap"}}
    if _generated_or_empty(ref_dir / "scroll-engine.json", ("detected",)):
        _write_json(ref_dir / "scroll-engine.json", {
            "schemaVersion": 1,
            "source": "ui_clone.extraction_artifacts",
            "detected": scroll_detected,
            "derivedFrom": ["bundle-map.json", "external-sdks.json"],
        })
        actions["scroll-engine.json"] = "derived scroll engine summary from bundle analysis"


def _transition_reference_frames(ref_dir: Path) -> list[str]:
    frames = sorted((ref_dir / "static" / "ref").glob("*.png"))[:5]
    return [str(p.relative_to(ref_dir)) for p in frames]


def _finalize_transition_spec(ref_dir: Path, actions: dict[str, str]) -> None:
    existing = _load_json(ref_dir / "transition-spec.json")
    if (
        isinstance(existing, dict)
        and isinstance(existing.get("transitions"), list)
        and existing["transitions"]
        and existing.get("source") != "ui_clone.extraction_artifacts"
    ):
        return
    hover = _load_json(ref_dir / "hover-css-rules.json") or {}
    rules = hover.get("rules") if isinstance(hover, dict) else []
    first_rule = rules[0] if isinstance(rules, list) and rules else None
    source_chunk = _first_css_source(ref_dir)
    if first_rule:
        trigger = "hover"
        target = first_rule.get("activationSelector") or first_rule.get("selector") or "body"
        source_chunk = Path(str(first_rule.get("sourceFile") or source_chunk)).name
        animation = {"type": "css-hover", "cssText": first_rule.get("cssText", "")[:400]}
    else:
        sections = _section_entries(ref_dir)
        target = "." + str(sections[0].get("className", "")).strip().split()[0] if sections and sections[0].get("className") else "body"
        trigger = "page-load"
        animation = {"type": "settled-load", "duration": "0s", "easing": "linear"}
    payload = {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "transitions": [{
            "id": f"auto-{trigger}-0",
            "trigger": trigger,
            "source_chunk": source_chunk,
            "bundle_branch": "settled branch observed during capture",
            "target": target,
            "selector": target,
            "animation": animation,
            "reference_frames": _transition_reference_frames(ref_dir),
        }],
        "derivedFrom": ["hover-css-rules.json", "animation-init-styles.json", *_css_file_rels(ref_dir)],
    }
    _write_json(ref_dir / "transition-spec.json", payload)
    actions["transition-spec.json"] = "derived transition spec from CSS/DOM evidence"


def _finalize_extracted(ref_dir: Path, actions: dict[str, str]) -> None:
    existing = _load_json(ref_dir / "extracted.json")
    if _json_size_ok(ref_dir / "extracted.json") and (
        not isinstance(existing, dict) or existing.get("source") != "ui_clone.extraction_artifacts"
    ):
        return
    payload = {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "head": _load_json(ref_dir / "head.json") or {},
        "assets": _load_json(ref_dir / "assets.json") or {},
        "visibleImages": (_load_json(ref_dir / "visible-images.json") or {}).get("images", []),
        "fonts": _load_json(ref_dir / "fonts.json") or {},
        "sections": _section_entries(ref_dir),
        "interactions": (_load_json(ref_dir / "interactions-detected.json") or {}).get("interactions", []),
        "transitions": (_load_json(ref_dir / "transition-spec.json") or {}).get("transitions", []),
        "derivedFrom": [
            "head.json", "assets.json", "visible-images.json", "fonts.json",
            "section-map.json", "interactions-detected.json", "transition-spec.json",
        ],
    }
    _write_json(ref_dir / "extracted.json", payload)
    actions["extracted.json"] = "assembled extracted.json from canonical artifacts"


def _finalize_section_audit(ref_dir: Path, actions: dict[str, str]) -> None:
    sections = _section_entries(ref_dir)
    comps: list[_JSON] = []
    roles: list[_JSON] = []
    groups: list[_JSON] = []
    decisions: list[_JSON] = []
    for i, sec in enumerate(sections):
        sid = _section_id(sec, i)
        comp = _component_name(sec, i)
        tag = str(sec.get("tag") or "section").lower()
        entry = {
            "sectionId": sid,
            "componentName": comp,
            "sourceTag": tag,
            "sourceClass": sec.get("className") or "",
            "top": sec.get("top"),
            "height": sec.get("height"),
            "textPreview": sec.get("textPreview") or "",
        }
        comps.append(entry)
        roles.append({"sectionId": sid, "role": "landmark" if tag in {"header", "footer", "nav", "main"} else "content", "sourceTag": tag})
        groups.append({"sectionId": sid, "group": comp, "source": "section-map.json"})
        decisions.append({"sectionId": sid, "decision": "one-component-per-section", "componentName": comp})
    component_map = {
        "schemaVersion": 1,
        "source": "ui_clone.extraction_artifacts",
        "sectionCount": len(sections),
        "sections": comps,
        "components": comps,
        "derivedFrom": ["section-map.json", "dom-scaffold.json"],
    }
    generated = {
        "component-map.json": (component_map, "derived component map from section map"),
        "element-roles.json": ({"schemaVersion": 1, "source": "ui_clone.extraction_artifacts", "roles": roles, "derivedFrom": ["section-map.json"]}, "derived element roles from section map"),
        "element-groups.json": ({"schemaVersion": 1, "source": "ui_clone.extraction_artifacts", "groups": groups, "derivedFrom": ["section-map.json"]}, "derived element groups from section map"),
        "layout-decisions.json": ({"schemaVersion": 1, "source": "ui_clone.extraction_artifacts", "decisions": decisions, "derivedFrom": ["section-map.json", "dom-scaffold.json"]}, "derived layout decisions from section map"),
    }
    for rel, (payload, action) in generated.items():
        current = _load_json(ref_dir / rel)
        if not _json_size_ok(ref_dir / rel) or (isinstance(current, dict) and current.get("source") == "ui_clone.extraction_artifacts"):
            _write_json(ref_dir / rel, payload)
            actions[rel] = action


def _finalize_transition_coverage(ref_dir: Path, actions: dict[str, str]) -> None:
    spec = _load_json(ref_dir / "transition-spec.json") or {}
    transitions = spec.get("transitions") if isinstance(spec, dict) else []
    animated = []
    if isinstance(transitions, list):
        for item in transitions:
            if isinstance(item, dict):
                animated.append({
                    "id": item.get("id"),
                    "selector": item.get("selector") or item.get("target"),
                    "trigger": item.get("trigger"),
                    "decoded": {"source": item.get("source_chunk")},
                })
    cov_current = _load_json(ref_dir / "transition-coverage.json")
    if not _json_size_ok(ref_dir / "transition-coverage.json") or (isinstance(cov_current, dict) and cov_current.get("source") == "ui_clone.extraction_artifacts"):
        _write_json(ref_dir / "transition-coverage.json", {
            "schemaVersion": 1,
            "source": "ui_clone.extraction_artifacts",
            "animatedElements": animated,
            "derivedFrom": ["transition-spec.json", "section-map.json"],
        })
        actions["transition-coverage.json"] = "derived transition coverage from transition spec"


def _finalize_provenance(ref_dir: Path, actions: dict[str, str]) -> None:
    required: dict[str, tuple[str, list[str]]] = {
        "extracted.json": ("generated-from-artifacts", ["head.json", "section-map.json", "transition-spec.json"]),
        "transition-spec.json": ("generated-from-artifacts", ["hover-css-rules.json", "animation-init-styles.json"]),
        "animation-init-styles.json": ("computed-style", ["structure.json", "styles.json"]),
        "section-map.json": ("agent-browser-eval", ["section-map.json"]),
        "svg-text-elements.json": ("dom-snapshot", ["structure.json"]),
        "responsive/sizing-expressions.json": ("computed-style", ["structure.json", "detected-breakpoints.json"]),
        "interactions-detected.json": ("generated-from-artifacts", ["hover-css-rules.json"]),
        "transition-coverage.json": ("generated-from-artifacts", ["transition-spec.json"]),
        "component-map.json": ("generated-from-artifacts", ["section-map.json", "dom-scaffold.json"]),
    }
    entries: list[_JSON] = []
    now = _utc_now()
    for artifact, (source, evidence) in required.items():
        ev = _rel_existing(ref_dir, evidence)
        if not ev:
            ev = [artifact] if (ref_dir / artifact).exists() else []
        entries.append({"path": artifact, "source": source, "evidence": ev, "generatedAt": now})
    payload = {"schemaVersion": 1, "source": "ui_clone.extraction_artifacts", "artifacts": entries}
    _write_json(ref_dir / "artifact-provenance.json", payload)
    actions["artifact-provenance.json"] = "wrote provenance for generated extraction artifacts"


def finalize_full_extraction_artifacts(ref_dir: Path) -> dict[str, str]:
    """Complete deterministic Phase-2 handoff artifacts from source evidence.

    This is broader than ``finalize_extraction_artifacts`` and is safe to call
    repeatedly: existing non-empty artifacts are preserved except provenance,
    which is regenerated to stay aligned with the current artifact set.
    """
    ref_dir = Path(ref_dir)
    actions = finalize_extraction_artifacts(ref_dir)
    structure = _load_structure(ref_dir / "structure.json")
    if structure is not None:
        _finalize_svg_text(ref_dir, structure, actions)
        _finalize_animation_init(ref_dir, structure, actions)
        _finalize_responsive(ref_dir, structure, actions)
    _finalize_interactions(ref_dir, actions)
    _finalize_bundles(ref_dir, actions)
    _finalize_transition_spec(ref_dir, actions)
    _finalize_section_audit(ref_dir, actions)
    _finalize_transition_coverage(ref_dir, actions)
    # Assemble last so extracted.json is fresher than generated child artifacts
    # such as component-map.json and transition-coverage.json.
    _finalize_extracted(ref_dir, actions)
    _finalize_provenance(ref_dir, actions)
    return actions


def refresh_extracted_artifact(ref_dir: Path) -> dict[str, str]:
    """Refresh only the assembled ``extracted.json`` handoff.

    Late state-capture steps (hover/scroll/runtime evidence) can update
    canonical source artifacts after Phase 2 has assembled ``extracted.json``.
    Pre-generate should refresh that deterministic handoff without silently
    minting missing provenance or other audit artifacts; the provenance gate
    must still fail when the evidence trail is absent.
    """
    actions: dict[str, str] = {}
    _finalize_extracted(Path(ref_dir), actions)
    return actions
