"""Helpers to copy or clone external packages into a Peagen workspace."""

from __future__ import annotations

import os
import shutil
import stat
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
import hashlib
import json

import typer


def _copy_tree(src: Path, dst: Path) -> None:
    """
    Copy *src* into *dst* but DO NOT overwrite existing files.
    Directories are merged; generated files already present win.
    """
    for path in src.rglob("*"):
        rel = path.relative_to(src)
        target = dst / rel
        if path.is_dir():
            target.mkdir(parents=True, exist_ok=True)
        else:
            target.parent.mkdir(parents=True, exist_ok=True)
            if not target.exists():  # keep generated file
                shutil.copy2(path, target)


# ────────────────────────────────────────────────────────────────────────────
# git helpers
# ────────────────────────────────────────────────────────────────────────────
def _git_clone_to(dest: Path, uri: str, ref: Optional[str] = None) -> None:
    """Shallow-clone *uri* into *dest* at *ref* (tag/branch/SHA)."""
    cmd = ["git", "clone", "--depth", "1"]
    if ref:
        cmd += ["--branch", ref]
    cmd += [uri, str(dest)]

    env = os.environ.copy()
    env["GIT_TERMINAL_PROMPT"] = "0"  # never wait for creds

    try:
        subprocess.check_call(cmd, env=env)
    except subprocess.CalledProcessError as exc:
        typer.echo(f"[ERROR] git clone failed: {exc}")
        raise typer.Exit(1)


def _strip_git_dir(pkg_dir: Path) -> None:
    """
    Delete the .git directory inside *pkg_dir*, clearing read-only bits so that
    Windows can remove pack/idx files without WinError 5.
    """
    git_dir = pkg_dir / ".git"
    if not git_dir.exists():
        return

    def _on_rm_error(fn, path, exc_info):
        # clear read-only attr and retry
        os.chmod(path, stat.S_IWRITE)
        fn(path)

    # Some AV tools hold locks briefly; retry a couple of times.
    for attempt in range(3):
        try:
            shutil.rmtree(git_dir, onerror=_on_rm_error)
            return
        except PermissionError:
            time.sleep(0.5)

    typer.echo(
        f"WARNING: could not fully delete {git_dir}; workspace cleanup may be slow"
    )


def _dir_checksum(root: Path) -> str:
    """Return a sha256 checksum of all files under *root*."""
    h = hashlib.sha256()
    for path in sorted(root.rglob("*")):
        if path.is_file():
            h.update(path.relative_to(root).as_posix().encode())
            with path.open("rb") as fh:
                for chunk in iter(lambda: fh.read(8192), b""):
                    h.update(chunk)
    return h.hexdigest()


# ────────────────────────────────────────────────────────────────────────────
# materialise a single package
# ────────────────────────────────────────────────────────────────────────────
def _materialise_source_pkg(
    pkg_spec: Dict[str, Any],
    workspace: Path,
    upload: bool = False,
    storage_adapter: Optional[Any] = None,
) -> Path:
    """
    Clone/copy *pkg_spec* into the workspace WITHOUT clobbering files that
    are already there (e.g. generated by `peagen process`).
    """
    dest = workspace / pkg_spec["dest"]
    dest.mkdir(parents=True, exist_ok=True)  # ensure folder

    # 1. populate temp dir --------------------------------------------------
    tmp = Path(tempfile.mkdtemp(prefix="srcpkg_"))
    try:
        typ = pkg_spec.get("type")
        if typ == "git":
            _git_clone_to(tmp, pkg_spec["uri"], pkg_spec.get("ref"))
            checksum = (
                subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=tmp)
                .decode()
                .strip()
            )
        elif typ == "local":
            src_path = Path(pkg_spec["path"]).expanduser()
            shutil.copytree(src_path, tmp, dirs_exist_ok=True)
            checksum = _dir_checksum(src_path)
        else:
            raise ValueError(f"Unknown source package type: {typ!r}")

        pkg_spec["checksum"] = checksum

        _strip_git_dir(tmp)  # remove .git for Win cleanup

        # 2. merge into workspace ------------------------------------------
        _copy_tree(tmp, dest)

    finally:
        shutil.rmtree(tmp, ignore_errors=True)

    # optional remote upload (unchanged)
    if upload and storage_adapter:
        _sync_dir(dest, storage_adapter, prefix=str(dest.relative_to(workspace)))

    return dest


def _sync_dir(root: Path, adapter: Any, *, prefix: str = "") -> None:
    """
    Portable fallback for adapters that only implement `upload()`.
    Uses adapter.upload_dir() if available, else walks the tree.
    """
    if hasattr(adapter, "upload_dir"):
        adapter.upload_dir(root, prefix=prefix)
        return

    for file_path in root.rglob("*"):
        if file_path.is_file():
            key = f"{prefix}/{file_path.relative_to(root)}"
            with file_path.open("rb") as fh:
                adapter.upload(str(key), fh)


# ────────────────────────────────────────────────────────────────────────────
# convenience: materialise many packages
# ────────────────────────────────────────────────────────────────────────────
def materialise_packages(
    packages: List[Dict[str, Any]],
    workspace: Path,
    storage_adapter: Optional[Any] = None,
    *,
    upload: bool = False,
) -> List[Path]:
    """Clone / copy a list of source packages into *workspace*.

    A ``source_packages.lock`` file is written containing the resolved
    commit SHA or checksum for each package. On subsequent runs the
    recorded values are loaded and mismatches abort execution.
    """

    lock_path = workspace / "source_packages.lock"
    previous: Dict[str, str] = {}
    if lock_path.exists():
        try:
            previous = json.loads(lock_path.read_text(encoding="utf-8"))
        except Exception:  # pragma: no cover - corrupted lock file
            previous = {}

    dests: List[Path] = []
    current: Dict[str, str] = {}
    for spec in packages:
        dests.append(
            _materialise_source_pkg(
                spec,
                workspace,
                upload=upload,
                storage_adapter=storage_adapter,
            )
        )
        if "checksum" in spec:
            current[spec["dest"]] = spec["checksum"]
            prev = previous.get(spec["dest"])
            if prev and prev != spec["checksum"]:
                typer.echo(
                    f"[ERROR] Source package {spec['dest']} changed "
                    f"(was {prev}, now {spec['checksum']})"
                )
                raise typer.Exit(1)

    lock_path.write_text(json.dumps(current, indent=2), encoding="utf-8")

    return dests
