"""Remote source resolution and caching for cross-project references.

Supports fetching .agent/ directories (or subdirectories) from GitHub
repositories via the GitHub API, with local caching for offline access.

Source format:
    git:owner/repo              -> default branch, root .agent/
    git:owner/repo@ref          -> specific branch/tag/commit, root .agent/
    git:owner/repo@ref:path     -> specific branch/tag/commit, custom path

Cache layout:
    <cache_dir>/relations/<owner>-<repo>-<ref>-<path_hash>/
"""

import hashlib
import json
import time
import zipfile
from io import BytesIO
from pathlib import Path
from typing import NamedTuple

from cokodo_agent.config import DEFAULT_CACHE_DIR

CACHE_BASE = DEFAULT_CACHE_DIR / "relations"
CACHE_MAX_AGE_SECONDS = 86400  # 24 hours


class RemoteSource(NamedTuple):
    """Parsed remote source identifier."""

    owner: str
    repo: str
    ref: str  # branch, tag, or commit; empty string = default branch
    path: str  # subpath within the repo; empty string = ".agent"


def is_remote_source(source: str) -> bool:
    """Check if a source string is a remote reference (git:... format)."""
    return source.startswith("git:")


def parse_remote_source(source: str) -> RemoteSource:
    """Parse a remote source string into its components.

    Formats:
        git:owner/repo
        git:owner/repo@ref
        git:owner/repo@ref:path

    Raises ValueError if the format is invalid.
    """
    if not source.startswith("git:"):
        raise ValueError(f"Not a remote source: {source}")

    rest = source[4:]  # strip "git:"

    # Split path part (after :, but only the SECOND colon since git: uses the first)
    path = ""
    if ":" in rest:
        rest, path = rest.split(":", 1)

    # Split ref part
    ref = ""
    if "@" in rest:
        rest, ref = rest.split("@", 1)

    # Parse owner/repo
    parts = rest.split("/")
    if len(parts) != 2 or not parts[0] or not parts[1]:
        raise ValueError(
            f"Invalid remote source '{source}': expected git:owner/repo[@ref[:path]]"
        )

    return RemoteSource(
        owner=parts[0],
        repo=parts[1],
        ref=ref,
        path=path or ".agent",
    )


def _cache_key(source: RemoteSource) -> str:
    """Generate a filesystem-safe cache directory name."""
    path_hash = hashlib.sha256(source.path.encode()).hexdigest()[:8]
    ref = source.ref or "HEAD"
    return f"{source.owner}-{source.repo}-{ref}-{path_hash}"


def _cache_dir(source: RemoteSource) -> Path:
    """Get the cache directory path for a remote source."""
    return CACHE_BASE / _cache_key(source)


def _cache_meta_path(source: RemoteSource) -> Path:
    """Get the cache metadata file path."""
    return _cache_dir(source) / ".cache_meta.json"


def _read_cache_meta(source: RemoteSource) -> dict | None:
    """Read cache metadata, or None if not cached."""
    meta_path = _cache_meta_path(source)
    if not meta_path.exists():
        return None
    try:
        return json.loads(meta_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None


def _write_cache_meta(source: RemoteSource, **kwargs: object) -> None:
    """Write cache metadata."""
    meta_path = _cache_meta_path(source)
    meta = {
        "source": f"git:{source.owner}/{source.repo}@{source.ref or 'HEAD'}:{source.path}",
        "fetched_at": time.time(),
        "fetched_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        **kwargs,
    }
    meta_path.write_text(
        json.dumps(meta, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


def is_cache_fresh(source: RemoteSource, max_age: int = CACHE_MAX_AGE_SECONDS) -> bool:
    """Check if the cache for a remote source is fresh (within max_age seconds)."""
    meta = _read_cache_meta(source)
    if meta is None:
        return False
    fetched_at = meta.get("fetched_at", 0)
    return (time.time() - fetched_at) < max_age


def get_cached_path(source: RemoteSource) -> Path | None:
    """Get the cached content path if it exists, or None."""
    cache = _cache_dir(source)
    content_dir = cache / "content"
    if content_dir.exists() and any(content_dir.iterdir()):
        return content_dir
    return None


def fetch_remote_source(
    source: RemoteSource,
    force: bool = False,
    timeout: float = 30.0,
) -> Path:
    """Fetch a remote source from GitHub and cache it locally.

    Uses the GitHub zipball API to download repository contents,
    then extracts only the requested path.

    Args:
        source: Parsed remote source.
        force: Force re-fetch even if cache is fresh.
        timeout: HTTP request timeout in seconds.

    Returns:
        Path to the cached content directory.

    Raises:
        ConnectionError: If the fetch fails (network, 404, etc).
    """
    if not force and is_cache_fresh(source):
        cached = get_cached_path(source)
        if cached is not None:
            return cached

    try:
        import httpx
    except ImportError:
        cached = get_cached_path(source)
        if cached is not None:
            return cached
        raise ConnectionError(
            "httpx not installed. Install with: pip install cokodo-agent[network]\n"
            "Or use cached content if available."
        )

    ref = source.ref or ""
    if ref:
        archive_url = f"https://api.github.com/repos/{source.owner}/{source.repo}/zipball/{ref}"
    else:
        archive_url = f"https://api.github.com/repos/{source.owner}/{source.repo}/zipball"

    try:
        with httpx.Client(timeout=timeout, follow_redirects=True) as client:
            resp = client.get(archive_url)
            resp.raise_for_status()
    except Exception as e:
        cached = get_cached_path(source)
        if cached is not None:
            return cached
        raise ConnectionError(
            f"Failed to fetch {source.owner}/{source.repo}: {e}"
        ) from e

    cache = _cache_dir(source)
    content_dir = cache / "content"

    # Clean previous content
    if content_dir.exists():
        import shutil
        shutil.rmtree(content_dir)
    content_dir.mkdir(parents=True, exist_ok=True)

    _extract_path_from_zipball(resp.content, source.path, content_dir)

    actual_ref = ref or "HEAD"
    _write_cache_meta(source, ref_resolved=actual_ref)

    return content_dir


def _extract_path_from_zipball(
    zip_bytes: bytes,
    target_path: str,
    output_dir: Path,
) -> None:
    """Extract files under target_path from a GitHub zipball.

    GitHub zipballs have a root folder like "owner-repo-sha/".
    We strip that prefix and the target_path prefix, then write
    the remaining files to output_dir.
    """
    with zipfile.ZipFile(BytesIO(zip_bytes)) as zf:
        members = zf.namelist()
        if not members:
            return

        # GitHub zipball root folder
        root_folder = members[0].split("/")[0]

        # The path inside the zip we want
        prefix = f"{root_folder}/{target_path}"
        if not prefix.endswith("/"):
            prefix += "/"

        extracted = 0
        for member in members:
            if not member.startswith(prefix):
                continue
            relative = member[len(prefix):]
            if not relative:
                continue

            target_file = output_dir / relative

            if member.endswith("/"):
                target_file.mkdir(parents=True, exist_ok=True)
            else:
                target_file.parent.mkdir(parents=True, exist_ok=True)
                with zf.open(member) as src:
                    target_file.write_bytes(src.read())
                extracted += 1

        if extracted == 0:
            raise FileNotFoundError(
                f"Path '{target_path}' not found in repository"
            )


def clear_cache(source: RemoteSource | None = None) -> int:
    """Clear cached remote sources.

    Args:
        source: If provided, clear only this source's cache.
                If None, clear all remote caches.

    Returns:
        Number of cache entries removed.
    """
    import shutil

    if source is not None:
        cache = _cache_dir(source)
        if cache.exists():
            shutil.rmtree(cache)
            return 1
        return 0

    if not CACHE_BASE.exists():
        return 0

    count = 0
    for entry in CACHE_BASE.iterdir():
        if entry.is_dir():
            shutil.rmtree(entry)
            count += 1
    return count


def list_cached_sources() -> list[dict]:
    """List all cached remote sources with their metadata.

    Returns a list of dicts with keys: source, fetched_iso, fresh, path.
    """
    if not CACHE_BASE.exists():
        return []

    results: list[dict] = []
    for entry in sorted(CACHE_BASE.iterdir()):
        if not entry.is_dir():
            continue
        meta_path = entry / ".cache_meta.json"
        if not meta_path.exists():
            continue
        try:
            meta = json.loads(meta_path.read_text(encoding="utf-8"))
            fetched_at = meta.get("fetched_at", 0)
            results.append({
                "source": meta.get("source", "unknown"),
                "fetched_iso": meta.get("fetched_iso", ""),
                "fresh": (time.time() - fetched_at) < CACHE_MAX_AGE_SECONDS,
                "path": str(entry / "content"),
            })
        except (OSError, json.JSONDecodeError):
            continue

    return results
