"""Template source management - handles local paths, URLs, GitHub repos, and archives."""

from __future__ import annotations

import hashlib
import logging
import shutil
import tarfile
import zipfile
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import urlopen

import pygit2

from . import constants

logger = logging.getLogger(__name__)


def _template_cache_dir() -> Path:
    """Return the cache directory for remote templates (lazy, respects ROOT override)."""
    return constants.ROOT / ".quarto-graft" / ".template-cache"


class TemplateSource:
    """Represents a source of templates (local path, URL, or archive)."""

    def __init__(self, spec: dict[str, str], source_name: str = "custom"):
        """
        Initialize a template source.

        Args:
            spec: Dictionary with 'path', 'url', or 'github' key
            source_name: Human-readable name for this source
        """
        self.spec = spec
        self.source_name = source_name
        self._resolved_path: Path | None = None

    def resolve(self) -> Path:
        """
        Resolve the template source to a local directory path.

        Returns:
            Path to directory containing templates

        Raises:
            RuntimeError: If source cannot be resolved
        """
        if self._resolved_path and self._resolved_path.exists():
            return self._resolved_path

        if "path" in self.spec:
            self._resolved_path = self._resolve_local_path(self.spec["path"])
        elif "url" in self.spec:
            # If the URL looks like a GitHub repo (non-archive), handle via git clone
            github_info = self._parse_github_url(self.spec["url"])
            if github_info:
                self._resolved_path = self._resolve_github(github_info["repo"], github_info.get("ref"))
            else:
                self._resolved_path = self._resolve_url(self.spec["url"])
        elif "github" in self.spec:
            self._resolved_path = self._resolve_github(
                self.spec["github"],
                self.spec.get("ref"),
            )
        else:
            raise RuntimeError(f"Template source must have 'path', 'url', or 'github': {self.spec}")

        if not self._resolved_path.exists():
            raise RuntimeError(f"Resolved template path does not exist: {self._resolved_path}")

        return self._resolved_path

    def _resolve_local_path(self, path_str: str) -> Path:
        """Resolve a local path (relative or absolute)."""
        path = Path(path_str)

        # If absolute, use as-is
        if path.is_absolute():
            logger.debug(f"[template-source] Using absolute path: {path}")
            return path

        # If relative, resolve relative to project root
        resolved = (constants.ROOT / path).resolve()
        logger.debug(f"[template-source] Resolved relative path '{path_str}' to: {resolved}")
        return resolved

    def _resolve_url(self, url: str) -> Path:
        """
        Resolve a URL by downloading and caching it.

        Supports:
        - Direct archive URLs (.zip, .tar.gz, .tgz)
        - GitHub archive URLs

        Security:
        - 30 second timeout
        - 100MB max download size
        - Size validation before full download
        """
        # Create cache directory
        _template_cache_dir().mkdir(parents=True, exist_ok=True)

        # Generate cache key from URL
        url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
        parsed = urlparse(url)
        filename = Path(parsed.path).name or "templates"

        # Determine cache subdirectory
        cache_subdir = _template_cache_dir() / f"{url_hash}-{filename}"

        # Check if already cached
        if cache_subdir.exists() and any(cache_subdir.iterdir()):
            logger.info(f"[template-source] Using cached templates from: {url}")
            return cache_subdir

        logger.info(f"[template-source] Downloading templates from: {url}")

        # Download the file with security constraints
        MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024  # 100MB
        DOWNLOAD_TIMEOUT = 30  # seconds

        try:
            with urlopen(url, timeout=DOWNLOAD_TIMEOUT) as response:
                # Check content length header
                content_length = response.headers.get("Content-Length")
                if content_length:
                    size = int(content_length)
                    if size > MAX_DOWNLOAD_SIZE:
                        raise RuntimeError(
                            f"Template archive too large ({size / 1024 / 1024:.1f}MB). "
                            f"Maximum allowed: {MAX_DOWNLOAD_SIZE / 1024 / 1024}MB"
                        )

                # Read with size limit enforcement
                content = response.read(MAX_DOWNLOAD_SIZE + 1)
                if len(content) > MAX_DOWNLOAD_SIZE:
                    raise RuntimeError(f"Template download exceeded size limit of {MAX_DOWNLOAD_SIZE / 1024 / 1024}MB")
        except Exception as e:
            raise RuntimeError(f"Failed to download template source from {url}: {e}") from e

        # Determine file type and extract
        if url.endswith(".zip") or "zip" in parsed.path.lower():
            self._extract_zip(content, cache_subdir)
        elif url.endswith((".tar.gz", ".tgz")) or "tar" in parsed.path.lower():
            self._extract_tar(content, cache_subdir)
        else:
            github_info = self._parse_github_url(url)
            if github_info:
                # Non-archive GitHub URL (e.g., https://github.com/user/repo or .../tree/ref)
                return self._resolve_github(github_info["repo"], github_info.get("ref"))
            raise RuntimeError(
                f"Unknown archive format for URL: {url}. "
                "Supported formats: .zip, .tar.gz, .tgz, or GitHub repository URLs"
            )

        logger.info(f"[template-source] Extracted templates to: {cache_subdir}")
        return cache_subdir

    def _extract_zip(self, content: bytes, dest: Path) -> None:
        """Extract a zip archive to destination, rejecting unsafe members."""
        import io

        dest.mkdir(parents=True, exist_ok=True)
        resolved_dest = dest.resolve()

        with zipfile.ZipFile(io.BytesIO(content)) as zf:
            # Find the root directory in the archive
            members = zf.namelist()
            if not members:
                raise RuntimeError("Empty zip archive")

            # Check if all files are in a single root directory
            root_dirs = {Path(m).parts[0] for m in members if m and not m.startswith(".")}
            strip_root = root_dirs.pop() if len(root_dirs) == 1 else ""

            for member in members:
                if not member or member.endswith("/"):
                    continue

                # Strip single root directory if applicable
                if strip_root:
                    if not member.startswith(strip_root + "/"):
                        continue
                    rel = str(Path(member).relative_to(strip_root))
                else:
                    rel = member

                # Reject absolute paths and path traversal
                if rel.startswith("/") or ".." in Path(rel).parts:
                    logger.warning(f"[template-source] Skipping unsafe zip member: {member}")
                    continue

                # Final check: resolved target must be inside dest
                target_path = (resolved_dest / rel).resolve()
                if not target_path.is_relative_to(resolved_dest):
                    logger.warning(f"[template-source] Skipping zip member escaping destination: {member}")
                    continue

                target_path.parent.mkdir(parents=True, exist_ok=True)
                with zf.open(member) as source, open(target_path, "wb") as target:
                    shutil.copyfileobj(source, target)

    def _extract_tar(self, content: bytes, dest: Path) -> None:
        """Extract a tar.gz archive to destination, rejecting unsafe members."""
        import io

        dest.mkdir(parents=True, exist_ok=True)
        resolved_dest = dest.resolve()

        with tarfile.open(fileobj=io.BytesIO(content), mode="r:*") as tf:
            members = tf.getmembers()
            if not members:
                raise RuntimeError("Empty tar archive")

            # Check if all files are in a single root directory
            root_dirs = {Path(m.name).parts[0] for m in members if m.name and not m.name.startswith(".")}
            root_dir: str = root_dirs.pop() if len(root_dirs) == 1 else ""

            safe_members: list[tarfile.TarInfo] = []
            for member in members:
                # Reject symlinks and hardlinks — they can point outside dest
                if member.issym() or member.islnk():
                    logger.warning(f"[template-source] Skipping symlink/hardlink in archive: {member.name}")
                    continue

                # Strip the single root directory if applicable
                if root_dir:
                    if not member.name.startswith(root_dir + "/"):
                        continue
                    member.name = str(Path(member.name).relative_to(root_dir))
                    if not member.name:
                        continue

                # Reject absolute paths and path traversal
                if member.name.startswith("/") or ".." in Path(member.name).parts:
                    logger.warning(f"[template-source] Skipping unsafe archive member: {member.name}")
                    continue

                # Final check: resolved target must be inside dest
                target = (resolved_dest / member.name).resolve()
                if not target.is_relative_to(resolved_dest):
                    logger.warning(f"[template-source] Skipping archive member escaping destination: {member.name}")
                    continue

                safe_members.append(member)

            tf.extractall(dest, members=safe_members, filter="data")

    def discover_templates(self, template_type: str) -> list[str]:
        """
        Discover templates of a given type from this source.

        Args:
            template_type: Either "trunk" or "graft"

        Returns:
            List of template names
        """
        try:
            resolved = self.resolve()
        except RuntimeError as e:
            logger.warning(f"[template-source] Failed to resolve source '{self.source_name}': {e}")
            return []

        # Look for templates in:
        # 1. <source>/trunk-templates/ or <source>/graft-templates/
        # 2. <source>/ (if templates are at root)
        template_dir_name = f"{template_type}-templates"
        search_paths = [
            resolved / template_dir_name,
            resolved,
        ]

        templates = []
        for search_path in search_paths:
            if not search_path.exists():
                continue

            for entry in search_path.iterdir():
                if entry.is_dir() and not entry.name.startswith((".", "with-")):
                    templates.append(entry.name)

            if templates:
                # Found templates, don't search further
                break

        return sorted(set(templates))

    def get_template_path(self, template_name: str, template_type: str) -> Path | None:
        """
        Get the path to a specific template.

        Args:
            template_name: Name of the template
            template_type: Either "trunk" or "graft"

        Returns:
            Path to template directory, or None if not found
        """
        try:
            resolved = self.resolve()
        except RuntimeError:
            return None

        template_dir_name = f"{template_type}-templates"
        search_paths = [
            resolved / template_dir_name / template_name,
            resolved / template_name,
        ]

        for path in search_paths:
            if path.exists() and path.is_dir():
                return path

        return None

    # ------------------------------------------------------------------
    # GitHub handling
    # ------------------------------------------------------------------
    def _parse_github_url(self, url: str) -> dict[str, str] | None:
        """
        Parse a GitHub URL to extract repo and ref.

        Supports URLs like:
          - https://github.com/user/repo
          - https://github.com/user/repo.git
          - https://github.com/user/repo/tree/main
          - https://github.com/user/repo/tree/v1.0.0
        """
        parsed = urlparse(url)
        if parsed.netloc not in {"github.com", "www.github.com"}:
            return None

        parts = [p for p in Path(parsed.path).parts if p and p != "/"]
        if len(parts) < 2:
            return None

        user, repo = parts[0], parts[1]
        repo = repo[:-4] if repo.endswith(".git") else repo

        ref = None
        if len(parts) >= 4 and parts[2] == "tree":
            ref = parts[3]

        return {"repo": f"{user}/{repo}", "ref": ref}  # type: ignore[dict-item]

    def _resolve_github(self, repo: str, ref: str | None = None) -> Path:
        """
        Resolve a GitHub repository by cloning it (optionally at a ref/tag/branch).

        Cloned repos are cached under .quarto-graft/.template-cache/.
        """
        _template_cache_dir().mkdir(parents=True, exist_ok=True)

        ref_display = ref or "default"
        cache_key = hashlib.sha256(f"{repo}@{ref_display}".encode()).hexdigest()[:16]
        cache_dir = _template_cache_dir() / f"github-{cache_key}-{repo.replace('/', '-')}"

        if cache_dir.exists() and any(cache_dir.iterdir()):
            logger.info(f"[template-source] Using cached GitHub repo {repo}@{ref_display}")
            return cache_dir

        logger.info(f"[template-source] Cloning GitHub repo {repo}{' @ ' + ref if ref else ''}")
        cache_dir.mkdir(parents=True, exist_ok=True)

        clone_url = f"https://github.com/{repo}.git"
        try:
            repo_obj = pygit2.clone_repository(
                clone_url,
                cache_dir,
                checkout_branch=ref if ref else None,
            )
            if ref:
                # If ref is not a branch, attempt to check out by rev
                try:
                    target = repo_obj.revparse_single(ref)
                    repo_obj.checkout_tree(target)
                    repo_obj.set_head(target.id)
                    repo_obj.state_cleanup()
                except KeyError:
                    # Try common tag ref
                    try:
                        target = repo_obj.revparse_single(f"refs/tags/{ref}")
                        repo_obj.checkout_tree(target)
                        repo_obj.set_head(target.id)
                        repo_obj.state_cleanup()
                    except (KeyError, pygit2.GitError) as e:
                        raise RuntimeError(f"Ref '{ref}' not found in {clone_url}") from e
        except (pygit2.GitError, RuntimeError) as e:
            # Clean up failed clone
            if cache_dir.exists():
                shutil.rmtree(cache_dir, ignore_errors=True)
            raise RuntimeError(f"Failed to clone {clone_url} (ref: {ref or 'default'}): {e}") from e

        return cache_dir


def load_template_sources_from_config() -> list[TemplateSource]:
    """
    Load template sources from grafts.yaml.

    Returns:
        List of TemplateSource objects
    """
    from .constants import GRAFTS_CONFIG_FILE
    from .yaml_utils import get_yaml_loader

    if not GRAFTS_CONFIG_FILE.exists():
        return []

    yaml_loader = get_yaml_loader()
    data = yaml_loader.load(GRAFTS_CONFIG_FILE.read_text(encoding="utf-8")) or {}

    templates_config = data.get("templates")
    if not templates_config:
        return []

    if not isinstance(templates_config, list):
        logger.warning(
            f"[template-source] 'templates' in {GRAFTS_CONFIG_FILE} should be a list, got {type(templates_config)}"
        )
        return []

    sources = []
    for idx, spec in enumerate(templates_config):
        if not isinstance(spec, dict):
            logger.warning(f"[template-source] Template source {idx} is not a dict: {spec}")
            continue

        source_name = f"custom-{idx + 1}"
        if "path" in spec:
            source_name = f"local:{spec['path']}"
        elif "url" in spec:
            source_name = f"url:{spec['url']}"
        elif "github" in spec:
            source_name = f"github:{spec['github']}"
            if spec.get("ref"):
                source_name += f"@{spec['ref']}"

        try:
            sources.append(TemplateSource(spec, source_name))
        except Exception as e:
            logger.warning(f"[template-source] Failed to create source from {spec}: {e}")

    return sources
