#!/usr/bin/env python3
"""
HyperFrames HTML lint — catches common mistakes that cannot be detected
by simple regex. Based on the official HyperFrames common-mistakes guide.

Usage:
    python3 scripts/lint-html.py <html-file>
    python3 scripts/lint-html.py <html-file> --json   # machine-readable output

Exit codes: 0 = clean, 1 = errors found, 2 = warnings only
"""
import argparse
import json
import re
import sys


# ── GSAP supported properties (from hyperframes docs gsap) ──
# Source: https://hyperframes.heygen.com/guides/gsap-animation#supported-properties
# color/backgroundColor added per latest docs; width/height/visibility removed
# (they break deterministic rendering — see https://hyperframes.heygen.com/guides/gsap-animation)
GSAP_SUPPORTED = {
    "opacity", "x", "y", "scale", "scaleX", "scaleY",
    "rotation", "color", "backgroundColor",
}

# ── Properties that should NEVER be animated on <video> directly ──
VIDEO_FORBIDDEN_PROPS = {"width", "height", "top", "left", "right", "bottom"}

# ── Media playback methods that conflict with framework ──
MEDIA_CONTROL_PATTERNS = [
    (r'\.play\s*\(', ".play() on media element"),
    (r'\.pause\s*\(', ".pause() on media element"),
    (r'\.currentTime\s*=', ".currentTime = on media element"),
]

# ── Detection functions ──────────────────────────────────────

def find_video_ids(html):
    """Return set of video element ids from HTML."""
    ids = set()
    for m in re.finditer(r'<video\b[^>]*\bid\s*=\s*"([^"]+)"', html, re.IGNORECASE):
        ids.add(m.group(1))
    return ids


def find_audio_ids(html):
    """Return set of audio element ids from HTML."""
    ids = set()
    for m in re.finditer(r'<audio\b[^>]*\bid\s*=\s*"([^"]+)"', html, re.IGNORECASE):
        ids.add(m.group(1))
    return ids


def find_composition_ids(html):
    """Return set of data-composition-id values."""
    ids = set()
    for m in re.finditer(r'data-composition-id\s*=\s*"([^"]+)"', html):
        ids.add(m.group(1))
    return ids


def find_timeline_keys(html):
    """Return set of keys registered on window.__timelines."""
    keys = set()
    for m in re.finditer(r'window\.__timelines\s*\[\s*["\']([^"\']+)["\']\s*\]', html):
        keys.add(m.group(1))
    return keys


def find_gsap_targets(html):
    """Return list of (method, target, props_csv, raw_snippet) for GSAP .to/.from/.fromTo calls.

    Handles multiline calls by tracking brace/paren depth. Extracts property
    names from the props object literal even when it spans multiple lines.
    """
    results = []
    pattern = r'(?:tl|gsap)\s*\.\s*(to|from|fromTo|set)\s*\('
    for m in re.finditer(pattern, html):
        method = m.group(1)
        rest = html[m.end():]
        # Find matching closing paren
        depth = 1
        end_pos = 0
        in_string = False
        string_char = None
        for i, ch in enumerate(rest):
            if in_string:
                if ch == '\\': continue
                elif ch == string_char: in_string = False
                continue
            if ch in '"\'' and (i == 0 or rest[i-1] != '\\'):
                in_string = True
                string_char = ch
                continue
            if ch == '(': depth += 1
            elif ch == ')':
                depth -= 1
                if depth == 0:
                    end_pos = i
                    break
        if end_pos == 0:
            continue
        call_text = html[m.start():m.end() + end_pos + 1]
        args_text = rest[:end_pos]

        # Extract target (first argument)
        target = None
        tm = re.match(r'\s*["\']([^"\']*)["\']', args_text)
        if tm:
            target = tm.group(1)
        else:
            vm = re.match(r'\s*([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w+|\[[^\]]+\])*)', args_text)
            target = vm.group(1) if vm else '?'

        # Find the props object (second argument after first comma, tracking depth)
        props_text = _find_props_object(args_text)
        prop_names = _extract_prop_names(props_text) if props_text else set()

        results.append((method, target, ','.join(sorted(prop_names)), call_text[:200]))
    return results


def _find_props_object(args_text):
    """Find the second argument (props object) in a GSAP call's argument list."""
    depth = 0
    in_str = False
    str_char = None
    for i, ch in enumerate(args_text):
        if in_str:
            if ch == '\\': continue
            elif ch == str_char: in_str = False
            continue
        if ch in '"\'':
            in_str = True
            str_char = ch
            continue
        if ch == '(': depth += 1
        elif ch == ')': depth -= 1
        elif ch == ',' and depth == 0:
            rest = args_text[i+1:].strip()
            if rest and rest[0] == '{':
                # Find matching closing brace
                bdepth = 1
                for j in range(1, len(rest)):
                    c = rest[j]
                    if c == '{': bdepth += 1
                    elif c == '}':
                        bdepth -= 1
                        if bdepth == 0:
                            return rest[:j+1]
            return None
    return None


def _extract_prop_names(props_text):
    """Extract top-level property names from a JS object literal string.

    Handles nested objects, strings, and function calls by tracking brace depth.
    Only extracts property names at depth=1 (the top level of the props object).
    """
    props = set()
    if not props_text or props_text[0] != '{':
        return props
    depth = 0
    in_string = False
    str_char = None
    i = 0
    while i < len(props_text):
        ch = props_text[i]
        if in_string:
            if ch == '\\': i += 2; continue
            elif ch == str_char: in_string = False
            i += 1; continue
        if ch in '"\'':
            in_string = True; str_char = ch
            i += 1; continue
        if ch == '{': depth += 1
        elif ch == '}': depth -= 1
        elif depth == 1:
            m = re.match(r'\s*([a-zA-Z_]\w*)\s*:', props_text[i:])
            if m:
                props.add(m.group(1))
                i += m.end() - 1
        i += 1
    return props


def check_video_animation(html):
    """P0: Detect GSAP animating forbidden props on <video> elements."""
    issues = []
    video_ids = find_video_ids(html)
    if not video_ids:
        return issues

    for method, target, props_str, raw in find_gsap_targets(html):
        # Check if target matches a video element
        target_id = target.lstrip("#.")
        if target_id in video_ids or target in video_ids:
            # Check animated properties
            for prop in VIDEO_FORBIDDEN_PROPS:
                if prop in props_str:
                    issues.append({
                        "severity": "error",
                        "code": "video-direct-animation",
                        "message": f"GSAP {method}() animates '{prop}' directly on <video id=\"{target_id}\">. "
                                   f"Wrap video in a <div> and animate the wrapper instead.",
                        "snippet": raw[:120],
                    })
                    break  # One issue per call
    return issues


def check_media_playback_control(html):
    """P0: Detect .play()/.pause()/.currentTime on media elements in JS."""
    issues = []
    # Collect all media element IDs
    media_ids = find_video_ids(html) | find_audio_ids(html)
    if not media_ids:
        return issues

    # Find all <script> blocks
    script_blocks = re.findall(r'<script[^>]*>(.*?)</script>', html, re.DOTALL)
    for block in script_blocks:
        for pattern, desc in MEDIA_CONTROL_PATTERNS:
            for m in re.finditer(pattern, block):
                start = max(0, m.start() - 40)
                end = min(len(block), m.end() + 40)
                ctx = block[start:end].replace('\n', ' ')
                before = block[max(0, m.start()-120):m.start()]
                # Check if any known media element ID is referenced near this call
                is_media = bool(re.search(r'(video|audio|media)', before, re.IGNORECASE))
                if not is_media:
                    for mid in media_ids:
                        if mid in before:
                            is_media = True
                            break
                if is_media:
                    issues.append({
                        "severity": "error",
                        "code": "media-playback-control",
                        "message": f"Script uses {desc}. The HyperFrames framework owns all media playback. "
                                   f"Use data-start/data-duration/data-media-start instead.",
                        "snippet": ctx.strip(),
                    })
                    break
    return issues


def check_missing_clip_class(html):
    """P0: Detect elements with data-start/data-duration missing class='clip'."""
    issues = []
    # Find elements with data-start or data-duration
    pattern = r'<(\w+)\b([^>]*\bdata-(?:start|duration)\s*=\s*"[^"]*"[^>]*)>'
    for m in re.finditer(pattern, html, re.IGNORECASE):
        tag = m.group(1)
        attrs = m.group(2)
        full_tag = m.group(0)
        # Skip composition root elements (they manage the timeline, not timed visibility)
        if 'data-composition-id' in attrs:
            continue
        if tag in ('div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span',
                    'img', 'section', 'article'):
            if 'class="clip"' not in full_tag and "class='clip'" not in full_tag:
                # Extract id or first few chars for reporting
                id_m = re.search(r'id\s*=\s*"([^"]+)"', attrs)
                name = f"#{id_m.group(1)}" if id_m else f"<{tag}>"
                issues.append({
                    "severity": "error",
                    "code": "missing-clip-class",
                    "message": f"Element {name} has data-start/duration but missing class=\"clip\". "
                               f"Without it, the element is always visible.",
                    "snippet": full_tag[:120],
                })
    return issues


def check_timeline_key_mismatch(html):
    """P1: Detect window.__timelines key != data-composition-id."""
    issues = []
    comp_ids = find_composition_ids(html)
    timeline_keys = find_timeline_keys(html)
    if not timeline_keys:
        return issues  # No timeline to check

    for key in timeline_keys:
        if key not in comp_ids:
            issues.append({
                "severity": "error",
                "code": "timeline-key-mismatch",
                "message": f"window.__timelines[\"{key}\"] registered but no element "
                           f"has data-composition-id=\"{key}\". Timeline key must match composition id.",
                "snippet": f'window.__timelines["{key}"]',
            })
    return issues


def check_missing_timeline_padding(html):
    """P1: Warn if composition has audio/video but no timeline padding."""
    issues = []
    has_media = bool(re.search(r'<(?:video|audio)\b[^>]*data-duration\s*=\s*"([^"]+)"', html, re.IGNORECASE))
    if not has_media:
        return issues

    # Check for tl.set({}, {}, TIME) or tl.to({}, { duration: TIME }, POS)
    has_padding = bool(re.search(
        r'(?:tl|gsap)\s*\.\s*(?:set|to)\s*\(\s*\{\s*\}\s*,',
        html
    ))
    if not has_padding:
        last_tween = re.findall(
            r'(?:tl|gsap)\s*\.\s*(?:to|from|set)\s*\([^)]+,\s*(\d+(?:\.\d+)?)\s*\)',
            html
        )
        if last_tween:
            max_time = max(float(t) for t in last_tween)
            issues.append({
                "severity": "warning",
                "code": "missing-timeline-padding",
                "message": f"Composition has media but last GSAP tween ends at {max_time}s. "
                           f"Add tl.set({{}}, {{}}, MEDIA_DURATION) to extend timeline.",
                "snippet": f"Last tween at {max_time}s",
            })
    return issues


def check_unsupported_gsap_props(html):
    """P1: Warn about GSAP animating unsupported properties.

    Uses parsed property names from find_gsap_targets() for precise detection.
    """
    issues = []
    # Properties not in GSAP_SUPPORTED — catch common misuses
    suspicious_props = {
        "fontSize", "font-size",
        "margin", "marginTop", "marginBottom", "marginLeft", "marginRight",
        "padding", "paddingTop", "border", "borderRadius", "zIndex",
        # Position: use x/y instead
        "left", "top", "right", "bottom",
        # Visual: use scale/opacity instead (break deterministic rendering)
        "width", "height", "visibility",
    }

    for method, target, props_csv, raw in find_gsap_targets(html):
        if not props_csv:
            continue
        parsed_props = set(props_csv.split(','))
        for prop in parsed_props & suspicious_props:
            if prop in ("left", "right"):
                hint = f"'{prop}' (use x instead)"
            elif prop in ("top", "bottom"):
                hint = f"'{prop}' (use y instead)"
            elif prop in ("width", "height", "visibility"):
                hint = f"'{prop}' (breaks deterministic rendering — use scale/opacity instead)"
            else:
                hint = f"'{prop}'"
            issues.append({
                "severity": "warning",
                "code": "unsupported-gsap-prop",
                "message": f"GSAP animates {hint} on '{target}'. "
                           f"HyperFrames only supports: {', '.join(sorted(GSAP_SUPPORTED))}",
                "snippet": raw[:120],
            })
    return issues


# ── Main ──────────────────────────────────────────────────────

ALL_CHECKS = [
    ("video-direct-animation", check_video_animation, "P0"),
    ("media-playback-control", check_media_playback_control, "P0"),
    ("missing-clip-class", check_missing_clip_class, "P0"),
    ("timeline-key-mismatch", check_timeline_key_mismatch, "P1"),
    ("missing-timeline-padding", check_missing_timeline_padding, "P1"),
    ("unsupported-gsap-prop", check_unsupported_gsap_props, "P1"),
]


def lint_html(html):
    """Run all checks, return list of issues."""
    all_issues = []
    for code, check_fn, priority in ALL_CHECKS:
        try:
            issues = check_fn(html)
            all_issues.extend(issues)
        except Exception as e:
            all_issues.append({
                "severity": "error",
                "code": "lint-error",
                "message": f"Lint check '{code}' failed: {e}",
                "snippet": "",
            })
    return all_issues


def main():
    parser = argparse.ArgumentParser(description="HyperFrames HTML linter")
    parser.add_argument("input", help="Path to HTML file")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    args = parser.parse_args()

    with open(args.input, 'r') as f:
        html = f.read()

    issues = lint_html(html)

    if args.json:
        print(json.dumps(issues, indent=2, ensure_ascii=False))
    else:
        errors = [i for i in issues if i["severity"] == "error"]
        warnings = [i for i in issues if i["severity"] == "warning"]

        if issues:
            print(f"=== HyperFrames Lint: {os.path.basename(args.input)} ===")
            print(f"  {len(errors)} error(s), {len(warnings)} warning(s)")
            print()

        for i in issues:
            prefix = "[FAIL]" if i["severity"] == "error" else "[WARN]"
            print(f"{prefix} {i['code']}: {i['message']}")
            if i["snippet"]:
                print(f"       {i['snippet'][:150]}")

        if not issues:
            print("  No issues found.")

    # Exit code
    errors = sum(1 for i in issues if i["severity"] == "error")
    sys.exit(1 if errors > 0 else 0)


if __name__ == "__main__":
    import os  # needed for os.path.basename in main
    main()
