"""Import skills from local archives, URLs, or GitHub Gists."""

from __future__ import annotations

import json
import os
import shutil
import subprocess
import tarfile
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


@dataclass
class SkillPreview:
    """Preview of a skill before installation."""

    name: str
    description: str
    files: dict[str, str]  # filename -> content
    dependencies: list[str] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)


@dataclass
class ImportResult:
    """Result of a skill import."""

    skill_name: str
    installed: bool
    tests_passed: bool | None = None
    test_output: str = ""
    error: str = ""


def preview_skill(source: str, *, skills_dir: Path | None = None) -> SkillPreview:
    """Download/extract a skill and return a preview without installing.

    Args:
        source: Local path, URL, or GitHub gist URL.
        skills_dir: Override skills directory for checking conflicts.

    Returns:
        SkillPreview with name, description, files, and dependencies.

    Raises:
        FileNotFoundError: If source doesn't exist or can't be fetched.
        ValueError: If the archive is invalid or missing required files.
    """
    archive_path = _resolve_source(source)

    try:
        return _extract_preview(archive_path)
    finally:
        # Clean up temp files from URL downloads
        if archive_path != Path(source) and archive_path.exists():
            archive_path.unlink(missing_ok=True)


def import_skill(
    source: str,
    *,
    skills_dir: Path | None = None,
    run_tests: bool = True,
) -> ImportResult:
    """Import a skill from a local archive, URL, or GitHub gist.

    Args:
        source: Local path, URL, or GitHub gist URL.
        skills_dir: Override skills directory for testing.
        run_tests: Whether to run the skill's tests after installation.

    Returns:
        ImportResult with installation status and test results.

    Raises:
        FileNotFoundError: If source doesn't exist or can't be fetched.
        ValueError: If the archive is invalid.
    """
    from alive.config import SKILLS_DIR

    skills_dir = skills_dir or SKILLS_DIR
    archive_path = _resolve_source(source)

    try:
        preview = _extract_preview(archive_path)

        # Check for conflicts
        target_dir = skills_dir / preview.name
        if target_dir.exists():
            raise ValueError(
                f"Skill '{preview.name}' already exists. "
                f"Remove it first with: rm -rf {target_dir}"
            )

        # Extract to skills directory
        with tarfile.open(archive_path, "r:gz") as tar:
            _safe_extract(tar, skills_dir)

        # Run tests if requested
        tests_passed = None
        test_output = ""
        if run_tests:
            tests_passed, test_output = _run_skill_tests(
                skills_dir / preview.name
            )

        return ImportResult(
            skill_name=preview.name,
            installed=True,
            tests_passed=tests_passed,
            test_output=test_output,
        )

    finally:
        if archive_path != Path(source) and archive_path.exists():
            archive_path.unlink(missing_ok=True)


def _resolve_source(source: str) -> Path:
    """Resolve a source string to a local archive path.

    Handles:
    - Local file paths
    - HTTP/HTTPS URLs (direct download)
    - GitHub gist URLs (fetch files, create archive)
    """
    # Local path
    local = Path(source)
    if local.exists():
        return local

    # URL
    if source.startswith(("http://", "https://")):
        if "gist.github.com" in source:
            return _download_gist(source)
        return _download_url(source)

    raise FileNotFoundError(f"Source not found: {source}")


def _download_url(url: str) -> Path:
    """Download a .tar.gz from a URL to a temp file."""
    import urllib.request
    import urllib.error

    try:
        tmp = tempfile.NamedTemporaryFile(
            suffix=".alive-skill.tar.gz", delete=False
        )
        urllib.request.urlretrieve(url, tmp.name)
        return Path(tmp.name)
    except urllib.error.URLError as e:
        raise FileNotFoundError(f"Failed to download from {url}: {e}") from e


def _download_gist(gist_url: str) -> Path:
    """Fetch files from a GitHub gist and pack into a .tar.gz."""
    import urllib.request
    import urllib.error

    # Extract gist ID from URL
    parts = gist_url.rstrip("/").split("/")
    gist_id = parts[-1]

    api_url = f"https://api.github.com/gists/{gist_id}"
    headers = {"Accept": "application/vnd.github+json"}

    token = os.environ.get("GITHUB_TOKEN")
    if token:
        headers["Authorization"] = f"Bearer {token}"

    req = urllib.request.Request(api_url, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read())
    except urllib.error.URLError as e:
        raise FileNotFoundError(f"Failed to fetch gist {gist_id}: {e}") from e

    files = data.get("files", {})
    if not files:
        raise ValueError(f"Gist {gist_id} has no files")

    # Determine skill name from metadata.json or first file's directory
    meta_content = None
    if "metadata.json" in files:
        meta_content = files["metadata.json"].get("content", "")

    skill_name = "unknown"
    if meta_content:
        try:
            skill_name = json.loads(meta_content).get("name", "unknown")
        except json.JSONDecodeError:
            pass

    if skill_name == "unknown":
        # Try to infer from gist description
        desc = data.get("description", "")
        if desc.startswith("Alive skill: "):
            skill_name = desc.split("Alive skill: ")[1].split(" ")[0].split("—")[0].strip()

    # Create temp archive
    tmpdir = tempfile.mkdtemp(prefix="alive_gist_")
    skill_dir = Path(tmpdir) / skill_name
    skill_dir.mkdir()

    for filename, file_info in files.items():
        content = file_info.get("content", "")
        (skill_dir / filename).write_text(content)

    archive_path = Path(tmpdir) / f"{skill_name}.alive-skill.tar.gz"
    with tarfile.open(archive_path, "w:gz") as tar:
        tar.add(str(skill_dir), arcname=skill_name)

    # Clean up extracted dir, keep archive
    shutil.rmtree(str(skill_dir), ignore_errors=True)
    return archive_path


def _extract_preview(archive_path: Path) -> SkillPreview:
    """Extract and validate a skill archive, returning a preview."""
    if not archive_path.exists():
        raise FileNotFoundError(f"Archive not found: {archive_path}")

    try:
        with tarfile.open(archive_path, "r:gz") as tar:
            members = tar.getnames()
            if not members:
                raise ValueError("Archive is empty")

            # Skill name is the top-level directory
            skill_name = members[0].split("/")[0]

            # Validate required files
            member_basenames = {m.split("/", 1)[1] if "/" in m else m for m in members}
            if "skill.py" not in member_basenames:
                raise ValueError(
                    f"Invalid skill archive: missing skill.py"
                )
            if "metadata.json" not in member_basenames:
                raise ValueError(
                    f"Invalid skill archive: missing metadata.json"
                )

            # Read files for preview
            files: dict[str, str] = {}
            metadata: dict[str, Any] = {}
            for member in tar.getmembers():
                if member.isfile():
                    basename = member.name.split("/", 1)[1] if "/" in member.name else member.name
                    f = tar.extractfile(member)
                    if f:
                        try:
                            content = f.read().decode("utf-8")
                            files[basename] = content
                            if basename == "metadata.json":
                                metadata = json.loads(content)
                        except (UnicodeDecodeError, json.JSONDecodeError):
                            files[basename] = "<binary file>"

            return SkillPreview(
                name=skill_name,
                description=metadata.get("description", ""),
                files=files,
                dependencies=metadata.get("dependencies", []),
                metadata=metadata,
            )

    except tarfile.TarError as e:
        raise ValueError(f"Invalid archive: {e}") from e


def _safe_extract(tar: tarfile.TarFile, dest: Path) -> None:
    """Extract tar safely — reject paths that escape the destination."""
    for member in tar.getmembers():
        member_path = (dest / member.name).resolve()
        if not str(member_path).startswith(str(dest.resolve())):
            raise ValueError(
                f"Unsafe path in archive: {member.name} (path traversal)"
            )
    tar.extractall(path=str(dest), filter="data")


def _run_skill_tests(skill_dir: Path) -> tuple[bool, str]:
    """Run a skill's test file and return (passed, output)."""
    test_file = skill_dir / "test_skill.py"
    if not test_file.exists():
        return True, "No tests found."

    try:
        from alive.sandbox.dependency import get_dependency_manager

        dm = get_dependency_manager()
        dm.ensure_venv()
        python = str(dm.python)
    except (ImportError, RuntimeError, OSError):
        python = "python3"

    try:
        proc = subprocess.run(
            [python, "-m", "pytest", str(test_file), "-v", "--tb=short"],
            capture_output=True,
            text=True,
            timeout=60,
            cwd=str(skill_dir),
        )
        output = proc.stdout + proc.stderr
        return proc.returncode == 0, output.strip()
    except subprocess.TimeoutExpired:
        return False, "Tests timed out after 60 seconds."
    except FileNotFoundError:
        # pytest not available, try direct execution
        try:
            proc = subprocess.run(
                [python, str(test_file)],
                capture_output=True,
                text=True,
                timeout=60,
                cwd=str(skill_dir),
            )
            output = proc.stdout + proc.stderr
            return proc.returncode == 0, output.strip()
        except (subprocess.SubprocessError, OSError) as e:
            return False, f"Could not run tests: {e}"
