#!/usr/bin/env bash
# transition-compare.sh — Compare hover/transition behavior between original and implementation
#
# Usage: bash transition-compare.sh <orig-url> <impl-url> <session> [output-dir]
#
# For each element with CSS transitions on both sites:
# 1. Captures idle state (screenshot + computedStyle)
# 2. Simulates hover (mouseenter dispatch)
# 3. Captures hover state (screenshot + computedStyle)
# 4. Diffs idle/hover computedStyle between ref and impl
# 5. Compares transition timing (duration, easing, delay)
#
# Output: <dir>/transitions/report.json
#         <dir>/transitions/{ref,impl}/{element}-{idle,hover}.png

set -euo pipefail

# Source the timeout shim so macOS gets a working `timeout` cmd even when
# coreutils isn't installed. See scripts/lib/timeout-shim.sh.
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_SHIM="$_SCRIPT_DIR/../../../scripts/lib/timeout-shim.sh"
[ -f "$_SHIM" ] && . "$_SHIM" || true

VIEW_W="${VIEW_W:-1440}"
VIEW_H="${VIEW_H:-900}"
NO_IMAGES="${NO_IMAGES:-0}"
WAIT_REF="${WAIT_REF:-8000}"
WAIT_IMPL="${WAIT_IMPL:-6000}"
TRANSITION_WAIT="${TRANSITION_WAIT:-500}"   # ms to wait after hover before screenshot
MAX_TRANSITIONS="${MAX_TRANSITIONS:-30}"    # max semantic candidates to collect
COMPARE_LIMIT="${COMPARE_LIMIT:-20}"        # max top-priority candidates to hard-compare
# CSS selector(s) to exclude from ref detection (e.g. third-party SDK overlays not in the clone).
# Default skips Finsweet Cookie Consent (`.fs-cc_*`) — the clone never replicates the consent SDK.
EXCLUDE_SELECTORS="${EXCLUDE_SELECTORS:-[class*=fs-cc], [id*=cookie], [class*=cookie-banner], [class*=consent]}"

ORIG_URL="${1:?Usage: transition-compare.sh <orig-url> <impl-url> <session> [output-dir]}"
IMPL_URL="${2:?Usage: transition-compare.sh <orig-url> <impl-url> <session> [output-dir]}"
SESSION="${3:?Usage: transition-compare.sh <orig-url> <impl-url> <session> [output-dir]}"
DIR="${4:-tmp/ref/visual-debug}"

# Fail fast on swapped arguments. A URL passed as <session> becomes an invalid
# agent-browser session name whose daemon dies at startup with no stderr —
# "Daemon process exited during startup with no error output" gives the caller
# nothing to act on, so validate here instead.
case "$ORIG_URL" in
  http://*|https://*) ;;
  *) echo "transition-compare: <orig-url> must be http(s)://… (got: $ORIG_URL). Arg order is <orig-url> <impl-url> <session> [output-dir]." >&2; exit 2 ;;
esac
case "$IMPL_URL" in
  http://*|https://*) ;;
  *) echo "transition-compare: <impl-url> must be http(s)://… (got: $IMPL_URL). Arg order is <orig-url> <impl-url> <session> [output-dir]." >&2; exit 2 ;;
esac
case "$SESSION" in
  *[!A-Za-z0-9._-]*|"") echo "transition-compare: <session> must be a slug ([A-Za-z0-9._-]+, got: $SESSION). Arg order is <orig-url> <impl-url> <session> [output-dir]." >&2; exit 2 ;;
esac

# Convert relative path to absolute (Stop gate uses absolute paths, result.txt lookup breaks otherwise)
if [[ "$DIR" != /* ]]; then
  DIR="$(pwd)/$DIR"
fi

SESSION_REF="${SESSION}-tc-ref"
SESSION_IMPL="${SESSION}-tc-impl"

_TC_PY=""  # set later; declare here so cleanup_all can reference it
cleanup_all() {
  agent-browser --session "$SESSION_REF" close 2>/dev/null || true
  agent-browser --session "$SESSION_IMPL" close 2>/dev/null || true
  [ -n "$_TC_PY" ] && rm -f "$_TC_PY"
  return 0
}
trap cleanup_all EXIT

mkdir -p "$DIR/transitions/ref" "$DIR/transitions/impl"

echo "═══ Transition Comparison ═══"
echo "Original: $ORIG_URL"
echo "Implementation: $IMPL_URL"
echo ""

# ── Open both sites ──
# `open` may report a navigation timeout on slow third-party sites even when the
# page eventually loads. Tolerate that — the explicit `wait` below settles state.
echo "▸ Opening both sites..."
ref_open_out="$(agent-browser --session "$SESSION_REF" open "$ORIG_URL" 2>&1 || true)"
printf '%s\n' "$ref_open_out" | head -1
impl_open_out="$(agent-browser --session "$SESSION_IMPL" open "$IMPL_URL" 2>&1 || true)"
printf '%s\n' "$impl_open_out" | head -1

# Navigation timeouts are tolerated (wait below settles state), but a session
# whose daemon never came up means every later eval silently no-ops — probe
# liveness once and surface the captured open output instead of cascading.
if ! agent-browser --session "$SESSION_REF" eval '(() => 1)()' > /dev/null 2>&1; then
  echo "transition-compare: ref session '$SESSION_REF' failed to start: $ref_open_out" >&2
  exit 1
fi
if ! agent-browser --session "$SESSION_IMPL" eval '(() => 1)()' > /dev/null 2>&1; then
  echo "transition-compare: impl session '$SESSION_IMPL' failed to start: $impl_open_out" >&2
  exit 1
fi

agent-browser --session "$SESSION_REF" set viewport "$VIEW_W" "$VIEW_H" > /dev/null 2>&1
agent-browser --session "$SESSION_IMPL" set viewport "$VIEW_W" "$VIEW_H" > /dev/null 2>&1

agent-browser --session "$SESSION_REF" wait "$WAIT_REF" > /dev/null 2>&1
agent-browser --session "$SESSION_IMPL" wait "$WAIT_IMPL" > /dev/null 2>&1

# Remove overlays
DISMISS='(() => {
  document.querySelectorAll("[class*=popup], [class*=modal], [class*=signup]").forEach(el => {
    const s = getComputedStyle(el);
    if (s.position === "fixed" || s.position === "absolute") el.remove();
  });
  document.body.style.overflow = "";
  document.documentElement.style.overflow = "";
  return "ok";
})()'
agent-browser --session "$SESSION_REF" eval "$DISMISS" 2>&1 > /dev/null
agent-browser --session "$SESSION_IMPL" eval "$DISMISS" 2>&1 > /dev/null

# Hide images to reduce AE noise from dynamic thumbnails
HIDE_IMAGES_JS='(() => {
  const style = document.createElement("style");
  style.id = "__no_images__";
  style.textContent = "img, picture, video, iframe { visibility: hidden !important; }";
  document.head.appendChild(style);
  document.querySelectorAll("*").forEach(el => {
    if (el.style && el.style.backgroundImage) el.style.backgroundImage = "none";
  });
})()'

# Hide <canvas> elements (WebGL/Three.js/etc.) — their content is dynamic per-frame.
HIDE_CANVAS_JS='(() => {
  const style = document.createElement("style");
  style.id = "__no_canvas__";
  style.textContent = "canvas { visibility: hidden !important; }";
  document.head.appendChild(style);
})()'

if [ "$NO_IMAGES" = "1" ]; then
  echo "▸ Hiding images (NO_IMAGES=1)..."
  agent-browser --session "$SESSION_REF" eval "$HIDE_IMAGES_JS" 2>/dev/null || true
  agent-browser --session "$SESSION_IMPL" eval "$HIDE_IMAGES_JS" 2>/dev/null || true
fi

if [ "${NO_CANVAS:-0}" = "1" ]; then
  echo "▸ Hiding canvases (NO_CANVAS=1)..."
  agent-browser --session "$SESSION_REF" eval "$HIDE_CANVAS_JS" 2>/dev/null || true
  agent-browser --session "$SESSION_IMPL" eval "$HIDE_CANVAS_JS" 2>/dev/null || true
fi

# ── Step 1: Find elements with transitions on the original ──
echo "▸ Detecting transition elements..."

DETECT_TRANSITIONS='(() => {
  const results = [];
  const seen = new Set();
  const allEls = document.querySelectorAll("a, button, [role=button], img, .product-card, [class*=card], [class*=link], [class*=hover], [class*=btn], nav a, footer a, h1, h2, h3");
  const EXCLUDE = ${EXCLUDE_SELECTORS_JSON};

  allEls.forEach(el => {
    if (EXCLUDE && el.closest(EXCLUDE)) return;
    const rect = el.getBoundingClientRect();
    const offscreenVertical = rect.bottom < 0 || rect.top > window.innerHeight;
    const cs = getComputedStyle(el);
    for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
      const ns = getComputedStyle(n);
      if (ns.display === "none" || ns.visibility === "hidden") return;
      // Offscreen reveal wrappers often start at opacity:0 until scrolled into
      // view. transition-compare scrolls each candidate before hover capture,
      // so dropping those candidates here hides exactly the scroll-triggered
      // CTA/tab controls this gate must verify.
      if (Number.parseFloat(ns.opacity || "1") < 0.05 && !offscreenVertical) return;
    }
    const hasTrans = cs.transitionDuration !== "0s" && cs.transitionProperty !== "none";
    const hasAnim = cs.animationName !== "none";

    if (!hasTrans && !hasAnim) return;

    if (rect.width < 10 || rect.height < 10) return;
    if (rect.top + window.scrollY > document.documentElement.scrollHeight) return;

    // Build a selector that identifies THIS semantic element, not just its
    // first shared utility/tracking class. Sites commonly stamp the same
    // tracking class on logos, nav links, footer icons, and buttons. Collapsing
    // those to one selector makes the verifier compare unrelated controls and
    // lets real hover bugs slip through. Prefer a unique id/class; otherwise
    // emit a bounded structural path with nth-of-type.
    const GENERIC_CLASSES = new Set([
      "nclick-target", "swiper-slide", "swiper-wrapper", "active", "is-active",
      "is-show", "show", "on", "h_0", "h_1", "h_2", "h_3", "track-animation",
    ]);
    const esc = (v) => (window.CSS && CSS.escape) ? CSS.escape(v) : String(v).replace(/[^A-Za-z0-9_-]/g, "\\$&");
    const usefulClasses = (node) => (
      node.className && typeof node.className === "string"
        ? node.className.split(/\s+/).filter(c =>
            c && c.length < 40 && !c.includes("hover") && !GENERIC_CLASSES.has(c)
          )
        : []
    );
    const firstUniqueClassSelector = (node) => {
      for (const cls of usefulClasses(node)) {
        const sel = "." + esc(cls);
        try {
          if (document.querySelectorAll(sel).length === 1) return sel;
        } catch {}
      }
      return "";
    };
    const pathSelector = (node) => {
      const parts = [];
      let cur = node;
      while (cur && cur.nodeType === 1 && cur !== document.documentElement && parts.length < 6) {
        let part = cur.tagName.toLowerCase();
        if (cur.id) {
          part += "#" + esc(cur.id);
          parts.unshift(part);
          break;
        }
        const cls = usefulClasses(cur).slice(0, 2);
        if (cls.length) part += "." + cls.map(esc).join(".");
        if (cur.parentElement) {
          const siblings = [...cur.parentElement.children].filter(c => c.tagName === cur.tagName);
          if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(cur) + 1})`;
        }
        parts.unshift(part);
        cur = cur.parentElement;
      }
      return parts.join(" > ");
    };

    const tagName = el.tagName.toLowerCase();
    const textKey = (el.textContent || "").trim().replace(/\s+/g, " ").substring(0, 80);
    const hrefKey = el.getAttribute("href") || el.getAttribute("src") || el.getAttribute("aria-label") || el.getAttribute("title") || "";
    // Image motion is verified by image/runtime/live-parity gates. Including
    // every blank carousel/content <img> here turns transition-compare into a
    // dynamic duplicate-slide matcher instead of a user-visible control/state
    // checker.
    if (tagName === "img" && !textKey) return;
    // Content-card carousel anchors are high-volume content tiles. Their hover
    // transforms are covered by card/image/runtime/live-parity checks; letting
    // them consume the first MAX_TRANSITIONS slots hides the smaller global
    // controls that transition-compare is meant to prove (section CTAs, tabs,
    // nav, footer/header links).
    if (
      tagName === "a"
      && el.closest(".masonry-list, .swiper-wrapper, [class*=carousel], [class*=gallery], [class*=news-list], [class*=card-list], [class*=tile-list], [class*=slide]")
      && !el.matches("a[class*=btn], a[class*=button], a[class*=cta], a[class*=nav], a[class*=tab], a[class*=logo], a[class*=social], a[aria-label], nav a, header a, footer a")
    ) return;
    if (rect.left + rect.width < 0 || rect.left > window.innerWidth) return;
    let selector = "";
    if (el.id && document.querySelectorAll("#" + esc(el.id)).length === 1) {
      selector = "#" + esc(el.id);
    }
    selector = selector || firstUniqueClassSelector(el);
    if (!selector) {
      const hasOwnText = [...el.childNodes].some((node) =>
        node.nodeType === Node.TEXT_NODE && (node.textContent || "").trim().length > 0
      );
      const actionable = ["a", "button", "img"].includes(tagName)
        || ["button", "link", "tab"].includes(el.getAttribute("role") || "")
        || Boolean(hasOwnText || hrefKey);
      if (!actionable) return;
      selector = pathSelector(el);
    }
    if (!selector) selector = el.tagName.toLowerCase();

    if (seen.has(selector)) return;
    seen.add(selector);

    // Get transition properties
    const transProps = cs.transitionProperty.split(",").map(p => p.trim());
    const transDurs = cs.transitionDuration.split(",").map(d => d.trim());
    const transEase = cs.transitionTimingFunction.split(",").map(e => e.trim());

    results.push({
      selector,
      tag: tagName,
      text: textKey.substring(0, 40),
      matchKey: {
        tag: tagName,
        text: textKey,
        href: hrefKey,
        role: el.getAttribute("role") || "",
        aria: el.getAttribute("aria-label") || "",
      },
      rect: {
        top: Math.round(rect.top + window.scrollY),
        left: Math.round(rect.left),
        width: Math.round(rect.width),
        height: Math.round(rect.height),
      },
      transition: {
        properties: transProps,
        durations: transDurs,
        easings: transEase,
      },
      idleStyle: {
        opacity: cs.opacity,
        transform: cs.transform,
        backgroundColor: cs.backgroundColor,
        color: cs.color,
        scale: cs.scale || "none",
        filter: cs.filter,
        boxShadow: cs.boxShadow,
      },
    });
  });

  const priority = (item) => {
    const haystack = [
      item.selector || "",
      item.text || "",
      item.matchKey?.role || "",
      item.matchKey?.aria || "",
    ].join(" ").toLowerCase();
    let score = 0;
    if (/(^|[-_\s#.])(btn|button|cta|tab)([-_\s#.]|$)/.test(haystack)) score += 1200;
    if (/(^|[-_\s#.])(nav|logo|social|search|more)([-_\s#.]|$)/.test(haystack)) score += 700;
    if (item.tag === "button" || item.matchKey?.role === "button" || item.matchKey?.role === "tab") score += 300;
    if (item.matchKey?.href && item.matchKey.href !== "#") score += 120;
    if (item.text && item.text.length > 0 && item.text.length <= 60) score += 100;
    // Footer mega-menu links are often numerous duplicates of primary page
    // targets. Keep them comparable, but prefer the primary tab/CTA
    // controls when MAX_TRANSITIONS caps the set.
    if (/(^|[-_\s#.])(menu|sitemap|secondary)([-_\s#.]|$)/.test(haystack)) score -= 200;
    return score;
  };

  return results
    .sort((a, b) => priority(b) - priority(a) || a.rect.top - b.rect.top || a.rect.left - b.rect.left)
    .slice(0, ${MAX_TRANSITIONS});
})()'

# Substitute ${MAX_TRANSITIONS} and ${EXCLUDE_SELECTORS_JSON} into the JS body
# (single-quoted heredoc above blocks bash expansion).
# EXCLUDE_SELECTORS is JSON-encoded so it embeds as a JS string literal safely —
# matches the v0.4.2 hardening discipline that JSON-encodes selectors before eval.
EXCLUDE_SELECTORS_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$EXCLUDE_SELECTORS")
DETECT_TRANSITIONS="${DETECT_TRANSITIONS/\$\{MAX_TRANSITIONS\}/$MAX_TRANSITIONS}"
DETECT_TRANSITIONS="${DETECT_TRANSITIONS/\$\{EXCLUDE_SELECTORS_JSON\}/$EXCLUDE_SELECTORS_JSON}"

agent-browser --session "$SESSION_REF" eval "$DETECT_TRANSITIONS" \
  > "$DIR/transitions/ref-elements.json" \
  2> "$DIR/transitions/ref-elements.stderr.log"
agent-browser --session "$SESSION_IMPL" eval "$DETECT_TRANSITIONS" \
  > "$DIR/transitions/impl-elements.json" \
  2> "$DIR/transitions/impl-elements.stderr.log"

REF_TRANS=$(python3 -c "import json; print(len(json.loads(open('$DIR/transitions/ref-elements.json').read())))" 2>/dev/null || echo "0")
IMPL_TRANS=$(python3 -c "import json; print(len(json.loads(open('$DIR/transitions/impl-elements.json').read())))" 2>/dev/null || echo "0")

echo "  Ref:  $REF_TRANS transition elements"
echo "  Impl: $IMPL_TRANS transition elements"

if [ "$REF_TRANS" -eq 0 ]; then
  echo ""
  echo "  ℹ No transition elements detected on the original site."
  echo "  Possible causes:"
  echo "    1. All transitions are JS-driven (GSAP) — not in getComputedStyle at rest"
  echo "    2. Page not scrolled — transitions may be off-screen"
  echo "    3. Transitions only exist on hover (GSAP mouseenter), not in base CSS"
  echo "  If transitions exist, add custom selectors: bash transition-compare.sh ... then edit DETECT_TRANSITIONS"
  echo ""
  cat > "$DIR/transitions/report.json" <<JSON
[]
JSON
  cat > "$DIR/transitions/result.txt" <<TXT
Transition compare: 0 PASS, 0 FAIL
SKIP no transition elements detected on the original site.
TXT
  echo "═══ Transition Compare Complete ═══"
  echo "  0 elements — skipped"
  echo "  Summary: $DIR/transitions/result.txt"
  exit 0
fi

# ── Step 2: For each ref transition element, capture idle + hover states ──
echo "▸ Capturing idle + hover states..."

# Write hover capture script to a tmpfile — avoids bash quoting issues when
# embedding Python code with single-quotes inside a double-quoted -c argument.
# Trailing X's only — BSD/macOS mktemp does not substitute X's that precede a
# suffix (e.g. `-XXXXXX.py`); it would create a literal `tc-hover-XXXXXX.py`
# and then fail with "File exists" on every later run. python3 runs the file
# regardless of extension, so the `.py` suffix is unnecessary.
_TC_PY=$(mktemp /tmp/tc-hover-XXXXXX)

cat > "$_TC_PY" << 'PYEOF'
import json, re, subprocess, sys, time, os
from pathlib import Path

SESSION_REF     = os.environ["_TC_SESSION_REF"]
SESSION_IMPL    = os.environ["_TC_SESSION_IMPL"]
DIR             = os.environ["_TC_DIR"]
TRANSITION_WAIT = float(os.environ.get("TRANSITION_WAIT", "500")) / 1000  # ms → seconds
SCROLL_WAIT     = float(os.environ.get("_TC_SCROLL_WAIT", "300")) / 1000
COMPARE_LIMIT   = int(os.environ.get("COMPARE_LIMIT", "20"))

# Strip every char outside [A-Za-z0-9._-] so selector-derived filenames cannot
# escape into surrounding shell or path components. Also collapse to ≤30 chars.
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")


def _safe_name(selector: str) -> str:
    s = selector.replace("#", "id-").replace(".", "cls-")
    s = _SAFE_NAME_RE.sub("_", s)
    return s[:30] or "el"


def _ab_eval(session: str, js: str) -> subprocess.CompletedProcess:
    """Run agent-browser eval with argv (shell=False) — safe against selector injection."""
    return subprocess.run(
        ["agent-browser", "--session", session, "eval", js],
        capture_output=True,
        text=True,
    )


def capture_hover_state(session, elements_file, side, out_dir):
    elements = json.loads(Path(elements_file).read_text())
    results = []

    for el in elements[:COMPARE_LIMIT]:
        selector = el["selector"]
        safe_name = _safe_name(selector)
        # JSON-encode the selector so it embeds safely as a JS string literal —
        # quotes, backslashes, and unicode all survive without breaking the JS.
        sel_lit = json.dumps(selector)

        # Scroll to element
        _ab_eval(session, (
            "(() => {"
            f"const el = document.querySelector({sel_lit});"
            "if (!el) return 'not found';"
            "el.scrollIntoView({ block: 'center' });"
            "return 'scrolled';"
            "})()"
        ))
        time.sleep(SCROLL_WAIT)

        idle_path = Path(out_dir) / f"{safe_name}-idle.png"
        subprocess.run(
            ["agent-browser", "--session", session, "screenshot", str(idle_path)],
            capture_output=True,
        )

        # Trigger real CSS :hover via CDP first. Synthetic mouseenter events do
        # not activate stylesheet :hover rules, which hid false positives in
        # clones that invented hover transforms/opacities on unrelated targets.
        subprocess.run(
            ["agent-browser", "--session", session, "hover", selector],
            capture_output=True,
            text=True,
        )
        # Keep synthetic events as a fallback for JS mouseenter handlers.
        _ab_eval(session, (
            "(() => {"
            f"const el = document.querySelector({sel_lit});"
            "if (!el) return 'not found';"
            "el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));"
            "el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));"
            "el.focus?.();"
            "return 'hovered';"
            "})()"
        ))
        time.sleep(TRANSITION_WAIT)  # Wait for transition (TRANSITION_WAIT env var)

        hover_path = Path(out_dir) / f"{safe_name}-hover.png"
        subprocess.run(
            ["agent-browser", "--session", session, "screenshot", str(hover_path)],
            capture_output=True,
        )

        result = _ab_eval(session, (
            "(() => {"
            f"const el = document.querySelector({sel_lit});"
            "if (!el) return JSON.stringify({ error: 'not found' });"
            "const cs = getComputedStyle(el);"
            "return JSON.stringify({"
            "opacity: cs.opacity,"
            "transform: cs.transform,"
            "backgroundColor: cs.backgroundColor,"
            "color: cs.color,"
            "scale: cs.scale || 'none',"
            "filter: cs.filter,"
            "boxShadow: cs.boxShadow,"
            "borderColor: cs.borderColor,"
            "});"
            "})()"
        ))
        hover_style = result.stdout.strip().strip('"')

        _ab_eval(session, (
            "(() => {"
            f"const el = document.querySelector({sel_lit});"
            "if (!el) return 'not found';"
            "el.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));"
            "el.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));"
            "el.blur?.();"
            "return 'left';"
            "})()"
        ))
        subprocess.run(
            ["agent-browser", "--session", session, "hover", "body"],
            capture_output=True,
            text=True,
        )
        time.sleep(SCROLL_WAIT)

        try:
            hs = json.loads(hover_style.replace("\\\\", "\\\\\\\\")) if hover_style else {}
        except json.JSONDecodeError:
            hs = {}

        results.append({
            "selector": selector,
            "name": safe_name,
            "hoverStyle": hs,
        })

        sys.stdout.write(f"  ✓ {side}/{safe_name}\n")
        sys.stdout.flush()

    return results

ref_results  = capture_hover_state(SESSION_REF,  f"{DIR}/transitions/ref-elements.json",  "ref",  f"{DIR}/transitions/ref")
impl_results = capture_hover_state(SESSION_IMPL, f"{DIR}/transitions/impl-elements.json", "impl", f"{DIR}/transitions/impl")

with open(f"{DIR}/transitions/hover-states.json", "w") as f:
    json.dump({"ref": ref_results, "impl": impl_results}, f, indent=2)
PYEOF

_TC_SESSION_REF="$SESSION_REF" _TC_SESSION_IMPL="$SESSION_IMPL" _TC_DIR="$DIR" \
  TRANSITION_WAIT="$TRANSITION_WAIT" COMPARE_LIMIT="$COMPARE_LIMIT" \
  python3 "$_TC_PY" 2>&1

# ── Step 3: Diff transitions ──
echo ""
echo "▸ Comparing transitions..."

python3 -c "
import json

ref_els = json.loads(open('$DIR/transitions/ref-elements.json').read())
impl_els = json.loads(open('$DIR/transitions/impl-elements.json').read())
hover_states = json.loads(open('$DIR/transitions/hover-states.json').read())
COMPARE_LIMIT = int('$COMPARE_LIMIT')

# Match by semantic identity first, selector second.
#
# A plain shared selector such as .nclick-target, .swiper-slide, a, or
# img is not an identity. It can mean logo, nav link, carousel card, footer
# social icon, etc. Use text/href/tag evidence for those generic selectors and
# only let exact selectors dominate when they are specific.
GENERIC_SELECTORS = {
    '.nclick-target', '.swiper-slide', '.swiper-wrapper', 'a', 'button', 'img',
    '.active', '.is-active', '.is-show', '.show', '.on',
}

def _is_generic_selector(sel):
    if not sel:
        return True
    if sel in GENERIC_SELECTORS:
        return True
    # h_# classes are scaffold hover captures, not stable semantic identity.
    import re as _re
    return bool(_re.fullmatch(r'\.h_\d+', sel))

def _match_key(el):
    mk = el.get('matchKey') or {}
    text = (mk.get('text') or el.get('text') or '').strip()
    return {
        'tag': (mk.get('tag') or el.get('tag') or '').strip(),
        'text': text,
        'href': (mk.get('href') or '').strip(),
        'role': (mk.get('role') or '').strip(),
        'aria': (mk.get('aria') or '').strip(),
    }

def _size_close(a, b):
    ar, br = a.get('rect') or {}, b.get('rect') or {}
    try:
        aw, ah = float(ar.get('width') or 0), float(ar.get('height') or 0)
        bw, bh = float(br.get('width') or 0), float(br.get('height') or 0)
    except (TypeError, ValueError):
        return False
    if aw <= 0 or ah <= 0 or bw <= 0 or bh <= 0:
        return False
    return abs(aw - bw) <= max(8, aw * 0.15) and abs(ah - bh) <= max(8, ah * 0.15)

def _skip_transition_noise(el):
    key = _match_key(el)
    selector = el.get('selector') or ''
    # Blank <img> transitions inside lazy carousels/content grids are asset/media
    # behavior. They are intentionally covered by image/runtime/live-parity
    # gates; making them hard transition rows causes false blocks from cloned
    # swiper duplicate counts and offscreen lazy-load order.
    if key['tag'] == 'img' and not key['text']:
        return True
    if key['tag'] == 'a' and any(
        marker in selector
        for marker in (
            'masonry-list',
            'swiper-wrapper',
            'carousel',
            'gallery',
            'news-list',
            'card-list',
            'tile-list',
            'slide',
        )
    ) and not any(
        marker in selector
        for marker in (
            'btn',
            'button',
            'cta',
            'nav',
            'tab',
            'logo',
            'social',
        )
    ):
        return True
    return False

def find_impl_match(ref_el, impl_list):
    ref_sel = ref_el['selector']
    ref_key = _match_key(ref_el)
    generic_ref = _is_generic_selector(ref_sel)

    # Exact match only proves identity for specific selectors. Generic shared
    # selectors need semantic evidence too.
    if not generic_ref:
        for im in impl_list:
            if im['selector'] == ref_sel:
                return im

    def semantic_score(im):
        im_key = _match_key(im)
        score = 0
        if im_key['tag'] and ref_key['tag'] and im_key['tag'] == ref_key['tag']:
            score += 10
        if ref_key['text'] and im_key['text']:
            if ref_key['text'] == im_key['text']:
                score += 80
            elif len(ref_key['text']) >= 6 and (ref_key['text'] in im_key['text'] or im_key['text'] in ref_key['text']):
                score += 55
        if ref_key['href'] and im_key['href'] and ref_key['href'] == im_key['href']:
            score += 55
        if ref_key['aria'] and im_key['aria'] and ref_key['aria'] == im_key['aria']:
            score += 45
        if _size_close(ref_el, im):
            score += 8
        if im.get('selector') == ref_sel:
            score += 15 if generic_ref else 100
        return score

    scored = sorted(((semantic_score(im), im) for im in impl_list), key=lambda x: x[0], reverse=True)
    if scored and scored[0][0] >= (40 if generic_ref else 30):
        return scored[0][1]

    # Partial match (same class name)
    ref_cls = ref_sel.replace('.', '').replace('#', '')
    for im in impl_list:
        im_cls = im['selector'].replace('.', '').replace('#', '')
        if ref_cls and im_cls and not generic_ref and (ref_cls in im_cls or im_cls in ref_cls):
            return im
    ref_text = (ref_el.get('text') or '').strip()
    if ref_text and len(ref_text) >= 6:
        for im in impl_list:
            im_text = (im.get('text') or '').strip()
            if im_text and (ref_text == im_text or ref_text in im_text or im_text in ref_text):
                return im
    # CSS-module hash strip: prefix_hero__AjMaf -> prefix_hero
    # Only strip if the suffix looks like a hash (__ + 6+ alnum chars).
    import re as _re
    ref_root = _re.sub(r'__[A-Za-z0-9]{6,}$', '', ref_cls or '')
    if ref_root and ref_root != ref_cls:
        for im in impl_list:
            im_cls = im['selector'].replace('.', '').replace('#', '')
            im_root = _re.sub(r'__[A-Za-z0-9]{6,}$', '', im_cls or '')
            if im_root and (ref_root == im_root or ref_root in im_root or im_root in ref_root):
                return im
    return None

report = []
pass_count = 0
fail_count = 0

for ref_el in ref_els[:COMPARE_LIMIT]:
    if _skip_transition_noise(ref_el):
        continue
    impl_el = find_impl_match(ref_el, impl_els)

    entry = {
        'selector': ref_el['selector'],
        'text': ref_el.get('text', ''),
        'issues': [],
    }

    if not impl_el:
        entry['issues'].append('MISSING: no matching element in impl')
        entry['status'] = 'FAIL'
        fail_count += 1
        report.append(entry)
        continue

    # Compare transition timing PER PROPERTY (not whole-list equality).
    # getComputedStyle returns transition-property / -duration /
    # -timing-function as comma-joined parallel lists, but cubic-bezier()/
    # steps() carry internal commas, so the timing-function list is over-split
    # — regroup by paren balance before zipping. Then match each property the
    # REF actually animates against the impl's value for that same property,
    # ignoring extra inert properties the ref does not animate (an added
    # 'transform', a reordered list, or 'transition: all'). A wrong duration/
    # easing on an animated property, or a property the ref animates but the
    # impl omits, still FAILs — this is a tightening-compatible correction.
    def regroup_paren(tokens):
        out, buf, depth = [], '', 0
        for tok in tokens:
            buf = tok if not buf else buf + ', ' + tok
            depth += tok.count('(') - tok.count(')')
            if depth <= 0:
                out.append(buf)
                buf, depth = '', 0
        if buf:
            out.append(buf)
        return out

    def prop_timing_map(trans):
        props = [p.strip() for p in (trans.get('properties') or [])]
        durs = trans.get('durations') or []
        eases = regroup_paren(trans.get('easings') or [])
        m = {}
        for i, prop in enumerate(props):
            if not prop or prop == 'none':
                continue
            dur = durs[i % len(durs)] if durs else ''
            eas = eases[i % len(eases)] if eases else ''
            m[prop] = (dur, eas)  # last declaration wins, matching CSS cascade
        return m

    ref_map = prop_timing_map(ref_el['transition'])
    impl_map = prop_timing_map(impl_el['transition'])

    def lookup_timing(prop, m):
        if prop in m:
            return m[prop]
        if 'all' in m:  # impl 'transition: all <t>' covers any property
            return m['all']
        return None

    for prop, (ref_dur, ref_eas) in ref_map.items():
        impl_timing = lookup_timing(prop, impl_map)
        if impl_timing is None:
            entry['issues'].append(
                f'MISSING_TRANSITION: ref animates {prop} '
                f'(dur={ref_dur}, ease={ref_eas}), impl has no matching transition'
            )
            continue
        impl_dur, impl_eas = impl_timing
        if ref_dur != impl_dur:
            entry['issues'].append(
                f'DURATION_MISMATCH: prop={prop} ref={ref_dur} impl={impl_dur}'
            )
        if ref_eas != impl_eas:
            entry['issues'].append(
                f'EASING_MISMATCH: prop={prop} ref={ref_eas} impl={impl_eas}'
            )

    # Compare idle styles
    def normalize_transform(v):
        # matrix(1, 0, 0, 1, 0, 0) is the identity transform — semantically
        # equivalent to none.
        if v.replace(' ', '') == 'matrix(1,0,0,1,0,0)':
            return 'none'
        return v

    for prop in ['opacity', 'transform', 'backgroundColor', 'color']:
        ref_val = ref_el['idleStyle'].get(prop, '')
        impl_val = impl_el['idleStyle'].get(prop, '')
        if prop == 'transform':
            ref_val = normalize_transform(ref_val)
            impl_val = normalize_transform(impl_val)
        if ref_val != impl_val and ref_val and impl_val:
            # Allow minor color differences
            if prop in ['backgroundColor', 'color']:
                if ref_val.replace(' ', '') == impl_val.replace(' ', ''):
                    continue
            entry['issues'].append(f'IDLE_{prop.upper()}_MISMATCH: ref={ref_val}, impl={impl_val}')

    # Compare hover styles from captured states
    ref_hover = next((r for r in hover_states.get('ref', []) if r['selector'] == ref_el['selector']), None)
    impl_hover = next((r for r in hover_states.get('impl', []) if r['selector'] == (impl_el or {}).get('selector', '')), None)

    if ref_hover and impl_hover and ref_hover.get('hoverStyle') and impl_hover.get('hoverStyle'):
        def _norm_hover(prop, value):
            value = str(value or '').strip()
            if prop == 'transform':
                return normalize_transform(value)
            if prop == 'scale' and value in {'', 'normal'}:
                return 'none'
            return value

        def _hover_changed(prop, idle, hover):
            idle = _norm_hover(prop, idle)
            hover = _norm_hover(prop, hover)
            if idle == hover:
                return False
            inert = {'', 'none', 'normal', 'auto'}
            return not (idle in inert and hover in inert)

        for prop in ['opacity', 'transform', 'scale', 'backgroundColor', 'color']:
            ref_hv = ref_hover['hoverStyle'].get(prop, '')
            impl_hv = impl_hover['hoverStyle'].get(prop, '')
            ref_idle = ref_el['idleStyle'].get(prop, '')
            impl_idle = impl_el['idleStyle'].get(prop, '')

            ref_changes = _hover_changed(prop, ref_idle, ref_hv)
            impl_changes = _hover_changed(prop, impl_idle, impl_hv)

            # Check both missing and hallucinated hover deltas. The latter is
            # the header-hover-rotates / hover-makes-content-disappear
            # class: the clone adds motion on a paired element whose reference
            # hover state is static.
            if ref_changes and not impl_changes:
                entry['issues'].append(
                    f'HOVER_{prop.upper()}_NOT_APPLIED: ref changes {prop} on hover '
                    f'({ref_idle} -> {ref_hv}), impl stays same'
                )
            elif impl_changes and not ref_changes:
                entry['issues'].append(
                    f'EXTRA_HOVER_{prop.upper()}_APPLIED: impl changes {prop} on hover '
                    f'({impl_idle} -> {impl_hv}) but ref stays {ref_idle}'
                )

    if entry['issues']:
        entry['status'] = 'FAIL'
        fail_count += 1
    else:
        entry['status'] = 'PASS'
        pass_count += 1

    report.append(entry)

json.dump(report, open('$DIR/transitions/report.json', 'w'), indent=2)

# Common failure pattern: gates expect transitions/result.txt but
# transition-compare only emits report.json. Emit a flat result.txt
# alongside so dispatcher + gate.py post-implement enforcement can
# read a stable text artifact for FAIL counting.
with open('$DIR/transitions/result.txt', 'w') as fh:
    fh.write(f'Transition compare: {pass_count} PASS, {fail_count} FAIL\n')
    for r in report:
        marker = '✅ PASS' if r['status'] == 'PASS' else '❌ FAIL'
        sel = r.get('selector', '?')[:60]
        fh.write(f'{marker}  {sel}\n')
        for iss in (r.get('issues') or [])[:3]:
            fh.write(f'    - {iss[:120]}\n')

# Print summary
print('')
print('| Element | Status | Issues |')
print('|---------|--------|--------|')
for r in report:
    issues = '; '.join(r['issues'][:2]) if r['issues'] else '—'
    print(f'| {r[\"selector\"][:30]} | {r[\"status\"]} | {issues[:60]} |')

print(f'')
print(f'**Result: {pass_count} PASS, {fail_count} FAIL**')
" 2>&1

echo ""
echo "═══ Transition Compare Complete ═══"
echo "  Report:  $DIR/transitions/report.json"
echo "  Summary: $DIR/transitions/result.txt"
echo "  States:  $DIR/transitions/{ref,impl}/"

if grep -Eq '([1-9][0-9]*) FAIL|❌ FAIL' "$DIR/transitions/result.txt"; then
  exit 1
fi
