"""Hyperframes lifecycle manager: install, remove, doctor, env, offline validator.

Library code consumed by mugi_backend routes and tools/hyperframes_tools.
No @tool decorators here.
"""
from __future__ import annotations

import glob
import json
import logging
import os
import re
import shutil
import subprocess
import threading
from typing import Any

from persistence import (
    HYPERFRAMES_DIR,
    HYPERFRAMES_PROJECTS_DIR,
    HYPERFRAMES_RENDERS_DIR,
    MUGI_DIR,
    MUGI_LOGS_DIR,
)
from tools.cli.runner import cli_rich_path, find_cli_binary

_log = logging.getLogger(__name__)

HYPERFRAMES_PIN = "0.5.3"

_PROJECT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
_OUTPUT_NAME_RE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_MAX_SCAN_FILES = 50
_MAX_SCAN_FILE_SIZE = 512_000  # 500 KB

_render_lock = threading.Lock()

# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------

def hyperframes_home() -> str:
    return HYPERFRAMES_DIR


def hyperframes_installed() -> bool:
    cli = resolve_hyperframes_cli()
    has_lockfile = (
        os.path.isfile(os.path.join(HYPERFRAMES_DIR, "package-lock.json"))
        or os.path.isfile(os.path.join(HYPERFRAMES_DIR, "npm-shrinkwrap.json"))
    )
    return has_lockfile and os.path.isfile(cli)


def resolve_hyperframes_cli() -> str:
    return os.path.join(HYPERFRAMES_DIR, "node_modules", ".bin", "hyperframes")


def _resolve_node_bin() -> tuple[str | None, str]:
    """Return (node_path, error). Validates >= 22."""
    node = find_cli_binary("node")
    if not node:
        return None, "Node.js not found. Install via Settings > Software > Hyperframes."
    try:
        out = subprocess.run(
            [node, "--version"],
            capture_output=True, text=True, timeout=10,
        )
        ver_str = (out.stdout or "").strip().lstrip("v")
        parts = ver_str.split(".")
        major = int(parts[0]) if parts else 0
        if major < 22:
            return None, f"Node 22+ required; found v{ver_str}"
        return node, ""
    except Exception as exc:
        return None, f"Failed to check Node version: {exc}"


def _resolve_npm_bin(node_path: str) -> str | None:
    """Derive npm location from the node binary's directory."""
    node_dir = os.path.dirname(node_path)
    npm = os.path.join(node_dir, "npm")
    if os.path.isfile(npm):
        return npm
    return find_cli_binary("npm")


def validate_project_name(name: str) -> str | None:
    """Return error message if invalid, else None."""
    if not name or not _PROJECT_NAME_RE.match(name):
        return (
            f"Invalid project name {name!r}. "
            "Use 1-64 chars of letters, digits, hyphens, underscores."
        )
    return None


def validate_output_name(name: str) -> str | None:
    if not name or not _OUTPUT_NAME_RE.match(name):
        return (
            f"Invalid output name {name!r}. "
            "Use letters, digits, hyphens, underscores, dots only. No path separators."
        )
    if os.sep in name or "/" in name:
        return "Output name must not contain path separators."
    return None


def resolve_project_dir(project_name: str) -> tuple[str | None, str]:
    """Return (abs_project_dir, error). Uses abspath containment per bootstrap-safety."""
    err = validate_project_name(project_name)
    if err:
        return None, err
    candidate = os.path.abspath(os.path.join(HYPERFRAMES_PROJECTS_DIR, project_name))
    prefix = os.path.abspath(HYPERFRAMES_PROJECTS_DIR)
    if not candidate.startswith(prefix + os.sep):
        return None, "Project path escapes the managed directory."
    return candidate, ""


def resolve_output_path(output_name: str, fmt: str) -> tuple[str | None, str]:
    """Return (abs_output_path, error). Forces extension from format."""
    err = validate_output_name(output_name)
    if err:
        return None, err
    ext_map = {"mp4": ".mp4", "webm": ".webm", "mov": ".mov", "gif": ".gif"}
    ext = ext_map.get(fmt, ".mp4")
    base = os.path.splitext(output_name)[0] + ext
    candidate = os.path.abspath(os.path.join(HYPERFRAMES_RENDERS_DIR, base))
    prefix = os.path.abspath(HYPERFRAMES_RENDERS_DIR)
    if not candidate.startswith(prefix + os.sep):
        return None, "Output path escapes the managed renders directory."
    return candidate, ""


# ---------------------------------------------------------------------------
# Subprocess environment
# ---------------------------------------------------------------------------

def build_hyperframes_env() -> dict[str, str]:
    """Build a sandboxed env dict for Hyperframes CLI subprocesses."""
    env: dict[str, str] = {
        "PATH": cli_rich_path(),
        "HOME": os.environ.get("HOME", os.path.expanduser("~")),
        "HYPERFRAMES_NO_UPDATE_CHECK": "1",
        "HYPERFRAMES_NO_TELEMETRY": "1",
    }
    cache_dir = os.path.join(HYPERFRAMES_DIR, ".cache")
    env["XDG_CACHE_HOME"] = cache_dir

    browser_json = os.path.join(HYPERFRAMES_DIR, "browser.json")
    if os.path.isfile(browser_json):
        try:
            with open(browser_json) as f:
                data = json.load(f)
            shell_path = data.get("PRODUCER_HEADLESS_SHELL_PATH", "")
            if shell_path and os.path.isfile(shell_path):
                env["PRODUCER_HEADLESS_SHELL_PATH"] = shell_path
        except Exception:
            pass

    if not env.get("PRODUCER_HEADLESS_SHELL_PATH"):
        shell = _find_chrome_shell()
        if shell:
            env["PRODUCER_HEADLESS_SHELL_PATH"] = shell

    tmpdir = os.path.join(HYPERFRAMES_DIR, "tmp")
    os.makedirs(tmpdir, exist_ok=True)
    env["TMPDIR"] = tmpdir

    for key in ("HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY"):
        val = os.environ.get(key)
        if val:
            env[key] = val

    return env


def _find_chrome_shell() -> str | None:
    """Scan the Hyperframes cache tree for a chrome-headless-shell binary."""
    cache_dir = os.path.join(HYPERFRAMES_DIR, ".cache")
    if not os.path.isdir(cache_dir):
        return None
    for root, _dirs, files in os.walk(cache_dir):
        for name in files:
            if "chrome-headless-shell" in name and not name.endswith(".zip"):
                candidate = os.path.join(root, name)
                if os.access(candidate, os.X_OK):
                    return candidate
    return None


# ---------------------------------------------------------------------------
# Install / remove
# ---------------------------------------------------------------------------

def install_hyperframes(
    *, version: str | None = None, reinstall: bool = False,
) -> tuple[bool, str, dict[str, Any] | None]:
    """Install or reinstall the managed Hyperframes CLI. Returns (ok, message, doctor_json)."""
    pin = version or HYPERFRAMES_PIN
    log_path = os.path.join(MUGI_LOGS_DIR, "hyperframes_install.log")

    mugi_abs = os.path.abspath(os.path.expanduser("~/.mugi"))
    hf_abs = os.path.abspath(HYPERFRAMES_DIR)
    if not hf_abs.startswith(mugi_abs + os.sep):
        return False, "Refusing to install outside ~/.mugi", None

    try:
        os.makedirs(MUGI_LOGS_DIR, exist_ok=True)
        os.makedirs(HYPERFRAMES_PROJECTS_DIR, exist_ok=True)
        os.makedirs(HYPERFRAMES_RENDERS_DIR, exist_ok=True)

        def _append_log(text: str) -> None:
            try:
                with open(log_path, "a", encoding="utf-8") as f:
                    f.write(text)
            except Exception:
                pass

        _append_log(f"\n=== hyperframes install run (pin={pin}, reinstall={reinstall}) ===\n")

        # 1. Ensure Node >= 22
        node, err = _resolve_node_bin()
        if not node:
            _append_log(f"Node check failed: {err}\n")
            return False, err, None

        npm = _resolve_npm_bin(node)
        if not npm:
            _append_log("npm not found alongside Node\n")
            return False, "npm not found. Ensure Node.js is installed via Mugi Homebrew.", None

        # 2. Ensure ffmpeg + ffprobe
        ffmpeg_bin = find_cli_binary("ffmpeg")
        ffprobe_bin = find_cli_binary("ffprobe")
        if not ffmpeg_bin or not ffprobe_bin:
            msg = "FFmpeg/FFprobe not found. Install via Settings > Advanced > Mugi Homebrew."
            _append_log(msg + "\n")
            return False, msg, None

        # 3. Set up package.json if needed
        pkg_json = os.path.join(HYPERFRAMES_DIR, "package.json")
        if reinstall:
            nm = os.path.join(HYPERFRAMES_DIR, "node_modules")
            if os.path.isdir(nm):
                shutil.rmtree(nm, ignore_errors=True)
            for lock in ("package-lock.json", "npm-shrinkwrap.json"):
                p = os.path.join(HYPERFRAMES_DIR, lock)
                if os.path.isfile(p):
                    os.remove(p)

        if not os.path.isfile(pkg_json):
            with open(pkg_json, "w") as f:
                json.dump({"name": "mugi-hyperframes", "private": True}, f, indent=2)

        # 4. npm install
        npm_env = build_hyperframes_env()
        npm_env.pop("HYPERFRAMES_NO_UPDATE_CHECK", None)
        npm_env.pop("HYPERFRAMES_NO_TELEMETRY", None)

        spec = f"hyperframes@{pin}" if pin != "latest" else "hyperframes@latest"
        npm_result = subprocess.run(
            [npm, "install", spec, "--save-exact"],
            capture_output=True, text=True, timeout=600,
            cwd=HYPERFRAMES_DIR, env=npm_env,
        )
        _append_log(npm_result.stdout or "")
        _append_log(npm_result.stderr or "")
        if npm_result.returncode != 0:
            return (
                False,
                f"npm install failed (exit {npm_result.returncode}). See {log_path}",
                None,
            )

        # 5. hyperframes browser ensure
        cli = resolve_hyperframes_cli()
        if not os.path.isfile(cli):
            return False, f"Hyperframes CLI not found after npm install at {cli}", None

        br_env = build_hyperframes_env()
        br_result = subprocess.run(
            [node, cli, "browser", "ensure"],
            capture_output=True, text=True, timeout=600,
            cwd=HYPERFRAMES_DIR, env=br_env,
        )
        _append_log(br_result.stdout or "")
        _append_log(br_result.stderr or "")
        if br_result.returncode != 0:
            _append_log(f"browser ensure failed (exit {br_result.returncode})\n")

        # 6. Persist browser.json
        shell = _find_chrome_shell()
        if shell:
            try:
                with open(os.path.join(HYPERFRAMES_DIR, "browser.json"), "w") as f:
                    json.dump({"PRODUCER_HEADLESS_SHELL_PATH": shell}, f)
            except Exception:
                pass

        # 7. Doctor
        doctor_json = _run_doctor_json(node, cli)
        _append_log(f"doctor result: {json.dumps(doctor_json)}\n")

        return True, "Hyperframes installed", doctor_json

    except subprocess.TimeoutExpired:
        return False, "Hyperframes installation timed out", None
    except Exception as exc:
        _log.exception("hyperframes install failed")
        return False, str(exc), None


def remove_hyperframes(*, clear_cache: bool = False) -> tuple[bool, str]:
    """Remove the managed Hyperframes installation."""
    mugi_abs = os.path.abspath(os.path.expanduser("~/.mugi"))
    hf_abs = os.path.abspath(HYPERFRAMES_DIR)
    if not hf_abs.startswith(mugi_abs + os.sep):
        return False, "Refusing to remove outside ~/.mugi"
    try:
        if os.path.isdir(HYPERFRAMES_DIR):
            shutil.rmtree(HYPERFRAMES_DIR)
        if clear_cache:
            system_cache = os.path.expanduser("~/.cache/hyperframes")
            if os.path.isdir(system_cache):
                shutil.rmtree(system_cache, ignore_errors=True)
        return True, "Hyperframes removed"
    except Exception as exc:
        return False, f"Remove failed: {exc}"


# ---------------------------------------------------------------------------
# Doctor
# ---------------------------------------------------------------------------

def _run_doctor_json(node: str | None = None, cli: str | None = None) -> dict[str, Any]:
    if not cli:
        cli = resolve_hyperframes_cli()
    if not node:
        node, _ = _resolve_node_bin()
    if not node or not os.path.isfile(cli):
        return {"ok": False, "error": "Hyperframes not installed"}
    try:
        env = build_hyperframes_env()
        result = subprocess.run(
            [node, cli, "doctor", "--json"],
            capture_output=True, text=True, timeout=30,
            cwd=HYPERFRAMES_DIR, env=env,
        )
        text = (result.stdout or "").strip()
        if text:
            try:
                return json.loads(text)
            except json.JSONDecodeError:
                parsed = _parse_doctor_plaintext(text, returncode=result.returncode)
                if parsed:
                    return parsed
        stderr = (result.stderr or "").strip()
        if stderr:
            parsed = _parse_doctor_plaintext(stderr, returncode=result.returncode)
            if parsed:
                return parsed
        # Plain doctor fallback (older CLI without --json)
        plain = subprocess.run(
            [node, cli, "doctor"],
            capture_output=True, text=True, timeout=30,
            cwd=HYPERFRAMES_DIR, env=env,
        )
        combined = "\n".join(
            part for part in ((plain.stdout or "").strip(), (plain.stderr or "").strip()) if part
        )
        parsed = _parse_doctor_plaintext(combined, returncode=plain.returncode)
        if parsed:
            return parsed
        return {"ok": False, "error": "doctor output was not valid JSON", "raw": combined[:500]}
    except json.JSONDecodeError:
        return {"ok": False, "error": "doctor output was not valid JSON"}
    except subprocess.TimeoutExpired:
        return {"ok": False, "error": "doctor timed out"}
    except Exception as exc:
        return {"ok": False, "error": str(exc)}


def _parse_doctor_plaintext(text: str, *, returncode: int) -> dict[str, Any] | None:
    """Best-effort struct from plain `hyperframes doctor` output."""
    if not (text or "").strip():
        return None
    lower = text.lower()
    checks: dict[str, bool] = {}
    for label, pattern in (
        ("node", r"\bnode\b"),
        ("ffmpeg", r"\bffmpeg\b"),
        ("ffprobe", r"\bffprobe\b"),
        ("chromium", r"(?:\bchromium\b|\bchrome\b)"),
    ):
        if re.search(pattern, lower):
            failed = re.search(rf"(?:{pattern})[^\n]*(?:fail|missing|not found|error)", lower)
            checks[label] = not bool(failed)
    if not checks:
        return None
    ok = returncode == 0 and all(checks.values())
    return {"ok": ok, "checks": checks, "source": "plain_doctor"}


def doctor_hyperframes() -> dict[str, Any]:
    return _run_doctor_json()


# ---------------------------------------------------------------------------
# Version helpers
# ---------------------------------------------------------------------------

def hyperframes_version() -> str | None:
    if not hyperframes_installed():
        return None
    node, _ = _resolve_node_bin()
    cli = resolve_hyperframes_cli()
    if not node or not os.path.isfile(cli):
        return None
    try:
        result = subprocess.run(
            [node, cli, "--version"],
            capture_output=True, text=True, timeout=15,
            cwd=HYPERFRAMES_DIR, env=build_hyperframes_env(),
        )
        return (result.stdout or "").strip() or None
    except Exception:
        return None


def hyperframes_node_version() -> str | None:
    node, _ = _resolve_node_bin()
    if not node:
        return None
    try:
        result = subprocess.run(
            [node, "--version"], capture_output=True, text=True, timeout=10,
        )
        return (result.stdout or "").strip() or None
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Run helper (with optional global lock for renders)
# ---------------------------------------------------------------------------

def run_hyperframes(
    args: list[str],
    *,
    cwd: str | None = None,
    timeout_sec: int = 300,
    use_render_lock: bool = False,
) -> subprocess.CompletedProcess[str]:
    """Run the Hyperframes CLI via Node. Raises on missing install."""
    node, err = _resolve_node_bin()
    if not node:
        raise RuntimeError(err)
    cli = resolve_hyperframes_cli()
    if not os.path.isfile(cli):
        raise RuntimeError("Hyperframes CLI not found. Install via Settings > Software.")

    cmd = [node, cli] + args
    env = build_hyperframes_env()
    work_dir = cwd or HYPERFRAMES_DIR

    if use_render_lock:
        with _render_lock:
            return subprocess.run(
                cmd, capture_output=True, text=True,
                timeout=timeout_sec, cwd=work_dir, env=env,
            )
    return subprocess.run(
        cmd, capture_output=True, text=True,
        timeout=timeout_sec, cwd=work_dir, env=env,
    )


# ---------------------------------------------------------------------------
# Offline composition validator
# ---------------------------------------------------------------------------

_REMOTE_URL_RE = re.compile(r"""https?://""", re.IGNORECASE)
_REMOTE_SCRIPT_RE = re.compile(
    r"""<script[^>]+\bsrc\s*=\s*["']https?://""", re.IGNORECASE,
)
_REMOTE_LINK_RE = re.compile(
    r"""<link[^>]+\bhref\s*=\s*["']https?://""", re.IGNORECASE,
)
_SRC_ATTR_RE = re.compile(
    r"""\b(?:src|srcset|poster)\s*=\s*["'](https?://[^"']+)["']""", re.IGNORECASE,
)
_CSS_URL_RE = re.compile(
    r"""url\(\s*["']?(https?://[^)"']+)["']?\s*\)""", re.IGNORECASE,
)
_CSS_IMPORT_RE = re.compile(
    r"""@import\s+["'](https?://[^"']+)["']""", re.IGNORECASE,
)


_GSAP_VENDOR_FILENAME = "gsap.min.js"
_REMOTE_SCRIPT_SRC_RE = re.compile(
    r'<script\s+[^>]*\bsrc\s*=\s*["\']https?://[^"\']+["\'][^>]*>\s*</script>',
    re.IGNORECASE,
)
_ROOT_DIV_RE = re.compile(
    r'(<div\s+[^>]*\bid\s*=\s*["\']root["\'])([^>]*)(>)',
    re.IGNORECASE | re.DOTALL,
)


def _shared_gsap_vendor_path() -> str:
    return os.path.join(HYPERFRAMES_DIR, "vendor", _GSAP_VENDOR_FILENAME)


def _gsap_candidates() -> list[str]:
    candidates = [
        _shared_gsap_vendor_path(),
        os.path.join(HYPERFRAMES_DIR, "node_modules", "gsap", "dist", _GSAP_VENDOR_FILENAME),
    ]
    if os.path.isdir(HYPERFRAMES_PROJECTS_DIR):
        try:
            for name in os.listdir(HYPERFRAMES_PROJECTS_DIR):
                candidates.append(
                    os.path.join(HYPERFRAMES_PROJECTS_DIR, name, _GSAP_VENDOR_FILENAME)
                )
                candidates.append(
                    os.path.join(
                        HYPERFRAMES_PROJECTS_DIR,
                        name,
                        "vendor",
                        _GSAP_VENDOR_FILENAME,
                    )
                )
        except OSError:
            pass
    return candidates


def ensure_gsap_vendor_file() -> tuple[str | None, str]:
    """Return a readable GSAP min bundle under ~/.mugi/hyperframes/vendor/."""
    dest = _shared_gsap_vendor_path()
    if os.path.isfile(dest) and os.path.getsize(dest) > 1000:
        return dest, ""

    for candidate in _gsap_candidates():
        if os.path.isfile(candidate) and os.path.getsize(candidate) > 1000:
            os.makedirs(os.path.dirname(dest), exist_ok=True)
            shutil.copy2(candidate, dest)
            return dest, ""

    node, err = _resolve_node_bin()
    if not node:
        return None, err or "Node.js not found; cannot vendor GSAP for offline render."
    npm = _resolve_npm_bin(node)
    if not npm:
        return None, "npm not found; cannot vendor GSAP for offline render."

    os.makedirs(HYPERFRAMES_DIR, exist_ok=True)
    npm_env = build_hyperframes_env()
    npm_env.pop("HYPERFRAMES_NO_UPDATE_CHECK", None)
    npm_env.pop("HYPERFRAMES_NO_TELEMETRY", None)
    try:
        result = subprocess.run(
            [npm, "install", "gsap@3.12.2", "--save-exact", "--no-fund", "--no-audit"],
            capture_output=True,
            text=True,
            timeout=180,
            cwd=HYPERFRAMES_DIR,
            env=npm_env,
        )
    except subprocess.TimeoutExpired:
        return None, "Timed out installing GSAP for offline render."
    if result.returncode != 0:
        stderr = (result.stderr or result.stdout or "")[:300]
        return None, f"Failed to install GSAP for offline render: {stderr}"

    module_gsap = os.path.join(
        HYPERFRAMES_DIR, "node_modules", "gsap", "dist", _GSAP_VENDOR_FILENAME,
    )
    if not os.path.isfile(module_gsap):
        return None, "GSAP install succeeded but gsap.min.js was not found."
    os.makedirs(os.path.dirname(dest), exist_ok=True)
    shutil.copy2(module_gsap, dest)
    return dest, ""


def _ensure_root_timing_attrs(attrs: str, *, default_duration: str = "20") -> str:
    """Ensure #root has data-start and data-duration attributes."""
    lower = attrs.lower()
    if "data-start=" not in lower:
        attrs = f'{attrs} data-start="0"'
    if "data-duration=" not in lower:
        attrs = f'{attrs} data-duration="{default_duration}"'
    return attrs


def patch_project_for_offline_render(
    project_dir: str,
    *,
    default_duration: str = "20",
) -> tuple[bool, list[str]]:
    """Repair index.html after hyperframes init or a bad wholesale rewrite.

    Vendors GSAP locally, removes remote ``<script src>``, injects ``window.__hf``,
    and ensures seek-friendly GSAP timelines.
    """
    messages: list[str] = []
    index_path = os.path.join(project_dir, "index.html")
    if not os.path.isfile(index_path):
        return False, ["index.html missing"]

    gsap_src, gsap_err = ensure_gsap_vendor_file()
    if gsap_err or not gsap_src:
        return False, [gsap_err or "GSAP vendor bundle unavailable"]

    vendor_dir = os.path.join(project_dir, "vendor")
    os.makedirs(vendor_dir, exist_ok=True)
    dest_gsap = os.path.join(vendor_dir, _GSAP_VENDOR_FILENAME)
    if (
        not os.path.isfile(dest_gsap)
        or os.path.getsize(dest_gsap) != os.path.getsize(gsap_src)
    ):
        shutil.copy2(gsap_src, dest_gsap)
        messages.append("Copied local GSAP vendor bundle")

    try:
        with open(index_path, encoding="utf-8", errors="replace") as f:
            content = f.read()
    except OSError as exc:
        return False, [f"Could not read index.html: {exc}"]

    original = content
    local_script = f'<script src="./vendor/{_GSAP_VENDOR_FILENAME}"></script>'

    if _REMOTE_SCRIPT_SRC_RE.search(content):
        content = _REMOTE_SCRIPT_SRC_RE.sub(local_script, content)
        messages.append("Replaced remote script tags with local GSAP")

    if "./vendor/" not in content and "gsap" in content.lower() and local_script not in content:
        head_close = content.lower().find("</head>")
        if head_close >= 0:
            content = content[:head_close] + f"    {local_script}\n  " + content[head_close:]
            messages.append("Inserted local GSAP script tag")

    if "__hf" not in content:
        marker = "window.__timelines = window.__timelines || {};"
        if marker in content:
            content = content.replace(
                marker,
                "window.__hf = window.__hf || {};\n      " + marker,
                1,
            )
        else:
            content = re.sub(
                r"(<script>\s*)",
                r"\1window.__hf = window.__hf || {};\n      ",
                content,
                count=1,
            )
        messages.append("Injected window.__hf timing hooks")

    content = re.sub(
        r"gsap\.timeline\(\s*\)",
        "gsap.timeline({ paused: true })",
        content,
    )

    def _patch_root(match: re.Match[str]) -> str:
        prefix, attrs, suffix = match.group(1), match.group(2), match.group(3)
        return prefix + _ensure_root_timing_attrs(attrs, default_duration=default_duration) + suffix

    content = _ROOT_DIV_RE.sub(_patch_root, content, count=1)

    if content != original:
        try:
            with open(index_path, "w", encoding="utf-8") as f:
                f.write(content)
        except OSError as exc:
            return False, [f"Could not write index.html: {exc}"]
        if not messages:
            messages.append("Patched index.html for offline render")

    ok, _warnings, errors = validate_composition_offline(project_dir)
    if not ok:
        return False, messages + errors
    return True, messages


def validate_composition_offline(
    project_dir: str,
) -> tuple[bool, list[str], list[str]]:
    """Scan HTML/CSS for remote URLs. Returns (ok, warnings, errors).

    Errors block render; warnings allow but flag.
    """
    warnings: list[str] = []
    errors: list[str] = []

    html_files: list[str] = []
    index = os.path.join(project_dir, "index.html")
    if os.path.isfile(index):
        html_files.append(index)

    comp_pattern = os.path.join(project_dir, "compositions", "**", "*.html")
    for p in glob.glob(comp_pattern, recursive=True):
        if len(html_files) >= _MAX_SCAN_FILES:
            warnings.append(f"Stopped scanning after {_MAX_SCAN_FILES} HTML files.")
            break
        if p not in html_files:
            html_files.append(p)

    if not html_files:
        warnings.append("No index.html found in project directory.")
        return True, warnings, errors

    for filepath in html_files:
        try:
            size = os.path.getsize(filepath)
            if size > _MAX_SCAN_FILE_SIZE:
                warnings.append(f"Skipped {os.path.basename(filepath)} ({size} bytes > limit).")
                continue
            with open(filepath, encoding="utf-8", errors="replace") as f:
                content = f.read()
        except Exception as exc:
            warnings.append(f"Could not read {os.path.basename(filepath)}: {exc}")
            continue

        rel = os.path.relpath(filepath, project_dir)

        remote_script_spans: list[tuple[int, int]] = []
        for m in _REMOTE_SCRIPT_RE.finditer(content):
            remote_script_spans.append(m.span())
            errors.append(
                f"{rel}: remote <script src> (blocks offline render); "
                f"vendor the script locally (e.g. ./vendor/…) or omit CDNs entirely."
            )

        for m in _SRC_ATTR_RE.finditer(content):
            ms, me = m.span()
            if any(
                max(rs, ms) < min(re, me)
                for rs, re in remote_script_spans
            ):
                continue
            url = m.group(1)
            warnings.append(f"{rel}: remote asset URL: {url[:120]}")

        for m in _REMOTE_LINK_RE.finditer(content):
            warnings.append(f"{rel}: remote <link href>")

        for m in _CSS_URL_RE.finditer(content):
            warnings.append(f"{rel}: remote url() in CSS: {m.group(1)[:120]}")

        for m in _CSS_IMPORT_RE.finditer(content):
            warnings.append(f"{rel}: remote @import: {m.group(1)[:120]}")

        # Root index.html must keep Hyperframes seek API (producer expects window.__hf).
        if rel == "index.html" and "__hf" not in content:
            errors.append(
                f"{rel}: missing Hyperframes timing API (__hf / window.__hf). "
                "Do not replace index.html wholesale: read_file the generated index, "
                "then patch in place—init templates wire duration/seek for the renderer."
            )

    ok = len(errors) == 0
    return ok, warnings, errors


# ---------------------------------------------------------------------------
# Status summary (for backend route)
# ---------------------------------------------------------------------------

def hyperframes_status_dict() -> dict[str, Any]:
    """Build the status JSON payload for GET /software/hyperframes/status."""
    installed = hyperframes_installed()
    version = hyperframes_version() if installed else None
    node_ver = hyperframes_node_version()
    ffmpeg_found = find_cli_binary("ffmpeg") is not None
    ffprobe_found = find_cli_binary("ffprobe") is not None
    chrome_found = _find_chrome_shell() is not None

    project_count = 0
    if os.path.isdir(HYPERFRAMES_PROJECTS_DIR):
        try:
            for name in os.listdir(HYPERFRAMES_PROJECTS_DIR):
                idx = os.path.join(HYPERFRAMES_PROJECTS_DIR, name, "index.html")
                if os.path.isfile(idx):
                    project_count += 1
        except OSError:
            pass

    render_count = 0
    if os.path.isdir(HYPERFRAMES_RENDERS_DIR):
        try:
            render_count = len([
                f for f in os.listdir(HYPERFRAMES_RENDERS_DIR)
                if os.path.isfile(os.path.join(HYPERFRAMES_RENDERS_DIR, f))
            ])
        except OSError:
            pass

    log_path = os.path.join(MUGI_LOGS_DIR, "hyperframes_install.log")

    return {
        "installed": installed,
        "installing": False,  # overridden by route with lock.locked()
        "version": version or "",
        "node_version": node_ver or "",
        "ffmpeg_found": ffmpeg_found,
        "ffprobe_found": ffprobe_found,
        "chrome_found": chrome_found,
        "project_count": project_count,
        "render_count": render_count,
        "message": "",
        "managed_dir": HYPERFRAMES_DIR,
        "install_log_path": log_path,
    }
