"""
CVE data download and extraction module.

Provides functions to download CVE data from GitHub CVEProject/cvelistV5 repository
using commit hashes and extract to target directory. Uses direct GitHub archive URLs
with streaming download for reliable, complete transfers.
"""

import logging
import shutil
import tarfile
import tempfile
import time
import zipfile
from pathlib import Path

import httpx

logger = logging.getLogger(__name__)

# Cache TTL in seconds (7 days)
CACHE_TTL_SECONDS = 7 * 24 * 60 * 60

# CVE List V5 GitHub repository
CVELISTV5_REPO_OWNER = "CVEProject"
CVELISTV5_REPO_NAME = "cvelistV5"

# Default baseline commit (2026-02-10_baseline)
DEFAULT_COMMIT_HASH = "a218727"


def download_and_extract_cve_data(
    commit_hash: str, cache_dir: Path, target_dir: Path
) -> Path:
    """
    Download CVE data from GitHub repository and extract to target directory.

    Downloads archive from CVEProject/cvelistV5 at specified commit hash with
    streaming and robust timeout handling. Validates cache TTL (7 days), then
    extracts to cache_dir/extracted_{commit_hash}. If already extracted and valid,
    reuses cached extraction. Finally copies/links to target_dir.

    Args:
        commit_hash: Git commit hash (e.g., 'a218727' for 2026-02-10_baseline)
        cache_dir: Directory for caching downloaded archive and extracted data
        target_dir: Directory to extract CVE JSON files (can be same as cache_dir)

    Returns:
        Path to target_dir containing extracted CVE JSON files

    Raises:
        OSError: File system operation error
        RuntimeError: Download or extraction error
        ValueError: Invalid parameters (empty commit_hash, non-writable directories)
    """
    if not commit_hash or not commit_hash.strip():
        raise ValueError("commit_hash cannot be empty")

    if not cache_dir:
        raise ValueError("cache_dir cannot be None")

    if not target_dir:
        raise ValueError("target_dir cannot be None")

    # Create directories
    cache_dir.mkdir(parents=True, exist_ok=True)
    target_dir.mkdir(parents=True, exist_ok=True)

    # Directory for extracted CVE data in cache
    extracted_dir = cache_dir / f"extracted_{commit_hash}"

    # Check if already extracted and valid
    if extracted_dir.exists() and (extracted_dir / "cves").exists():
        logger.info("Using cached extracted CVE data: %s", extracted_dir)
        _copy_extracted_data(extracted_dir, target_dir)
        logger.info("CVE data copied from cache to: %s", target_dir)
        return target_dir

    # Download archive with cache validation
    archive_path = _download_with_cache(commit_hash, cache_dir)

    # Extract to cache directory
    _extract_nested_zips(archive_path, extracted_dir)

    # Copy extracted data to target directory
    _copy_extracted_data(extracted_dir, target_dir)

    logger.info("CVE data extracted and copied to: %s", target_dir)
    return target_dir


def _download_with_cache(commit_hash: str, cache_dir: Path) -> Path:
    """
    Download tarball archive from GitHub with 7-day cache TTL.

    Downloads archive directly from GitHub with streaming and robust timeout
    handling. Maintains cache for 7 days to avoid redundant downloads.

    Args:
        commit_hash: Git commit hash (short or full)
        cache_dir: Cache directory

    Returns:
        Path to cached tar.gz file

    Raises:
        RuntimeError: Download validation error or download failed
        OSError: File write error
    """
    # Check for existing cached file matching the commit hash pattern
    cached_file = _find_cached_file_by_commit(commit_hash, cache_dir)

    if cached_file and cached_file.exists():
        age_seconds = time.time() - cached_file.stat().st_mtime
        if age_seconds < CACHE_TTL_SECONDS:
            logger.info(
                "Using cached archive: %s (age: %.1f days)",
                cached_file,
                age_seconds / 86400,
            )
            return cached_file
        logger.info("Cache expired, re-downloading: %s", cached_file)
        cached_file.unlink()

    # Download via GitHub API
    logger.info(
        "Downloading %s/%s at commit %s via GitHub API",
        CVELISTV5_REPO_OWNER,
        CVELISTV5_REPO_NAME,
        commit_hash,
    )
    tmp_path = None
    try:
        with tempfile.NamedTemporaryFile(delete=False, dir=cache_dir, suffix=".tmp") as tmp:
            tmp_path = Path(tmp.name)

        _download_github_tarball(
            CVELISTV5_REPO_OWNER, CVELISTV5_REPO_NAME, commit_hash, tmp_path
        )

        # Verify the downloaded file is a valid tar.gz archive and can be read
        if not tarfile.is_tarfile(tmp_path):
            logger.error("Downloaded file is not a valid tarball: %s", tmp_path)
            logger.error("File size: %d bytes", tmp_path.stat().st_size)
            raise RuntimeError(f"Downloaded file is not a valid tarball: {tmp_path}")

        # Verify tarball is readable (but don't fully validate members to avoid EOF errors)
        # Some GitHub CDN responses have truncated gzip streams that pass is_tarfile() 
        # but fail during full extraction - we'll let extraction handle the error
        try:
            with tarfile.open(tmp_path, "r:*") as tf:
                # Just verify we can open it without errors
                pass
            logger.debug("Tarball opened successfully")
        except (tarfile.TarError, EOFError) as e:
            logger.error("Tarball cannot be opened: %s", e)
            raise RuntimeError(f"Downloaded tarball cannot be opened: {e}") from e

        # Move to cache location
        cache_file = _move_to_cache(tmp_path, commit_hash, cache_dir)
        logger.info("Downloaded to cache: %s", cache_file)

    except RuntimeError:
        # Re-raise download and validation errors
        if tmp_path and tmp_path.exists():
            tmp_path.unlink()
        raise
    except OSError as e:
        if tmp_path and tmp_path.exists():
            tmp_path.unlink()
        raise OSError(f"Failed to write cache file: {e}") from e

    return cache_file


def _download_github_tarball(
    owner: str, repo: str, commit_hash: str, dest_path: Path
) -> None:
    """
    Download repository tarball from GitHub using direct archive URL with streaming.

    Downloads tarball directly from GitHub's archive endpoint with robust streaming,
    timeout handling, and completion verification to ensure complete transfers.

    Args:
        owner: Repository owner
        repo: Repository name
        commit_hash: Commit hash (short or full)
        dest_path: Destination tar.gz file path

    Raises:
        RuntimeError: Download failed or file validation failed
    """
    max_retries = 3
    last_error = None

    for attempt in range(max_retries):
        try:
            # GitHub archive endpoint for tarball (works with commit SHA, tag, or branch)
            # Format: /archive/{ref}.tar.gz where ref can be commit hash
            archive_url = f"https://github.com/{owner}/{repo}/archive/{commit_hash}.tar.gz"
            
            logger.debug(
                "Downloading tarball from %s/%s at commit %s (attempt %d/%d)",
                owner,
                repo,
                commit_hash,
                attempt + 1,
                max_retries,
            )

            # Use httpx with long timeout for large downloads
            # httpx is already a dependency via GitHubKit
            with httpx.stream(
                "GET",
                archive_url,
                follow_redirects=True,
                timeout=600.0,  # 10 minutes timeout for large file
            ) as response:
                # Check for HTTP errors
                response.raise_for_status()

                # Track download progress
                downloaded_size = 0
                with open(dest_path, "wb") as f:
                    for chunk in response.iter_bytes(chunk_size=65536):  # 64KB chunks
                        if chunk:
                            f.write(chunk)
                            downloaded_size += len(chunk)

                logger.info("Download complete: %d bytes", downloaded_size)

                # Verify file is valid tarball
                if not tarfile.is_tarfile(dest_path):
                    raise RuntimeError(f"Downloaded file is not a valid tarball: {dest_path}")

                logger.info("Tarball verified successfully")
                return  # Success

        except (httpx.HTTPError, OSError, RuntimeError) as e:
            last_error = e
            if attempt < max_retries - 1:
                logger.warning(
                    "Download attempt %d failed: %s. Retrying...",
                    attempt + 1,
                    str(e),
                )
                if dest_path.exists():
                    dest_path.unlink()
            else:
                logger.error("Download failed after %d attempts", max_retries)

    # All retries failed
    raise RuntimeError(
        f"Failed to download {owner}/{repo} at {commit_hash} after {max_retries} attempts: {last_error}"
    ) from last_error


def _find_cached_file_by_commit(commit_hash: str, cache_dir: Path) -> Path | None:
    """
    Find cached archive file matching commit hash.

    Searches for files matching pattern cvelistv5_*.tar.gz that contain the commit hash.

    Args:
        commit_hash: Git commit hash (short or full)
        cache_dir: Cache directory

    Returns:
        Path to matching cached file or None if not found
    """
    if not cache_dir.exists():
        return None

    for archive_file in cache_dir.glob("cvelistv5_*.tar.gz"):
        if commit_hash in archive_file.name:
            return archive_file
    return None


def _move_to_cache(tmp_path: Path, commit_hash: str, cache_dir: Path) -> Path:
    """
    Move temporary file to cache with standardized naming.

    Args:
        tmp_path: Temporary file path
        commit_hash: Commit hash for cache filename
        cache_dir: Cache directory

    Returns:
        Path to cached file
    """
    cache_file = cache_dir / f"cvelistv5_{commit_hash}.tar.gz"

    logger.debug("Moving downloaded archive from %s to %s", tmp_path, cache_file)
    tmp_path.replace(cache_file)
    return cache_file


def _copy_extracted_data(source_dir: Path, target_dir: Path) -> None:
    """
    Copy extracted CVE data from source to target directory.

    Copies the 'cves' subdirectory from source to target, creating target if needed.

    Args:
        source_dir: Source directory containing extracted CVE data
        target_dir: Target directory to copy data to

    Raises:
        OSError: File copy error
    """
    source_cves = source_dir / "cves"
    target_cves = target_dir / "cves"

    if not source_cves.exists():
        raise OSError(f"Source CVE directory not found: {source_cves}")

    # Remove target if exists to ensure clean copy
    if target_cves.exists():
        logger.debug("Removing existing target CVE directory: %s", target_cves)
        shutil.rmtree(target_cves)

    logger.debug("Copying CVE data from %s to %s", source_cves, target_cves)
    shutil.copytree(source_cves, target_cves)


def _extract_nested_zips(archive_path: Path, target_dir: Path) -> None:
    """
    Extract tarball archive and any nested ZIPs within.

    Extracts tarball to target_dir, then recursively extracts any ZIP files
    found within until no more ZIPs remain.

    Args:
        archive_path: Path to tarball archive
        target_dir: Target extraction directory

    Raises:
        RuntimeError: Tarball extraction error or corrupted archive
        OSError: File system error
    """
    if not tarfile.is_tarfile(archive_path):
        raise RuntimeError(f"Not a valid tarball: {archive_path}")

    # Extract top-level tarball
    logger.info("Extracting archive: %s", archive_path)
    try:
        with tarfile.open(archive_path, "r:*") as tf:
            # Extract files individually to handle potential gzip stream truncation
            # GitHub CDN sometimes returns truncated gzip streams, so we extract what we can
            extracted_count = 0
            try:
                for member in tf:
                    try:
                        tf.extract(member, target_dir)
                        extracted_count += 1
                    except Exception as e:
                        logger.warning("Failed to extract member %s: %s, continuing...", getattr(member, 'name', 'unknown'), e)
            except EOFError:
                # Stop if we hit EOF during iteration - we've extracted what we could
                logger.warning("Tarball stream ended prematurely, extracted %d members", extracted_count)

        if extracted_count == 0:
            raise RuntimeError(f"No files extracted from tarball: {archive_path}")

        logger.info("Extracted %d files from tarball", extracted_count)

        # Find and move contents up one level to remove wrapper directory
        extracted_items = list(target_dir.iterdir())
        if len(extracted_items) == 1 and extracted_items[0].is_dir():
            wrapper_dir = extracted_items[0]
            # Move contents from wrapper to target
            for item in wrapper_dir.iterdir():
                dest = target_dir / item.name
                shutil.move(str(item), str(dest))
            # Remove now-empty wrapper directory
            wrapper_dir.rmdir()

    except tarfile.TarError as e:
        raise RuntimeError(f"Corrupted tarball {archive_path}: {e}") from e
    except OSError as e:
        raise OSError(f"Failed to extract to {target_dir}: {e}") from e

    # Recursively extract nested ZIPs
    nested_zips = list(target_dir.rglob("*.zip"))
    while nested_zips:
        current_zip = nested_zips.pop(0)
        nested_extract_dir = current_zip.parent / current_zip.stem

        logger.debug("Extracting nested ZIP: %s", current_zip)
        try:
            with zipfile.ZipFile(current_zip, "r") as zf:
                zf.extractall(nested_extract_dir)

            # Remove extracted ZIP
            current_zip.unlink()

            # Find new ZIPs in extracted directory
            new_zips = list(nested_extract_dir.rglob("*.zip"))
            nested_zips.extend(new_zips)

        except zipfile.BadZipFile:
            logger.warning("Skipping corrupted nested ZIP: %s", current_zip)
            current_zip.unlink()
        except OSError as e:
            logger.warning("Failed to extract nested ZIP %s: %s", current_zip, e)

    logger.info("Extraction complete")
