#!/usr/bin/env python3
"""Remove orphaned files under web/app/assets/ not referenced by current Vite build.

Vite/Rolldown minified bundles reference chunks by bare filename (e.g. ServersPage-xxx.js)
without import() paths — scan file contents for known asset basenames.

Orphans newer than --retention-days are kept so long-lived browser tabs can still load
their lazy chunks after a deploy (see docs/design/specs/2026-06-02-keep-session-no-refresh-design.md).
"""
from __future__ import annotations

import re
import sys
import time
from pathlib import Path

# Leading `_` required for Vite chunks like `_plugin-vue_export-helper-*.js`
ASSET_NAME_RE = re.compile(
    r"[_A-Za-z0-9][A-Za-z0-9_.-]*\.(?:js|css|woff2?|ttf|eot)"
)
URL_RE = re.compile(r"""url\(["']?([^"')]+)["']?\)""")
DEFAULT_RETENTION_DAYS = 14


def _parse_args(argv: list[str]) -> tuple[bool, int, Path]:
    dry_run = "--dry-run" in argv
    retention_days = DEFAULT_RETENTION_DAYS
    positional: list[str] = []
    i = 0
    while i < len(argv):
        arg = argv[i]
        if arg == "--dry-run":
            i += 1
            continue
        if arg.startswith("--retention-days="):
            retention_days = int(arg.split("=", 1)[1])
            i += 1
            continue
        if arg == "--retention-days":
            if i + 1 >= len(argv):
                raise SystemExit("--retention-days requires a value")
            retention_days = int(argv[i + 1])
            i += 2
            continue
        positional.append(arg)
        i += 1
    if retention_days < 0:
        raise SystemExit("--retention-days must be >= 0")
    app_dir = Path(positional[0] if positional else "/www/wwwroot/api.synaglobal.vip/web/app")
    return dry_run, retention_days, app_dir


def _referenced_basenames(text: str, known: set[str]) -> set[str]:
    found: set[str] = set()
    for m in ASSET_NAME_RE.finditer(text):
        name = m.group(0)
        if name in known:
            found.add(name)
    for raw in URL_RE.findall(text):
        name = Path(raw).name
        if name in known:
            found.add(name)
    return found


def collect(app_dir: Path) -> set[Path]:
    assets = app_dir / "assets"
    index = app_dir / "index.html"
    if not index.is_file():
        raise SystemExit(f"missing {index}")

    all_names = {p.name for p in assets.iterdir() if p.is_file()}
    keep_names: set[str] = set()

    for m in re.finditer(r"/app/assets/([^\s\"']+)", index.read_text(encoding="utf-8")):
        name = m.group(1).split("/")[-1]
        if name in all_names:
            keep_names.add(name)

    if not keep_names:
        raise SystemExit("no entry assets found in index.html")

    changed = True
    while changed:
        changed = False
        for name in list(keep_names):
            path = assets / name
            if not path.is_file():
                continue
            text = path.read_text(encoding="utf-8", errors="ignore")
            for ref in _referenced_basenames(text, all_names):
                if ref not in keep_names:
                    keep_names.add(ref)
                    changed = True

    return {assets / n for n in keep_names}


def _partition_orphans(
    orphan: list[Path],
    retention_days: int,
) -> tuple[list[Path], list[Path]]:
    """Split orphans into delete-now vs keep-for-open-tabs (mtime within window)."""
    if retention_days <= 0:
        return orphan, []
    cutoff = time.time() - retention_days * 86400
    to_remove: list[Path] = []
    retained: list[Path] = []
    for p in orphan:
        if p.stat().st_mtime >= cutoff:
            retained.append(p)
        else:
            to_remove.append(p)
    return to_remove, retained


def main() -> int:
    dry_run, retention_days, app_dir = _parse_args(sys.argv[1:])
    assets = app_dir / "assets"
    if not assets.is_dir():
        raise SystemExit(f"not a directory: {assets}")

    keep = collect(app_dir)
    all_files = {p for p in assets.iterdir() if p.is_file()}
    orphan = sorted(all_files - keep, key=lambda p: p.name)
    to_remove, retained = _partition_orphans(orphan, retention_days)

    if dry_run:
        print(
            f"dry-run retention_days={retention_days} "
            f"referenced_kept={len(keep)} "
            f"would_remove={len(to_remove)} "
            f"retained_orphans={len(retained)} "
            f"total={len(all_files)}"
        )
        return 0

    for p in to_remove:
        p.unlink()
    print(
        f"kept={len(keep)} removed={len(to_remove)} "
        f"retained_orphans={len(retained)} retention_days={retention_days} "
        f"total_before={len(all_files)}"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
