#!/usr/bin/env python3
"""Version check and safe public-package update helper for online skills.

This helper is safe to copy into a public online skill package. It only handles
public skill files. Protected libraries, reference databases, source mappings,
and corpus payloads must stay server-side and remain gated by authorization.
"""

from __future__ import annotations

import argparse
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
from typing import Any

from _archive_safety import (
    ArchiveSafetyError,
    inspect_archive_members,
    inspect_archive_path,
    safe_extract_archive,
)
from _protected_rules import (
    PUBLIC_PADDING_PATH,
    PUBLIC_PADDING_SIZE_BYTES,
    is_protected_path,
    online_instruction_conflicts,
    online_skill_policy_errors,
    runtime_policy_errors,
    worldview_policy_errors,
)


DEFAULT_API_BASE = "https://superskill.online"
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
VERSION_FILE = "online-version.json"
PADDING_PATH = PUBLIC_PADDING_PATH.as_posix()
PADDING_SIZE_BYTES = PUBLIC_PADDING_SIZE_BYTES
VERSION_ENDPOINT_TEMPLATE = "/v1/skills/{skill_id}/version"
TRANSIENT_HTTP_STATUS = {408, 429, 500, 502, 503, 504}
AUTH_HTTP_STATUS = {401, 403}
VERSION_RE = re.compile(r"^\s*(\d{8})(?:-(\d+))?\s*$")
FRONTMATTER_NAME_RE = re.compile(r"^name:\s*['\"]?([^'\"\n#]+)", re.MULTILINE)
REQUIRED_PUBLIC_HELPER_PATHS = {
    "scripts/_archive_safety.py",
    "scripts/_protected_rules.py",
    "scripts/auth_client.py",
    "scripts/online_version_guard.py",
    "scripts/woo_online_client.py",
}


class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
    """Fail closed on redirects for canonical public-package downloads."""

    def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None:
        del req, fp, code, msg, headers, newurl
        return None


def require_https_api_base(value: str) -> str:
    normalized = value.strip().rstrip("/")
    parsed = urllib.parse.urlsplit(normalized)
    if (
        parsed.scheme.lower() != "https"
        or not parsed.netloc
        or parsed.username is not None
        or parsed.password is not None
        or parsed.query
        or parsed.fragment
    ):
        raise SystemExit("online skill API base must be an HTTPS origin")
    return normalized


def api_bases() -> list[str]:
    override = os.environ.get("WOO_API_BASE", "").strip()
    return [require_https_api_base(override or DEFAULT_API_BASE)]


def read_json(path: Path) -> dict[str, Any]:
    try:
        data = json.loads(path.read_text("utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    return data if isinstance(data, dict) else {}


def read_local_manifest(skill_root: Path) -> dict[str, Any]:
    return read_json(skill_root / VERSION_FILE)


def resolve_skill_id(skill_root: Path, explicit: str | None, manifest: dict[str, Any] | None = None) -> str:
    if explicit:
        skill_id = str(explicit).strip()
        if skill_id:
            return skill_id
    local = manifest if manifest is not None else read_local_manifest(skill_root)
    skill_id = str(local.get("skill_id") or "").strip()
    if not skill_id:
        raise SystemExit(
            "skill installation path error: online-version.json is missing or does not contain skill_id. "
            "Run the version guard from the installed skill package or pass the correct --skill-root/--skill-id."
        )
    return skill_id


def request_json(url: str, timeout: int) -> dict[str, Any]:
    request = urllib.request.Request(url, method="GET")
    with urllib.request.urlopen(request, timeout=timeout) as response:
        body = response.read().decode("utf-8")
    data = json.loads(body)
    if not isinstance(data, dict):
        raise ValueError("version response must be a JSON object")
    return data


def fetch_server_version(skill_id: str, timeout: int) -> dict[str, Any]:
    endpoint = VERSION_ENDPOINT_TEMPLATE.format(skill_id=skill_id)
    errors: list[str] = []
    for base in api_bases():
        url = f"{base.rstrip('/')}{endpoint}"
        try:
            data = request_json(url, timeout)
            data.setdefault("api_base", base)
            return data
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", errors="ignore")[:500]
            if exc.code in AUTH_HTTP_STATUS:
                raise SystemExit(f"version check failed with HTTP {exc.code}: {detail}")
            if exc.code in TRANSIENT_HTTP_STATUS:
                errors.append(f"{base}: HTTP {exc.code}: {detail}")
                continue
            if exc.code == 404:
                raise SystemExit(
                    "skill configuration error: the server does not recognize this skill_id. "
                    "Check the installed skill root and online-version.json; this is not an authorization-service outage."
                )
            raise SystemExit(f"version check failed with HTTP {exc.code}: {detail}")
        except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
            errors.append(f"{base}: {exc}")
    raise SystemExit("version check failed: " + " | ".join(errors))


def normalize_version(value: Any) -> tuple[int, int]:
    text = str(value or "").strip()
    if not text:
        return (0, 0)
    match = VERSION_RE.match(text)
    if not match:
        raise SystemExit(f"invalid version {text!r}; expected YYYYMMDD or YYYYMMDD-N")
    return (int(match.group(1)), int(match.group(2) or 0))


def is_newer(remote: Any, local: Any) -> bool:
    return normalize_version(remote) > normalize_version(local)


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def protected_member(name: str, skill_id: str) -> bool:
    return is_protected_path(name, strip_root=skill_id, allow_padding=True)


def root_dirs(names: list[str]) -> set[str]:
    roots: set[str] = set()
    for name in names:
        path = Path(name)
        if path.parts:
            roots.add(path.parts[0])
    return roots


def validate_runtime_contract(manifest: dict[str, Any], skill_md: str) -> bool:
    errors = runtime_policy_errors(manifest)
    errors.extend(online_skill_policy_errors(skill_md))
    errors.extend(worldview_policy_errors(skill_md, manifest))
    errors.extend(
        f"SKILL.md line {item['line']}: {item['message']}"
        for item in online_instruction_conflicts(skill_md)
    )
    if errors:
        raise SystemExit(f"online runtime policy validation failed: {'; '.join(errors)}")
    return True


def verify_public_zip(
    zip_path: Path,
    skill_id: str,
    expected_sha256: str | None = None,
    expected_version: str | None = None,
) -> dict[str, Any]:
    try:
        zip_path = inspect_archive_path(zip_path)
    except ArchiveSafetyError as exc:
        raise SystemExit(f"unsafe update package: {exc}") from exc
    if expected_sha256:
        actual = sha256_file(zip_path)
        if actual.lower() != expected_sha256.lower():
            raise SystemExit(f"sha256 mismatch: expected {expected_sha256}, got {actual}")
    else:
        actual = sha256_file(zip_path)

    try:
        archive_context = zipfile.ZipFile(zip_path)
    except (OSError, zipfile.BadZipFile, zipfile.LargeZipFile) as exc:
        raise SystemExit(f"invalid update package: {exc}") from exc
    with archive_context as archive:
        try:
            inspected = inspect_archive_members(archive)
        except ArchiveSafetyError as exc:
            raise SystemExit(f"unsafe update package: {exc}") from exc
        names = [
            normalised
            for member, normalised in inspected
            if not member.is_dir() and not normalised.endswith("/")
        ]
        leaks = [name for name in names if protected_member(name, skill_id)]
        if leaks:
            raise SystemExit(f"protected files found in public update package: {leaks[:10]}")
        padding_name = f"{skill_id}/{PADDING_PATH}"
        if padding_name not in names:
            raise SystemExit(f"public padding file missing from update package: {padding_name}")
        padding_info = archive.getinfo(padding_name)
        if padding_info.file_size != PADDING_SIZE_BYTES:
            raise SystemExit(
                f"public padding file must be exactly {PADDING_SIZE_BYTES} bytes, got {padding_info.file_size}"
            )
        roots = root_dirs(names)
        if roots != {skill_id}:
            raise SystemExit(f"update package root must be exactly {skill_id}/, got {sorted(roots)}")
        missing_helpers = sorted(
            f"{skill_id}/{relative}"
            for relative in REQUIRED_PUBLIC_HELPER_PATHS
            if f"{skill_id}/{relative}" not in names
        )
        if missing_helpers:
            raise SystemExit(
                f"required public helper files are missing: {missing_helpers}"
            )
        version_name = f"{skill_id}/{VERSION_FILE}"
        if version_name not in names:
            raise SystemExit(f"{VERSION_FILE} missing from update package")
        try:
            manifest = json.loads(archive.read(version_name).decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
            raise SystemExit(f"update package online-version.json is invalid: {exc}") from exc
        if not isinstance(manifest, dict):
            raise SystemExit("update package online-version.json must contain a JSON object")
        if manifest.get("skill_id") != skill_id:
            raise SystemExit("update package skill_id does not match")
        package_version = manifest.get("public_skill_version")
        if expected_version is not None:
            normalize_version(package_version)
            normalize_version(expected_version)
            if str(package_version).strip() != str(expected_version).strip():
                raise SystemExit(
                    "update package public_skill_version does not match server version: "
                    f"expected {expected_version}, got {package_version}"
                )
        skill_md_name = f"{skill_id}/SKILL.md"
        if skill_md_name not in names:
            raise SystemExit("SKILL.md missing from update package")
        skill_md = archive.read(skill_md_name).decode("utf-8", errors="replace")
        name_match = FRONTMATTER_NAME_RE.search(skill_md)
        if not name_match or name_match.group(1).strip() != skill_id:
            raise SystemExit("SKILL.md frontmatter name does not match skill_id")
        runtime_policy_validated = validate_runtime_contract(manifest, skill_md)
    return {
        "ok": True,
        "zip": str(zip_path),
        "sha256": actual,
        "skill_id": skill_id,
        "version": manifest.get("public_skill_version"),
        "runtime_policy_validated": runtime_policy_validated,
    }


def canonical_public_package_url(
    remote: dict[str, Any],
    *,
    skill_id: str,
    public_version: Any,
) -> tuple[str, str]:
    version = str(public_version or "").strip()
    normalize_version(version)
    expected_filename = f"{skill_id}-{version}.zip"
    explicit_filename = remote.get("public_package_filename")
    if explicit_filename is not None and explicit_filename != expected_filename:
        raise SystemExit(
            "server public_package_filename does not match skill_id/public version"
        )
    value = remote.get("public_package_url")
    if not isinstance(value, str) or not value:
        raise SystemExit("server public_package_url is missing")
    parsed = urllib.parse.urlsplit(value)
    expected_path = f"/downloads/{expected_filename}"
    if parsed.scheme != "https" or parsed.netloc != "superskill.online":
        raise SystemExit(
            "public_package_url must use the exact origin https://superskill.online"
        )
    if parsed.path != expected_path:
        raise SystemExit(f"public_package_url path must be exactly {expected_path}")
    if parsed.query or parsed.fragment:
        raise SystemExit("public_package_url must not contain query or fragment")
    return value, expected_filename


def download_file(url: str, target: Path, timeout: int) -> None:
    request = urllib.request.Request(url, method="GET")
    opener = urllib.request.build_opener(NoRedirectHandler())
    with opener.open(request, timeout=timeout) as response:
        if response.geturl() != url:
            raise SystemExit("public package download redirected away from canonical URL")
        with target.open("wb") as handle:
            shutil.copyfileobj(response, handle)


def package_filename(remote: dict[str, Any]) -> str | None:
    explicit = remote.get("public_package_filename")
    if explicit:
        return str(explicit)
    url = str(remote.get("public_package_url") or "")
    if not url:
        return None
    name = Path(urllib.parse.urlparse(url).path).name
    return name or None


def manual_install_report(
    *,
    skill_id: str,
    local_version: Any,
    remote_version: Any,
    remote: dict[str, Any],
    reason: str,
) -> dict[str, Any]:
    return {
        "ok": False,
        "updated": False,
        "manual_install_required": True,
        "reason": reason,
        "skill_id": skill_id,
        "local_public_skill_version": local_version,
        "remote_public_skill_version": remote_version,
        "public_package_url": remote.get("public_package_url"),
        "public_package_filename": package_filename(remote),
        "public_package_sha256": remote.get("public_package_sha256"),
        "expected_zip_root": f"{skill_id}/",
        "expected_skill_name": skill_id,
        "install_rule": "The zip filename may include a date suffix, but the extracted root folder and SKILL.md frontmatter name must remain the original skill_id.",
        "manual_steps": [
            "Download the public package from public_package_url.",
            "Verify the downloaded zip SHA256 matches public_package_sha256.",
            f"Confirm the zip root folder is {skill_id}/ and SKILL.md contains name: {skill_id}.",
            "Back up the old local public skill folder.",
            "Replace it with the verified public skill folder.",
            "Start a new conversation or reload the skill before relying on changed SKILL.md instructions.",
        ],
    }


def can_replace_skill_dir(current: Path) -> tuple[bool, str]:
    if not current.exists():
        return False, f"skill root not found: {current}"
    parent = current.parent
    try:
        parent.mkdir(parents=True, exist_ok=True)
        probe = parent / f".{current.name}-permission-probe-{dt.datetime.now().strftime('%Y%m%d%H%M%S%f')}"
        probe.mkdir()
        probe.rmdir()
    except OSError as exc:
        return False, f"local skill directory is not writable: {exc}"
    return True, ""


def extract_verified_zip(zip_path: Path, skill_id: str, parent: Path) -> Path:
    target = parent / skill_id
    parent.mkdir(parents=True, exist_ok=True)
    try:
        zip_path = inspect_archive_path(zip_path)
        with zipfile.ZipFile(zip_path) as archive:
            members = inspect_archive_members(archive)
            safe_extract_archive(archive, members, parent)
    except (
        ArchiveSafetyError,
        OSError,
        zipfile.BadZipFile,
        zipfile.LargeZipFile,
    ) as exc:
        raise SystemExit(f"safe update extraction failed: {exc}") from exc
    if not target.is_dir():
        raise SystemExit(f"extracted package did not create {target}")
    return target


def validate_replacement_dir(path: Path, skill_id: str) -> None:
    manifest = read_json(path / VERSION_FILE)
    if manifest.get("skill_id") != skill_id:
        raise SystemExit("replacement online-version.json skill_id does not match")
    skill_md = path / "SKILL.md"
    if not skill_md.is_file():
        raise SystemExit("replacement SKILL.md missing")
    text = skill_md.read_text("utf-8", errors="replace")
    name_match = FRONTMATTER_NAME_RE.search(text)
    if not name_match or name_match.group(1).strip() != skill_id:
        raise SystemExit("replacement SKILL.md frontmatter name does not match")
    validate_runtime_contract(manifest, text)


def replace_skill_dir(current: Path, replacement: Path, skill_id: str) -> Path:
    timestamp = dt.datetime.now().strftime("%Y%m%d%H%M%S")
    backup = current.parent / f".{current.name}-backup-{timestamp}"
    staging = current.parent / f".{current.name}-incoming-{timestamp}"
    validate_replacement_dir(replacement, skill_id)
    shutil.move(str(replacement), str(staging))
    current_moved = False
    try:
        shutil.move(str(current), str(backup))
        current_moved = True
        shutil.move(str(staging), str(current))
        validate_replacement_dir(current, skill_id)
    except BaseException as original_error:
        rollback_error: OSError | None = None
        if current_moved and backup.exists():
            try:
                if current.exists():
                    failed = current.parent / f".{current.name}-failed-update-{timestamp}"
                    shutil.move(str(current), str(failed))
                shutil.move(str(backup), str(current))
            except OSError as exc:
                rollback_error = exc
        if rollback_error is not None:
            raise RuntimeError(f"public skill update failed and rollback also failed: {rollback_error}") from original_error
        raise
    return backup


def command_status(args: argparse.Namespace) -> int:
    skill_root = args.skill_root.expanduser().resolve()
    local = read_local_manifest(skill_root)
    skill_id = resolve_skill_id(skill_root, args.skill_id, local)
    remote = fetch_server_version(skill_id, args.timeout)
    local_version = local.get("public_skill_version")
    remote_version = remote.get("public_skill_version")
    report = {
        "ok": True,
        "skill_id": skill_id,
        "local_public_skill_version": local_version,
        "remote_public_skill_version": remote_version,
        "remote_protected_payload_version": remote.get("protected_payload_version"),
        "update_required": is_newer(remote_version, local_version),
        "api_base": remote.get("api_base"),
    }
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 0


def command_verify_package(args: argparse.Namespace) -> int:
    report = verify_public_zip(
        args.zip.expanduser().resolve(),
        args.skill_id,
        args.expected_sha256,
        args.expected_version,
    )
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 0


def command_update(args: argparse.Namespace) -> int:
    skill_root = args.skill_root.expanduser().resolve()
    local = read_local_manifest(skill_root)
    skill_id = resolve_skill_id(skill_root, args.skill_id, local)
    remote = fetch_server_version(skill_id, args.timeout)
    local_version = local.get("public_skill_version")
    remote_version = remote.get("public_skill_version")
    update_required = is_newer(remote_version, local_version)
    if not update_required:
        print(json.dumps({
            "ok": True,
            "updated": False,
            "reason": "local public skill is current",
            "local_public_skill_version": local_version,
            "remote_public_skill_version": remote_version,
        }, ensure_ascii=False, indent=2))
        return 0

    package_url = remote.get("public_package_url")
    package_sha256 = remote.get("public_package_sha256")
    if not package_url or not package_sha256:
        raise SystemExit("server reports a newer public skill but did not return public_package_url and public_package_sha256")
    package_url, canonical_filename = canonical_public_package_url(
        remote,
        skill_id=skill_id,
        public_version=remote_version,
    )

    if not args.apply:
        print(json.dumps({
            "ok": True,
            "updated": False,
            "dry_run": True,
            "update_required": True,
            "local_public_skill_version": local_version,
            "remote_public_skill_version": remote_version,
            "public_package_url": package_url,
            "public_package_filename": canonical_filename,
            "public_package_sha256": package_sha256,
            "note": "Run again with --apply to download, verify, backup, and replace the public skill directory.",
        }, ensure_ascii=False, indent=2))
        return 0

    permission_ok, permission_reason = can_replace_skill_dir(skill_root)
    if not permission_ok:
        print(json.dumps(manual_install_report(
            skill_id=skill_id,
            local_version=local_version,
            remote_version=remote_version,
            remote=remote,
            reason=permission_reason,
        ), ensure_ascii=False, indent=2))
        return 3

    with tempfile.TemporaryDirectory(prefix=f"{skill_id}-public-update-") as tmp:
        tmpdir = Path(tmp)
        zip_path = tmpdir / "public-skill.zip"
        download_file(package_url, zip_path, args.timeout)
        verify_public_zip(
            zip_path,
            skill_id,
            expected_sha256=package_sha256,
            expected_version=remote_version,
        )
        replacement = extract_verified_zip(zip_path, skill_id, tmpdir / "extract")
        try:
            backup = replace_skill_dir(skill_root, replacement, skill_id)
        except PermissionError as exc:
            print(json.dumps(manual_install_report(
                skill_id=skill_id,
                local_version=local_version,
                remote_version=remote_version,
                remote=remote,
                reason=f"permission denied during replacement: {exc}",
            ), ensure_ascii=False, indent=2))
            return 3
        except OSError as exc:
            print(json.dumps(manual_install_report(
                skill_id=skill_id,
                local_version=local_version,
                remote_version=remote_version,
                remote=remote,
                reason=f"local replacement failed: {exc}",
            ), ensure_ascii=False, indent=2))
            return 3

    print(json.dumps({
        "ok": True,
        "updated": True,
        "skill_id": skill_id,
        "old_public_skill_version": local_version,
        "new_public_skill_version": remote_version,
        "backup": str(backup),
        "note": "If SKILL.md changed, start a new conversation or reload the skill before relying on new instructions.",
    }, ensure_ascii=False, indent=2))
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Check and safely update a public online skill package")
    subparsers = parser.add_subparsers(dest="command", required=True)

    status = subparsers.add_parser("status", help="Check local and server public-skill versions")
    status.add_argument("--skill-root", type=Path, default=PACKAGE_ROOT)
    status.add_argument("--skill-id")
    status.add_argument("--timeout", type=int, default=10)
    status.set_defaults(func=command_status)

    verify = subparsers.add_parser("verify-package", help="Verify a downloaded public skill zip")
    verify.add_argument("zip", type=Path)
    verify.add_argument("--skill-id", required=True)
    verify.add_argument("--expected-sha256")
    verify.add_argument("--expected-version", help="Require the package public_skill_version to match this value")
    verify.set_defaults(func=command_verify_package)

    update = subparsers.add_parser("update", help="Download, verify, backup, and replace a stale public skill")
    update.add_argument("--skill-root", type=Path, default=PACKAGE_ROOT)
    update.add_argument("--skill-id")
    update.add_argument("--timeout", type=int, default=30)
    update.add_argument("--apply", action="store_true", help="Actually replace the local public skill directory")
    update.set_defaults(func=command_update)

    args = parser.parse_args()
    return args.func(args)


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