#!/usr/bin/env python3
from __future__ import annotations

import argparse
from concurrent.futures import ThreadPoolExecutor
import json
import os
import shutil
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SPECS_DIR = REPO_ROOT / "model_specs"


class ManagerError(RuntimeError):
    pass


class InstallCancelled(ManagerError):
    pass


@dataclass(frozen=True)
class PackageRecord:
    family: str
    id: str
    display_name: str
    target_directory: str
    format: str
    precision: str
    files: tuple[str, ...]
    strip_prefix: str
    download: dict[str, Any]
    default: bool
    source_overridden: bool = False


@dataclass(frozen=True)
class RemoteFileInfo:
    size: int | None
    revision: str
    etag: str


def huggingface_token() -> str | None:
    token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
    if token:
        return token.strip()
    token_path = Path.home() / ".cache" / "huggingface" / "token"
    if token_path.is_file():
        cached = token_path.read_text(encoding="utf-8").strip()
        if cached:
            return cached
    return None


def hf_endpoint() -> str:
    """Base URL for Hugging Face Hub requests.

    Honors the standard HF_ENDPOINT environment variable (e.g.
    HF_ENDPOINT=https://hf-mirror.com for the hf-mirror mirror). Falls back to
    https://huggingface.co. Empty values and trailing slashes are tolerated.
    """
    endpoint = os.environ.get("HF_ENDPOINT", "").strip().rstrip("/")
    return endpoint or "https://huggingface.co"


def ms_endpoint() -> str:
    """Base URL for ModelScope requests.

    Honors AUDIOCPP_MS_BASE_URL, mirroring the native C++ package manager.
    Falls back to https://www.modelscope.cn. Empty values and trailing slashes
    are tolerated.
    """
    endpoint = os.environ.get("AUDIOCPP_MS_BASE_URL", "").strip().rstrip("/")
    return endpoint or "https://www.modelscope.cn"


def modelscope_token() -> str | None:
    token = os.environ.get("AUDIOCPP_MS_TOKEN", "").strip()
    return token or None


def http_headers(source: str = "huggingface") -> dict[str, str]:
    # Auth is provider-scoped: HF requests may carry the HF token, ModelScope
    # requests carry only AUDIOCPP_MS_TOKEN, so one provider's credential is
    # never sent to the other provider's host.
    headers = {"User-Agent": "audio.cpp model_manager_v2.py"}
    token = modelscope_token() if source == "modelscope" else huggingface_token()
    if token:
        headers["Authorization"] = f"Bearer {token}"
    return headers


def quote_repo_path(value: str) -> str:
    return "/".join(quote(part, safe="") for part in value.split("/"))


def validate_relative_path(value: str, label: str) -> Path:
    path = Path(value)
    if path.is_absolute() or any(part == ".." for part in path.parts):
        raise ManagerError(f"{label} must be a safe relative path: {value}")
    if not value or value == ".":
        raise ManagerError(f"{label} must not be empty")
    return path


def stripped_path(remote_path: str, strip_prefix: str) -> Path:
    result = remote_path
    if strip_prefix:
        prefix = strip_prefix.rstrip("/")
        if result == prefix:
            raise ManagerError(f"strip_prefix removes the whole file path: {remote_path}")
        if not result.startswith(prefix + "/"):
            raise ManagerError(f"file path does not start with strip_prefix '{strip_prefix}': {remote_path}")
        result = result[len(prefix) + 1 :]
    return validate_relative_path(result, "local file path")


def load_specs(specs_dir: Path) -> list[dict[str, Any]]:
    specs: list[dict[str, Any]] = []
    for path in sorted(specs_dir.glob("*.json")):
        with path.open("r", encoding="utf-8") as handle:
            spec = json.load(handle)
        spec["_spec_path"] = str(path)
        specs.append(spec)
    return specs


def merged_download(spec: dict[str, Any], package: dict[str, Any]) -> dict[str, Any]:
    defaults = spec.get("package_defaults", {}).get("download", {})
    download = dict(defaults)
    download.update(package.get("download", {}))
    return download


def package_kind(package: PackageRecord) -> str:
    return str(package.download.get("kind", ""))


def package_source(package: PackageRecord) -> str:
    """Download provider for a package: 'modelscope' or 'huggingface'."""
    return "modelscope" if package_kind(package) == "modelscope_snapshot" else "huggingface"


def package_revision(package: PackageRecord) -> str:
    """Kind-aware revision default: master on ModelScope, main on Hugging Face."""
    revision = str(package.download.get("revision", "")).strip()
    if revision:
        return revision
    return "master" if package_kind(package) == "modelscope_snapshot" else "main"


def apply_source_override(record: PackageRecord, source_repo: str | None) -> PackageRecord:
    """Redirect a package's download to ModelScope (--source modelscope).

    The repo comes from --source-repo when given, otherwise the spec's own
    repo is reused on ModelScope. An unset or 'main' spec revision becomes
    ModelScope's default branch ('master', via the kind-aware default); any
    other explicit revision passes through unchanged.
    """
    download = dict(record.download)
    download["kind"] = "modelscope_snapshot"
    if source_repo:
        download["repo"] = source_repo
    revision = str(download.get("revision", "")).strip()
    if not revision or revision == "main":
        download.pop("revision", None)
    return replace(record, download=download, source_overridden=True)


def source_override_hint(package: PackageRecord) -> str:
    if package.source_overridden:
        return "; the spec repo may not exist on ModelScope, name it with --source-repo <namespace/name>"
    return ""


def flatten_packages(specs: list[dict[str, Any]]) -> list[PackageRecord]:
    records: list[PackageRecord] = []
    for spec in specs:
        family = spec["family"]
        for package in spec.get("packages", []):
            records.append(
                PackageRecord(
                    family=family,
                    id=package["id"],
                    display_name=package["display_name"],
                    target_directory=package["target_directory"],
                    format=package["format"],
                    precision=package["precision"],
                    files=tuple(package["files"]),
                    strip_prefix=package.get("strip_prefix", ""),
                    download=merged_download(spec, package),
                    default=bool(package.get("default", False)),
                )
            )
    return records


def select_package(records: list[PackageRecord], args: argparse.Namespace) -> PackageRecord:
    format_filter = getattr(args, "format", None)
    precision_filter = getattr(args, "precision", None)
    exact = [record for record in records if record.id == args.package]
    if exact:
        if format_filter or precision_filter:
            raise ManagerError("format/precision filters are only used when selecting by family")
        return exact[0]

    family = [record for record in records if record.family == args.package]
    if not family:
        raise ManagerError(f"unknown package or family: {args.package}")
    candidates = family
    if format_filter:
        candidates = [record for record in candidates if record.format == format_filter]
    if precision_filter:
        candidates = [record for record in candidates if record.precision == precision_filter]
    if not candidates:
        raise ManagerError(f"no package for family '{args.package}' matches the requested filters")
    default_candidates = [record for record in candidates if record.default]
    if len(default_candidates) == 1:
        return default_candidates[0]
    if len(candidates) == 1:
        return candidates[0]
    ids = ", ".join(record.id for record in candidates)
    raise ManagerError(f"ambiguous family '{args.package}', choose one package id: {ids}")


def hf_url(repo: str, revision: str, remote_path: str) -> str:
    return f"{hf_endpoint()}/{repo}/resolve/{quote(revision, safe='')}/{quote_repo_path(remote_path)}"


def ms_url(repo: str, revision: str, remote_path: str) -> str:
    return f"{ms_endpoint()}/models/{repo}/resolve/{quote(revision, safe='')}/{quote_repo_path(remote_path)}"


def ms_files_url(repo: str, revision: str) -> str:
    return f"{ms_endpoint()}/api/v1/models/{repo}/repo/files?Revision={quote(revision, safe='')}&Recursive=true"


def download_url(package: PackageRecord, remote_path: str) -> str:
    repo = package.download["repo"]
    revision = package_revision(package)
    if package_kind(package) == "modelscope_snapshot":
        return ms_url(repo, revision, remote_path)
    return hf_url(repo, revision, remote_path)


# ModelScope resolve HEAD responses carry no Content-Length or ETag, so
# per-file size and checksum come from the repo file-list API instead. The
# listing is fetched once per repo+revision and shared through this cache; an
# empty entry means the listing was unavailable and HEAD fallback applies.
_MS_LISTING_CACHE: dict[tuple[str, str, str], dict[str, RemoteFileInfo]] = {}
_MS_LISTING_LOCK = threading.Lock()


def ms_repo_listing(repo: str, revision: str) -> dict[str, RemoteFileInfo]:
    key = (ms_endpoint(), repo, revision)
    with _MS_LISTING_LOCK:
        cached = _MS_LISTING_CACHE.get(key)
    if cached is not None:
        return cached
    listing: dict[str, RemoteFileInfo] = {}
    try:
        request = Request(ms_files_url(repo, revision), headers=http_headers("modelscope"))
        with urlopen(request, timeout=60) as response:
            payload = json.loads(response.read().decode("utf-8"))
        if payload.get("Code") != 200:
            raise ManagerError(f"ModelScope repo listing failed for {repo}: {payload.get('Message', 'unknown error')}")
        files = payload.get("Data", {}).get("Files")
        if not isinstance(files, list):
            raise ManagerError(f"ModelScope repo listing has no file list: {repo}")
        for item in files:
            if not isinstance(item, dict) or item.get("Type") != "blob":
                continue
            path = item.get("Path")
            if not path:
                continue
            size = item.get("Size")
            listing[str(path)] = RemoteFileInfo(
                size=int(size) if size is not None else None,
                revision="",
                etag=str(item.get("Sha256") or ""),
            )
    except Exception:
        listing = {}
    with _MS_LISTING_LOCK:
        _MS_LISTING_CACHE[key] = listing
    return listing


def check_ms_remote_file(package: PackageRecord, remote_path: str) -> RemoteFileInfo:
    repo = package.download["repo"]
    revision = package_revision(package)
    listing = ms_repo_listing(repo, revision)
    info = listing.get(remote_path)
    if info is not None:
        return info
    if listing:
        raise ManagerError(
            f"remote file is not accessible: {repo}/{remote_path} (not in the ModelScope repo listing)"
            f"{source_override_hint(package)}"
        )
    # The listing API is unreachable; fall back to a HEAD on the resolve URL,
    # which reports the file checksum as X-Linked-Etag but no size.
    request = Request(ms_url(repo, revision, remote_path), headers=http_headers("modelscope"), method="HEAD")
    try:
        with urlopen(request, timeout=60) as response:
            return RemoteFileInfo(
                size=None,
                revision="",
                etag=response.headers.get("X-Linked-Etag", "").strip('"'),
            )
    except HTTPError as error:
        raise ManagerError(
            f"remote file is not accessible: {repo}/{remote_path} ({error.code})"
            f"{source_override_hint(package)}"
        ) from error


def check_remote_file(package: PackageRecord, remote_path: str) -> RemoteFileInfo:
    if package_kind(package) == "modelscope_snapshot":
        return check_ms_remote_file(package, remote_path)
    repo = package.download["repo"]
    revision = package_revision(package)
    request = Request(hf_url(repo, revision, remote_path), headers=http_headers(), method="HEAD")
    try:
        with urlopen(request, timeout=60) as response:
            size = response.headers.get("Content-Length")
            return RemoteFileInfo(
                size=int(size) if size else None,
                revision=response.headers.get("X-Repo-Commit", ""),
                etag=response.headers.get("ETag", "").strip('"'),
            )
    except HTTPError as error:
        if package.download.get("gated") is True and error.code in (401, 403):
            return RemoteFileInfo(size=None, revision="", etag="")
        raise ManagerError(f"remote file is not accessible: {repo}/{remote_path} ({error.code})") from error


def cancellation_requested(cancel_file: Path | None) -> bool:
    return cancel_file is not None and cancel_file.is_file()


def download_file(
    package: PackageRecord,
    remote_path: str,
    output_path: Path,
    progress=None,
    cancel_file: Path | None = None,
) -> None:
    repo = package.download["repo"]
    request = Request(download_url(package, remote_path), headers=http_headers(package_source(package)))
    try:
        with urlopen(request, timeout=300) as response:
            expected_header = response.headers.get("Content-Length")
            expected = int(expected_header) if expected_header is not None else None
            output_path.parent.mkdir(parents=True, exist_ok=True)
            total = 0
            last_report = 0
            with output_path.open("wb") as handle:
                while True:
                    if cancellation_requested(cancel_file):
                        raise InstallCancelled("download cancelled by user")
                    chunk = response.read(1024 * 1024)
                    if not chunk:
                        break
                    handle.write(chunk)
                    total += len(chunk)
                    if progress is not None and total - last_report >= 8 * 1024 * 1024:
                        progress(total, expected)
                        last_report = total
            if progress is not None:
                progress(total, expected)
            if cancellation_requested(cancel_file):
                raise InstallCancelled("download cancelled by user")
            if expected is not None and total != expected:
                raise ManagerError(f"downloaded size mismatch for {output_path}: {total} != {expected}")
    except HTTPError as error:
        if package.download.get("gated") is True and error.code in (401, 403):
            raise ManagerError(
                f"{repo}/{remote_path} requires accepted Hugging Face access and a valid HF token"
            ) from error
        if package_kind(package) == "modelscope_snapshot" and error.code in (401, 403):
            raise ManagerError(f"{repo}/{remote_path} requires ModelScope access to this repo") from error
        raise ManagerError(
            f"failed to download {repo}/{remote_path}: HTTP {error.code}{source_override_hint(package)}"
        ) from error


def ensure_snapshot_package(package: PackageRecord) -> None:
    kind = package.download.get("kind")
    if kind not in ("huggingface_snapshot", "modelscope_snapshot"):
        raise ManagerError(
            f"{package.id} uses download kind '{kind}'. model_manager_v2 only installs "
            "huggingface_snapshot and modelscope_snapshot packages; "
            "use tools/model_manager.py for legacy composite or converter installs."
        )
    if not package.download.get("repo"):
        raise ManagerError(f"{package.id} has no remote repo for download kind '{kind}'")


def package_manifest_path(package: PackageRecord, models_root: Path) -> Path:
    target_dir = validate_relative_path(package.target_directory, "target_directory")
    return models_root / target_dir / f".audiocpp-package-{package.id}.json"


def read_package_manifest(package: PackageRecord, models_root: Path | None) -> dict[str, Any] | None:
    if models_root is None:
        return None
    path = package_manifest_path(package, models_root)
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
        return value if isinstance(value, dict) else None
    except (OSError, ValueError):
        return None


def write_package_manifest(
    package: PackageRecord,
    models_root: Path,
    resolved_revision: str,
    remote_files: dict[str, RemoteFileInfo],
) -> None:
    path = package_manifest_path(package, models_root)
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "schema_version": 1,
        "package_id": package.id,
        "repo": package.download.get("repo", ""),
        "requested_revision": package_revision(package),
        "resolved_revision": resolved_revision,
        "installed_at_unix": int(time.time()),
        "files": {
            name: {"size": info.size, "etag": info.etag}
            for name, info in remote_files.items()
        },
    }
    temporary = path.with_suffix(path.suffix + ".tmp")
    temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    temporary.replace(path)


def reusable_package_outputs(
    package: PackageRecord,
    records: list[PackageRecord],
    models_root: Path,
) -> set[Path]:
    """Return files safely shared with another completely installed package."""
    target_dir = validate_relative_path(package.target_directory, "target_directory")
    final_dir = models_root / target_dir
    requested = {
        (remote, final_dir / stripped_path(remote, package.strip_prefix))
        for remote in package.files
    }
    reusable: set[Path] = set()
    for other in records:
        if other.id == package.id or other.download != package.download:
            continue
        if not package_is_installed(other, models_root):
            continue
        other_dir = models_root / validate_relative_path(other.target_directory, "target_directory")
        for remote in other.files:
            output = other_dir / stripped_path(remote, other.strip_prefix)
            if (remote, output) in requested:
                reusable.add(output)
    return reusable


def install_package(package: PackageRecord, records: list[PackageRecord], args: argparse.Namespace) -> None:
    ensure_snapshot_package(package)
    target_dir = validate_relative_path(package.target_directory, "target_directory")
    models_root = Path(args.models_root)
    final_dir = models_root / target_dir
    plan = [(remote, final_dir / stripped_path(remote, package.strip_prefix)) for remote in package.files]
    full_plan = list(plan)

    print(f"selected {package.id} ({package.family})")
    print(f"repo {package.download['repo']}@{package_revision(package)}")
    print(f"target {final_dir}")
    for remote, output in plan:
        if args.check:
            info = check_remote_file(package, remote)
            if info.size is None and package.download.get("gated") is True:
                suffix = " gated_access_required"
            else:
                suffix = f" size={info.size}" if info.size is not None else ""
            print(f"check {remote}{suffix}")
        else:
            print(f"file remote={remote} local={output.relative_to(models_root)}")

    if args.dry_run or args.check:
        return
    existing_outputs = [output for _remote, output in plan if output.exists()]
    if existing_outputs and not args.overwrite:
        if len(existing_outputs) == len(plan) and all(output.is_file() for output in existing_outputs):
            print(f"already installed {package.id} -> {final_dir}")
            return
        reusable = reusable_package_outputs(package, records, models_root)
        conflicts = [output for output in existing_outputs if output not in reusable]
        if conflicts:
            raise ManagerError(f"some package files already exist in: {final_dir} (use --overwrite)")
        plan = [(remote, output) for remote, output in plan if output not in reusable]
    models_root.mkdir(parents=True, exist_ok=True)
    staging = Path(tempfile.mkdtemp(prefix=f".{package.target_directory.replace('/', '_')}.", dir=models_root))
    cancel_file = Path(args.cancel_file) if getattr(args, "cancel_file", "") else None
    try:
        if cancellation_requested(cancel_file):
            raise InstallCancelled("download cancelled by user")
        progress_enabled = bool(getattr(args, "progress", False))
        remote_files: dict[str, RemoteFileInfo] = {}
        if progress_enabled:
            remote_files = {remote: check_remote_file(package, remote) for remote, _output in plan}
        elif plan:
            remote_files[plan[0][0]] = check_remote_file(package, plan[0][0])
        expected_sizes = [remote_files[remote].size for remote, _output in plan] if progress_enabled else []
        total_expected = sum(size for size in expected_sizes if size is not None)
        if any(size is None for size in expected_sizes):
            total_expected = 0
        completed = 0

        def emit_progress(downloaded: int) -> None:
            print(
                f"AUDIOCPP_PROGRESS downloaded={downloaded} total={total_expected}",
                flush=True,
            )

        if progress_enabled:
            emit_progress(0)
        for remote, output in plan:
            destination = staging / output.relative_to(final_dir)
            if progress_enabled:
                download_file(
                    package,
                    remote,
                    destination,
                    lambda file_bytes, _expected, base=completed: emit_progress(base + file_bytes),
                    cancel_file,
                )
                completed += destination.stat().st_size
                emit_progress(completed)
            else:
                download_file(package, remote, destination, cancel_file=cancel_file)
        if cancellation_requested(cancel_file):
            raise InstallCancelled("download cancelled by user")
        # Record every package-owned file, including sidecars reused from a
        # sibling precision. Version checks compare these per-file ETags and
        # must not depend on a repository-wide revision.
        for remote, _output in full_plan:
            if remote not in remote_files:
                remote_files[remote] = check_remote_file(package, remote)
        if not final_dir.exists():
            final_dir.parent.mkdir(parents=True, exist_ok=True)
            staging.rename(final_dir)
        else:
            # Precision variants commonly share one target directory. Merge the
            # fully downloaded staging tree so Q8 and F16/BF16 can coexist. An
            # overwrite replaces only this package's files, preserving siblings.
            for staged_file in sorted(path for path in staging.rglob("*") if path.is_file()):
                relative = staged_file.relative_to(staging)
                destination = final_dir / relative
                destination.parent.mkdir(parents=True, exist_ok=True)
                if destination.exists():
                    if not args.overwrite:
                        raise ManagerError(f"package file already exists: {destination} (use --overwrite)")
                    destination.unlink()
                staged_file.replace(destination)
            shutil.rmtree(staging)
        resolved_revision = next(
            (info.revision for info in remote_files.values() if info.revision),
            package_revision(package),
        )
        write_package_manifest(package, models_root, resolved_revision, remote_files)
    except Exception:
        shutil.rmtree(staging, ignore_errors=True)
        raise
    print(f"installed {package.id} -> {final_dir}")


def uninstall_package(package: PackageRecord, records: list[PackageRecord], args: argparse.Namespace) -> None:
    target_dir = validate_relative_path(package.target_directory, "target_directory")
    models_root = Path(args.models_root)
    final_dir = models_root / target_dir
    package_files = [final_dir / stripped_path(remote, package.strip_prefix) for remote in package.files]
    manifest_path = package_manifest_path(package, models_root)
    existing = [path for path in package_files if path.is_file() or path.is_symlink()]
    if not existing:
        raise ManagerError(f"package is not installed: {package.id}")

    reusable = reusable_package_outputs(package, records, models_root)
    for path in existing:
        if path not in reusable:
            path.unlink()
    manifest_path.unlink(missing_ok=True)

    # Remove only directories made empty by this package. Other precision
    # variants and unrelated files in the shared target remain untouched.
    cleanup: set[Path] = set()
    for path in package_files:
        parent = path.parent
        while parent != final_dir.parent:
            cleanup.add(parent)
            parent = parent.parent
    for directory in sorted(cleanup, key=lambda path: len(path.parts), reverse=True):
        try:
            directory.rmdir()
        except OSError:
            pass
    print(f"removed {package.id} -> {final_dir}")


def clean_partial_package(package: PackageRecord, args: argparse.Namespace) -> None:
    models_root = Path(args.models_root).resolve()
    models_root.mkdir(parents=True, exist_ok=True)
    prefix = f".{package.target_directory.replace('/', '_')}."
    removed = 0
    for candidate in models_root.glob(prefix + "*"):
        resolved = candidate.resolve()
        try:
            resolved.relative_to(models_root)
        except ValueError as error:
            raise ManagerError(f"refusing to clean staging path outside models root: {resolved}") from error
        if candidate.is_dir():
            shutil.rmtree(candidate)
            removed += 1
    print(f"cleaned {removed} partial download director{'y' if removed == 1 else 'ies'} for {package.id}")


def command_list(records: list[PackageRecord], args: argparse.Namespace) -> None:
    rows = [
        {
            "family": record.family,
            "id": record.id,
            "display_name": record.display_name,
            "format": record.format,
            "precision": record.precision,
            "default": record.default,
            "target_directory": record.target_directory,
            "repo": record.download.get("repo", ""),
        }
        for record in records
    ]
    if args.json:
        print(json.dumps(rows, indent=2, ensure_ascii=False))
        return
    for row in rows:
        default = " default" if row["default"] else ""
        print(f"{row['id']:<44} {row['family']:<24} {row['format']:<11} {row['precision']:<8}{default}")


def command_info(records: list[PackageRecord], args: argparse.Namespace) -> None:
    package = select_package(records, args)
    row = {
        "family": package.family,
        "id": package.id,
        "display_name": package.display_name,
        "format": package.format,
        "precision": package.precision,
        "default": package.default,
        "target_directory": package.target_directory,
        "files": list(package.files),
        "strip_prefix": package.strip_prefix,
        "download": package.download,
    }
    if args.json:
        print(json.dumps(row, indent=2, ensure_ascii=False))
        return
    print(f"id: {package.id}")
    print(f"family: {package.family}")
    print(f"name: {package.display_name}")
    print(f"format: {package.format}")
    print(f"precision: {package.precision}")
    print(f"target: {package.target_directory}")
    print(f"download: {package.download.get('kind')} {package.download.get('repo', '')}")
    for remote in package.files:
        print(f"file: {remote}")


def package_is_installed(package: PackageRecord, models_root: Path | None) -> bool:
    if models_root is None:
        return False
    target_dir = validate_relative_path(package.target_directory, "target_directory")
    final_dir = models_root / target_dir
    if not final_dir.is_dir():
        return False
    return bool(package.files) and all(
        (final_dir / stripped_path(remote_path, package.strip_prefix)).is_file()
        for remote_path in package.files
    )


def package_version_state(
    installed: bool,
    manifest: dict[str, Any] | None,
    remote_files: dict[str, RemoteFileInfo],
) -> str:
    """Compare only files owned by this package, not the repository commit.

    Hugging Face's X-Repo-Commit changes whenever *any* file in a repository is
    uploaded. Several audio.cpp packages share one repository, so comparing
    that repository-wide revision incorrectly marked every installed package
    as outdated after an unrelated model upload. Per-file ETags identify the
    actual package payload and avoid that false positive.
    """
    if not installed:
        return "not_installed"
    manifest_files = manifest.get("files") if isinstance(manifest, dict) else None
    if not isinstance(manifest_files, dict):
        return "unknown"

    compared = 0
    for remote_path, stored in manifest_files.items():
        if not isinstance(stored, dict):
            continue
        local_etag = str(stored.get("etag", "")).strip('"')
        remote_etag = remote_files.get(remote_path, RemoteFileInfo(None, "", "")).etag.strip('"')
        if not local_etag or not remote_etag:
            continue
        compared += 1
        if local_etag != remote_etag:
            return "update_available"
    return "up_to_date" if compared else "unknown"


def package_size_record(package: PackageRecord, models_root: Path | None = None) -> dict[str, Any]:
    installed = package_is_installed(package, models_root)
    try:
        ensure_snapshot_package(package)
        total = 0
        unknown = False
        remote_revision = ""
        remote_files: dict[str, RemoteFileInfo] = {}
        for remote_path in package.files:
            info = check_remote_file(package, remote_path)
            remote_files[remote_path] = info
            if info.size is None:
                unknown = True
            else:
                total += info.size
            if not remote_revision and info.revision:
                remote_revision = info.revision
        manifest = read_package_manifest(package, models_root)
        local_revision = str(manifest.get("resolved_revision", "")) if manifest else ""
        version_state = package_version_state(installed, manifest, remote_files)
        state = "gated" if unknown and package.download.get("gated") is True else "unknown" if unknown else "ok"
        return {
            "id": package.id,
            "size_bytes": None if unknown else total,
            "state": state,
            "message": "Hugging Face access and a valid token are required" if state == "gated" else "",
            "installed": installed,
            "version_state": version_state,
            "local_revision": local_revision,
            "remote_revision": remote_revision,
        }
    except ManagerError as error:
        return {
            "id": package.id,
            "size_bytes": None,
            "state": "error",
            "message": str(error),
            "installed": installed,
            "version_state": "unknown" if installed else "not_installed",
            "local_revision": "",
            "remote_revision": "",
        }


def command_sizes(records: list[PackageRecord], args: argparse.Namespace) -> None:
    selected = records
    if args.package:
        requested = set(args.package)
        selected = [record for record in records if record.id in requested]
        missing = sorted(requested - {record.id for record in selected})
        if missing:
            raise ManagerError(f"unknown package ids: {', '.join(missing)}")
    models_root = Path(args.models_root) if args.models_root else None
    with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
        rows = list(pool.map(lambda package: package_size_record(package, models_root), selected))
    print(json.dumps(rows, ensure_ascii=False))


def command_installed(records: list[PackageRecord], args: argparse.Namespace) -> None:
    models_root = Path(args.models_root)
    rows = [
        {
            "id": package.id,
            "size_bytes": None,
            "state": "pending",
            "message": "",
            "installed": package_is_installed(package, models_root),
            "version_state": "unknown" if package_is_installed(package, models_root) else "not_installed",
            "local_revision": "",
            "remote_revision": "",
        }
        for package in records
    ]
    print(json.dumps(rows, ensure_ascii=False))


def add_source_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--source",
        choices=["huggingface", "modelscope"],
        default="huggingface",
        help="download source override; 'modelscope' downloads from ModelScope even when the "
        "spec says huggingface_snapshot (an unset or 'main' revision becomes 'master'). "
        "Manifest etags are source-specific, so cross-source checks may report updates",
    )
    parser.add_argument(
        "--source-repo",
        help="ModelScope repo (namespace/name) for --source modelscope; defaults to the spec's repo",
    )


def make_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Install audio.cpp model packages from model_specs/*.json.")
    parser.add_argument("--specs-dir", default=str(DEFAULT_SPECS_DIR), help="directory containing model spec JSON files")
    sub = parser.add_subparsers(dest="command", required=True)

    list_parser = sub.add_parser("list", help="list installable packages")
    list_parser.add_argument("--json", action="store_true")

    info_parser = sub.add_parser("info", help="show one package or family default")
    info_parser.add_argument("package")
    info_parser.add_argument("--format")
    info_parser.add_argument("--precision")
    info_parser.add_argument("--json", action="store_true")

    sizes_parser = sub.add_parser("sizes", help="check package download sizes without downloading files")
    sizes_parser.add_argument("package", nargs="*", help="optional package ids; default checks every package")
    sizes_parser.add_argument("--jobs", type=int, default=12, help="parallel metadata checks")
    sizes_parser.add_argument("--models-root", help="also report packages whose required files are installed")
    sizes_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON")
    add_source_arguments(sizes_parser)

    installed_parser = sub.add_parser("installed", help="report locally installed packages without network access")
    installed_parser.add_argument("--models-root", default="models")
    installed_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON")

    uninstall_parser = sub.add_parser("uninstall", help="remove only the files belonging to one installed package")
    uninstall_parser.add_argument("package", help="package id or family")
    uninstall_parser.add_argument("--format")
    uninstall_parser.add_argument("--precision")
    uninstall_parser.add_argument("--models-root", default="models")

    clean_parser = sub.add_parser("clean-partial", help="remove abandoned staging downloads for one package")
    clean_parser.add_argument("package", help="package id or family")
    clean_parser.add_argument("--models-root", default="models")

    install_parser = sub.add_parser("install", help="install one Hugging Face or ModelScope snapshot package")
    install_parser.add_argument("package", help="package id or family")
    install_parser.add_argument("--format")
    install_parser.add_argument("--precision")
    install_parser.add_argument("--models-root", default="models")
    install_parser.add_argument("--overwrite", action="store_true")
    install_parser.add_argument("--cancel-file", default="", help=argparse.SUPPRESS)
    install_parser.add_argument("--dry-run", action="store_true")
    install_parser.add_argument("--check", action="store_true", help="check remote files without downloading")
    install_parser.add_argument(
        "--progress",
        action="store_true",
        help="emit machine-readable AUDIOCPP_PROGRESS lines while downloading",
    )
    add_source_arguments(install_parser)
    return parser


def main() -> int:
    parser = make_parser()
    args = parser.parse_args()
    source = getattr(args, "source", "huggingface")
    source_repo = getattr(args, "source_repo", None)
    if source_repo and source != "modelscope":
        parser.error("--source-repo requires --source modelscope")
    try:
        records = flatten_packages(load_specs(Path(args.specs_dir)))
        if source == "modelscope":
            records = [apply_source_override(record, source_repo) for record in records]
        if args.command == "list":
            command_list(records, args)
        elif args.command == "info":
            command_info(records, args)
        elif args.command == "sizes":
            command_sizes(records, args)
        elif args.command == "installed":
            command_installed(records, args)
        elif args.command == "uninstall":
            uninstall_package(select_package(records, args), records, args)
        elif args.command == "clean-partial":
            clean_partial_package(select_package(records, args), args)
        elif args.command == "install":
            install_package(select_package(records, args), records, args)
        else:
            raise ManagerError(f"unknown command: {args.command}")
        return 0
    except ManagerError as error:
        print(f"error: {error}", file=sys.stderr)
        return 1


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