"""Build/CI operations with caller-owned inputs, independent of live selection."""
from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
import sys
import time
from types import MappingProxyType

from pm.filesystem import long_root, native
from pm.install import InstalledPackage
from pm.lock import Lockfile
from pm.package import InstallError


def check_project_lock(
    source: Path, *, python: Path | None = None, cache: Path | None = None,
    env: Mapping[str, str] | None = None, offline: bool = False, explicit: bool = False,
    quiet: bool = False,
) -> None:
    """Reject a missing or stale lock without rewriting source or creating a venv.

    ``quiet`` captures uv's output instead of reporting it: for a caller that
    expects a stale lock and says so itself, the red failure tail is noise.
    The error still carries uv's message.
    """
    from pm.environment import managed_environment
    from pm.operations import _require_install_allowed

    if not offline:
        _require_install_allowed(explicit)
    source = Path(source).absolute()
    if not (source / "pyproject.toml").is_file():
        raise InstallError("venv", f"project manifest is missing: {source}")
    environment = managed_environment(
        source / ".venv", python=Path(python) if python is not None else None,
        cache=Path(cache) if cache is not None else None, env=env,
        offline=offline, explicit=explicit, output=None if quiet else sys.stderr,
    )
    environment.check_lock(source)


def export_requirements(
    source: Path, out: Path, *, extras: Sequence[str] = (), python: Path | None = None,
    cache: Path | None = None, env: Mapping[str, str] | None = None, explicit: bool = False,
) -> None:
    """Export locked runtime requirements, preserving markers and direct URL pins."""
    from pm.environment import managed_environment
    from pm.operations import _require_install_allowed

    _require_install_allowed(explicit)
    source, out = Path(source).absolute(), Path(out).absolute()
    if not (source / "pyproject.toml").is_file():
        raise InstallError("venv", f"project manifest is missing: {source}")
    if not (source / "uv.lock").is_file():
        raise InstallError("venv", f"frozen export requires a lock: {source / 'uv.lock'}")
    environment = managed_environment(
        source / ".venv", python=Path(python) if python is not None else None,
        cache=Path(cache) if cache is not None else None, env=env,
        explicit=explicit, output=sys.stderr,
    )
    out.parent.mkdir(parents=True, exist_ok=True)
    environment.export_requirements(source, out, extras=extras)


def build_requirements_environment(
    requirements: Sequence[str], *, out: Path, python: Path | None = None,
    cache: Path | None = None, env: Mapping[str, str] | None = None,
    wheelhouse: Path | None = None, offline: bool = False, sealed: bool = False,
    explicit: bool = False, lock: Path | None = None,
) -> Path:
    """Create and check a fresh environment. Never mutate an existing destination.

    Wheelhouse builds disable indexes and source builds. Failure removes only
    this invocation's exclusively claimed output, not prior builds. *lock* is the
    uv.lock the requirements were exported from: its overrides apply to the
    install and the closing check.
    """
    from pm.environment import _fresh_build, managed_environment
    from pm.operations import _require_install_allowed, _requirements

    _require_install_allowed(explicit)
    requirements = _requirements(requirements) if requirements or isinstance(requirements, str) else []
    out = Path(out).absolute()
    lock = Path(lock).absolute() if lock is not None else None
    wheelhouse = Path(wheelhouse).absolute() if wheelhouse is not None else None
    if wheelhouse is not None and not wheelhouse.is_dir():
        raise InstallError("venv", f"wheelhouse directory is missing: {wheelhouse}")
    if out.exists() or out.is_symlink():
        raise FileExistsError(f"environment destination already exists: {out}")
    environment = managed_environment(
        out, python=Path(python) if python is not None else None,
        cache=Path(cache) if cache is not None else None, env=env,
        offline=offline, explicit=explicit, output=sys.stderr,
    )
    with _fresh_build(environment, sealed=sealed, lock=lock):
        environment.install_requirements(requirements, wheelhouse=wheelhouse, lock=lock)
    return environment.executable


def prepare_tools(names: Sequence[str], *, out: Path, target: str,
                  cache: Path | None = None) -> Path:
    """Realize an explicit tool closure in a build-owned store, not live state."""
    from pm.install import _install, _lockfile
    from pm.lock import Facts
    from pm.package import StatePackage
    from pm.registry import walk
    from pm.store import Store

    if isinstance(names, str):
        raise TypeError("names must be a sequence, not a string")
    packages = walk(list(names))
    if any(isinstance(package, StatePackage) for package in packages):
        raise ValueError("prepare_tools accepts tools, not application state")
    out = Path(out).resolve()
    store, lock = Store(out), _lockfile()
    with store.install_lock():
        facts = Facts(out / "facts.json", strict=True)
        for package in packages:
            _install(package, lock, facts, store, target, _lock_held=True)
    return out


def _copy_links(package, entry: Path) -> None:
    import os
    from pm.filesystem import is_junction

    for directory, dirs, files in os.walk(entry):
        for name in dirs + files:
            path = Path(directory) / name
            if path.is_symlink() or is_junction(path):
                if (Path(os.readlink(path)).is_absolute() or not path.exists()
                        or not path.resolve().is_relative_to(entry)):
                    raise InstallError(package.name, f"tool copy link escapes entry: {native(path)}")


@dataclass(frozen=True)
class VerifiedTools:
    entries: Mapping[str, InstalledPackage]
    _envs: tuple[dict, ...]

    def environment(self, base: Mapping[str, str]) -> dict[str, str]:
        from pm.package import compose_env

        return compose_env(list(self._envs), base=dict(base))


def verified_tools(names: Sequence[str], *, source_store: Path, target: str,
                   lock: Lockfile | None = None) -> VerifiedTools:
    """Read a pinned tool closure without writes, acquisition or binary execution.

    Publication already ran package verification. Admission binds its recorded
    digest to the canonical entry, lock and declared environment; it does not
    rerun arbitrary version probes just to read a previously published tool.
    """
    from pm.install import _identity, _lockfile
    from pm.lock import Facts
    from pm.package import StatePackage
    from pm.registry import walk
    from pm.store import tree_digest

    if isinstance(names, str):
        raise TypeError("names must be a sequence, not a string")
    packages = walk(list(names))
    if any(isinstance(package, StatePackage) for package in packages):
        raise ValueError("verified_tools accepts tool packages, not application state")
    source_store = Path(source_store).resolve()
    if not (source_store / "facts.json").is_file():
        raise InstallError("tools", f"source facts missing: {source_store}")
    # Admission walks and digests whole tool trees: spell the root like Store does.
    source_store = long_root(source_store)
    facts = Facts(source_store / "facts.json", strict=True)
    lock = lock if lock is not None else _lockfile()
    entries, envs = {}, []
    for package in packages:
        if getattr(package, "pin_only", False):
            continue
        version = lock.version(package.name)
        identity = _identity(lock, package.name, target)
        if not version or identity is None:
            raise InstallError(package.name, "tool source failed verification: missing pin")
        fact = facts.get(package.name)
        expected = package.store_entry(version, target)
        entry = source_store / expected
        if (not fact or fact.get("entry") != expected
                or not entry.is_dir() or not entry.resolve().is_relative_to(source_store)
                or not facts.installed(package.name, version, source_store, identity)
                or tree_digest(entry) != fact.get("digest")):
            raise InstallError(package.name, "tool source failed verification", "run preparation again")
        _copy_links(package, entry)
        binary = package.binary(entry, target)
        env = package.env(entry, target)
        if ((binary is not None and (not binary.is_file() or not binary.resolve().is_relative_to(entry)))
                or facts.env_for(package.name, source_store) != env):
            raise InstallError(package.name, "tool source failed verification: binary or environment")
        # The selection leaves PM (bundle scripts compare and execute it): ordinary spelling.
        entries[package.name] = InstalledPackage(Path(native(entry)), version,
                                                 Path(native(binary)) if binary is not None else None)
        envs.append(env)
    return VerifiedTools(MappingProxyType(entries), tuple(envs))


def stage_tools(names: Sequence[str], *, source_store: Path, out: Path, target: str) -> Path:
    """Copy a current, verified tool closure without acquisition or live selection.

    Stores must be disjoint. Both publication locks cover admission and copying;
    the ordinary installer owns verification, independent copies and fresh facts.
    """
    from contextlib import ExitStack
    from pm.install import _entry_verified, _install, _lockfile
    from pm.lock import Facts
    from pm.package import StatePackage
    from pm.registry import walk
    from pm.store import Store

    if isinstance(names, str):
        raise TypeError("names must be a sequence, not a string")
    source_store, out = Path(source_store).resolve(), Path(out).resolve()
    if source_store.is_relative_to(out) or out.is_relative_to(source_store):
        raise ValueError("tool stores must be disjoint")
    if not (source_store / "facts.json").is_file():
        raise InstallError("tools", f"source facts missing: {source_store}")
    packages = walk(list(names))
    if any(isinstance(package, StatePackage) for package in packages):
        raise ValueError("stage_tools accepts tool packages, not application state")
    source, destination = Store(source_store), Store(out)
    lock = _lockfile()
    with ExitStack() as stack:
        for store in sorted((source, destination), key=lambda store: str(store.root)):
            stack.enter_context(store.install_lock())
        selection = verified_tools(names, source_store=source_store, target=target, lock=lock)
        facts = Facts(source_store / "facts.json", strict=True)
        for package in packages:
            if package.name not in selection.entries:
                continue
            fact = facts.get(package.name)
            assert fact is not None  # Admission requires the selected fact under the same store lock.
            if not _entry_verified(package, fact, source, target):
                raise InstallError(package.name, "tool copy source failed verification", "run preparation again")
        staged = Facts(out / "facts.json", strict=True)
        for package in packages:
            started = time.monotonic()
            _install(package, lock, staged, destination, target,
                     copy_from=(facts, source), _lock_held=True, _fresh_copy=True)
            # Copy plus re-digest of a large tree runs silently for minutes on Windows.
            print(f"    copied {package.name} ({time.monotonic() - started:.1f}s)", flush=True)
    return out


def prune_cache(cache: Path, *, ci: bool = False) -> None:
    """Prune unused cache entries; CI mode also discards downloaded wheels."""
    from pm.environment import managed_environment

    cache = Path(cache).absolute()
    environment = managed_environment(cache, cache=cache, realize=False, output=sys.stderr)
    environment.prune_cache(ci=ci)
