from __future__ import annotations

import re
import shutil
import tarfile
import tempfile
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path, PurePosixPath

import requests

from skillport.shared.auth import TokenResult, is_gh_cli_available, resolve_github_token
from skillport.shared.utils import resolve_inside

GITHUB_URL_RE = re.compile(
    r"^https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)(?:/(?:tree|blob)/(?P<ref>[^/]+)(?P<path>/.*)?)?/?$"
)

MAX_FILE_BYTES = 25_000_000  # 25MB per file (large PDFs)
MAX_DOWNLOAD_BYTES = 200_000_000  # 200MB tarball download limit
MAX_EXTRACTED_BYTES = 100_000_000  # 100MB extracted skill limit (multimodal files)
EXCLUDE_NAMES = {
    ".git",
    ".env",
    "__pycache__",
    ".DS_Store",
    ".Spotlight-V100",
    ".Trashes",  # macOS
    "Thumbs.db",
    "desktop.ini",  # Windows
    "node_modules",  # JS dependencies
}


# --- Error Message Builders ---


def _build_404_error_message(auth: TokenResult) -> str:
    """Build context-aware error message for 404 responses."""
    if auth.has_token:
        # Token exists but still got 404
        source_hint = f" (from {auth.source})" if auth.source else ""
        return (
            f"Repository not found or token lacks access{source_hint}.\n"
            "Check:\n"
            "  - Is the repository URL correct?\n"
            "  - Does the token have 'repo' scope (classic) or 'Contents: Read' (fine-grained)?\n"
            "  - Are you a collaborator on this private repository?"
        )
    else:
        # No token available
        if is_gh_cli_available():
            return (
                "Repository not found or private.\n"
                "For private repos, authenticate with: gh auth login"
            )
        else:
            return (
                "Repository not found or private.\n"
                "For private repos:\n"
                "  - Install GitHub CLI and run: gh auth login\n"
                "  - Or set: export GITHUB_TOKEN=ghp_..."
            )


def _build_403_error_message(auth: TokenResult) -> str:
    """Build context-aware error message for 403 responses."""
    if auth.has_token:
        return (
            "GitHub API access denied. Your token may lack required permissions.\n"
            "Ensure the token has 'repo' scope for private repositories."
        )
    else:
        return (
            "GitHub API rate limit exceeded.\n"
            "Authenticate to increase your rate limit:\n"
            "  - gh auth login (recommended)\n"
            "  - Or set: export GITHUB_TOKEN=ghp_..."
        )


@dataclass
class ParsedGitHubURL:
    owner: str
    repo: str
    ref: str
    path: str

    @property
    def tarball_url(self) -> str:
        return f"https://api.github.com/repos/{self.owner}/{self.repo}/tarball/{self.ref}"

    @property
    def normalized_path(self) -> str:
        return self.path.lstrip("/")


@dataclass
class GitHubFetchResult:
    """Result of fetching from GitHub including extracted path and commit info."""

    extracted_path: Path
    commit_sha: str  # Short SHA (first 7 chars typically)


def get_default_branch(owner: str, repo: str, auth: TokenResult | None = None) -> str:
    """Fetch default branch from GitHub API.

    Args:
        owner: Repository owner
        repo: Repository name
        auth: Optional TokenResult. If None, resolves token automatically.

    Returns:
        Default branch name (falls back to "main" on error)
    """
    if auth is None:
        auth = resolve_github_token()

    headers = {"Accept": "application/vnd.github+json"}
    if auth.has_token:
        headers["Authorization"] = f"Bearer {auth.token}"

    url = f"https://api.github.com/repos/{owner}/{repo}"
    try:
        resp = requests.get(url, headers=headers, timeout=10)
        if resp.ok:
            return resp.json().get("default_branch", "main")
    except Exception:
        pass
    return "main"


def parse_github_url(
    url: str,
    *,
    resolve_default_branch: bool = False,
    auth: TokenResult | None = None,
) -> ParsedGitHubURL:
    match = GITHUB_URL_RE.match(url.strip())
    if not match:
        raise ValueError(
            "Unsupported GitHub URL. Use https://github.com/<owner>/<repo>[/tree|blob/<ref>/<path>]"
        )

    owner = match.group("owner")
    repo = match.group("repo")
    ref = match.group("ref")
    path = match.group("path") or ""

    if ".." in path.split("/"):
        raise ValueError("Path traversal detected in URL")

    # If no ref specified, resolve default branch from API
    if not ref:
        if resolve_default_branch:
            resolved_auth = auth if auth is not None else resolve_github_token()
            ref = get_default_branch(owner, repo, resolved_auth)
        else:
            ref = "main"

    return ParsedGitHubURL(owner=owner, repo=repo, ref=ref, path=path)


def _iter_members_for_prefix(tar: tarfile.TarFile, prefix: str) -> Iterable[tarfile.TarInfo]:
    for member in tar.getmembers():
        if not member.name.startswith(prefix):
            continue
        member.name = member.name[len(prefix) :].lstrip("/")
        yield member


def _tar_rel_posix_path(name: str) -> str:
    """Normalize a tar member name to a safe POSIX-style relative path."""
    if not name:
        raise ValueError("Invalid tar entry: empty name")

    normalized = name.replace("\\", "/")
    p = PurePosixPath(normalized)

    if p.is_absolute():
        raise ValueError(f"Path traversal detected: {name}")

    parts = [part for part in p.parts if part not in (".", "")]
    if not parts or any(part == ".." for part in parts):
        raise ValueError(f"Path traversal detected: {name}")

    if any(":" in part for part in parts):
        raise ValueError(f"Path traversal detected: {name}")

    return "/".join(parts)


def download_tarball(parsed: ParsedGitHubURL, auth: TokenResult) -> Path:
    headers = {"Accept": "application/vnd.github+json"}
    if auth.has_token:
        headers["Authorization"] = f"Bearer {auth.token}"

    resp = requests.get(parsed.tarball_url, headers=headers, stream=True, timeout=60)
    if resp.status_code == 404:
        raise ValueError(_build_404_error_message(auth))
    if resp.status_code == 403:
        raise ValueError(_build_403_error_message(auth))
    if not resp.ok:
        raise ValueError(f"Failed to fetch tarball: HTTP {resp.status_code}")

    tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
    total = 0
    try:
        for chunk in resp.iter_content(chunk_size=8192):
            if chunk:
                total += len(chunk)
                if total > MAX_DOWNLOAD_BYTES:
                    raise ValueError("Repository exceeds 200MB download limit")
                tmp.write(chunk)
        tmp.flush()
        return Path(tmp.name)
    finally:
        tmp.close()


def _extract_commit_sha_from_root(root_dir_name: str, owner: str, repo: str) -> str:
    """Extract commit SHA from tarball root directory name.

    GitHub tarball root dirs follow format: owner-repo-commitsha
    e.g., "anthropics-skill-repo-abc1234def"
    """
    prefix = f"{owner}-{repo}-"
    if root_dir_name.startswith(prefix):
        return root_dir_name[len(prefix) :]
    # Fallback: try to get the last part after splitting by dash
    parts = root_dir_name.split("-")
    if len(parts) >= 3:
        return parts[-1]
    return ""


def extract_tarball(tar_path: Path, parsed: ParsedGitHubURL) -> tuple[Path, str]:
    """Extract tarball and return (extracted_path, commit_sha).

    The commit SHA is extracted from the tarball root directory name.
    """
    dest_root = Path(tempfile.mkdtemp(prefix="skillport-gh-"))
    commit_sha = ""

    with tarfile.open(tar_path, "r:gz") as tar:
        roots = {member.name.split("/")[0] for member in tar.getmembers() if member.name}
        if not roots:
            raise ValueError("Tarball is empty")
        root = sorted(roots)[0]

        # Extract commit SHA from root directory name
        commit_sha = _extract_commit_sha_from_root(root, parsed.owner, parsed.repo)

        target_prefix = f"{root}/{parsed.normalized_path}".rstrip("/")
        if parsed.normalized_path:
            target_prefix = target_prefix + "/"
        else:
            target_prefix = f"{root}/"

        total_bytes = 0
        for member in _iter_members_for_prefix(tar, target_prefix):
            if not member.name:
                continue

            rel_posix = _tar_rel_posix_path(member.name)
            parts = PurePosixPath(rel_posix).parts
            if any(p in EXCLUDE_NAMES or p.startswith(".") for p in parts):
                continue
            if member.islnk() or member.issym():
                raise ValueError(f"Symlinks are not allowed in GitHub source: {member.name}")

            dest_path = resolve_inside(dest_root, rel_posix)

            if member.isdir():
                dest_path.mkdir(parents=True, exist_ok=True)
                continue

            if member.size > MAX_FILE_BYTES:
                raise ValueError(f"File too large (>25MB): {member.name}")

            extracted = tar.extractfile(member)
            if not extracted:
                continue
            dest_path.parent.mkdir(parents=True, exist_ok=True)

            with extracted:
                with open(dest_path, "wb") as f:
                    while True:
                        chunk = extracted.read(8192)
                        if not chunk:
                            break
                        total_bytes += len(chunk)
                        if total_bytes > MAX_EXTRACTED_BYTES:
                            raise ValueError("Extracted skill exceeds 100MB limit")
                        f.write(chunk)

    return dest_root, commit_sha


def fetch_github_source(url: str) -> Path:
    """Fetch GitHub source and return extracted path (legacy interface)."""
    result = fetch_github_source_with_info(url)
    return result.extracted_path


def fetch_github_source_with_info(url: str) -> GitHubFetchResult:
    """Fetch GitHub source and return extracted path with commit info."""
    auth = resolve_github_token()
    parsed = parse_github_url(url, resolve_default_branch=True, auth=auth)
    tar_path = download_tarball(parsed, auth)
    try:
        extracted_path, commit_sha = extract_tarball(tar_path, parsed)
        return GitHubFetchResult(
            extracted_path=extracted_path,
            commit_sha=commit_sha,
        )
    finally:
        try:
            tar_path.unlink(missing_ok=True)
        except Exception:
            pass


def get_latest_commit_sha(parsed: ParsedGitHubURL, token: str | None = None) -> str:
    """Fetch the latest commit SHA for a given ref from GitHub API.

    Args:
        parsed: ParsedGitHubURL with owner, repo, and ref
        token: Optional GitHub token for auth

    Returns:
        The full commit SHA string, or empty string on error
    """
    headers = {"Accept": "application/vnd.github+json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"

    # Use commits endpoint to resolve ref to commit
    url = f"https://api.github.com/repos/{parsed.owner}/{parsed.repo}/commits/{parsed.ref}"
    try:
        resp = requests.get(url, headers=headers, timeout=10)
        if resp.ok:
            return resp.json().get("sha", "")[:40]  # Full SHA
    except Exception:
        pass
    return ""


# --- Tree-based content hash (for update checking) ---
_tree_cache: dict[tuple[str, str, str], dict] = {}


def _fetch_tree(parsed: ParsedGitHubURL, token: str | None) -> dict:
    """Fetch repo tree (recursive) with simple in-process cache."""
    cache_key = (parsed.owner, parsed.repo, parsed.ref)
    if cache_key in _tree_cache:
        return _tree_cache[cache_key]

    headers = {"Accept": "application/vnd.github+json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"

    url = f"https://api.github.com/repos/{parsed.owner}/{parsed.repo}/git/trees/{parsed.ref}?recursive=1"
    resp = requests.get(url, headers=headers, timeout=15)
    if not resp.ok:
        raise ValueError(f"Failed to fetch tree: HTTP {resp.status_code}")
    data = resp.json()
    if data.get("truncated"):
        raise ValueError("GitHub tree response truncated")
    _tree_cache[cache_key] = data
    return data


def rename_single_skill_dir(extracted_dir: Path, skill_name: str) -> Path:
    """Rename extracted GitHub directory to match single skill name.

    When a GitHub repo contains a single skill, the extracted temp directory
    has a random name (skillport-gh-*). This renames it to match the skill name
    from SKILL.md frontmatter for consistency.

    Args:
        extracted_dir: The temporary extraction directory
        skill_name: The skill name from SKILL.md

    Returns:
        The renamed path (or original if no rename needed)
    """
    if skill_name == extracted_dir.name:
        return extracted_dir

    renamed = extracted_dir.parent / skill_name
    if renamed.exists():
        shutil.rmtree(renamed)
    extracted_dir.rename(renamed)
    return renamed


def get_remote_tree_hash(parsed: ParsedGitHubURL, token: str | None, path: str) -> str:
    """Compute remote content hash for a skill path using the GitHub tree API.

    Returns:
        hash string "sha256:..." or "" if unavailable.
    """
    try:
        tree = _fetch_tree(parsed, token)
    except Exception:
        return ""

    base_path = (path or parsed.normalized_path).rstrip("/")
    prefix = f"{base_path}/" if base_path else ""

    entries = tree.get("tree", [])
    if not isinstance(entries, list):
        return ""

    import hashlib

    # First pass: collect valid entries with their relative paths
    valid_entries: list[tuple[str, str]] = []  # (rel_path, blob_sha)
    total_files = 0
    total_bytes = 0

    for entry in entries:
        if entry.get("type") != "blob":
            continue
        entry_path = entry.get("path", "")
        if not entry_path.startswith(prefix):
            continue
        rel = entry_path[len(prefix) :] if prefix else entry_path
        if not rel:
            continue
        parts = Path(rel).parts
        if any(part.startswith(".") for part in parts):
            continue
        if any(part in ("__pycache__", ".git") for part in parts):
            continue
        size = entry.get("size", 0) or 0
        total_bytes += size
        total_files += 1
        # thresholds mirror local hash safeguards
        if total_files > 5000 or total_bytes > 100 * 1024 * 1024:
            return ""
        blob_sha = entry.get("sha", "")
        if not blob_sha:
            continue
        valid_entries.append((rel, blob_sha))

    if not valid_entries:
        return ""

    # Sort entries by relative path to match compute_content_hash_with_reason()
    valid_entries.sort(key=lambda x: x[0])

    # Second pass: compute hash in sorted order
    hasher = hashlib.sha256()
    for rel_path, blob_sha in valid_entries:
        hasher.update(rel_path.encode("utf-8"))
        hasher.update(b"\x00")
        hasher.update(blob_sha.encode("utf-8"))
        hasher.update(b"\x00")

    return f"sha256:{hasher.hexdigest()}"
