"""Download Python embeddable package to a specific directory."""

from __future__ import annotations

import argparse
import logging
import platform
import shutil
import sys
import time
import zipfile
from contextlib import suppress
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from typing import Final
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

from pytola.core.logger import get_logger

from ..models.solution import Solution

logger = get_logger(__name__)

cwd = Path.cwd()

# Default cache directory
_DEFAULT_CACHE_DIR = Path.home() / ".pytola" / ".cache" / "embed-python"
_DEFAULT_PYTHON_VER = "3.8.10"

# Architecture mapping
ARCH_DICT: Final = {
    "amd64": "amd64",
    "x86_64": "amd64",
    "x86": "amd64",
    "arm64": "arm64",
    "aarch64": "arm64",
}

# User-Agent for HTTP requests
USER_AGENT: Final = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
)


@dataclass(frozen=True)
class EmbedDownloader:
    """Class for downloading Python embeddable package."""

    parent: EmbedInstaller
    version: str

    @cached_property
    def cache_file(self) -> Path:
        """Get the cache file path for the embeddable package."""
        return self.parent.cache_dir / f"python-{self.version}-embed-{self.parent.arch}.zip"

    @cached_property
    def download_urls(self) -> list[str]:
        """Get the list of URLs to try for download."""
        if self.parent.skip_speed_test:
            url = self.get_official_url_template(self.parent.arch).format(
                version=self.latest_path_version,
            )
            logger.info(f"Skipping speed test, using official URL: {url}")
            return [url]
        return self.find_available_urls(
            self.latest_path_version,
            self.parent.arch,
            self.parent.timeout,
        )

    def download(self) -> bool:
        """Download the Python embeddable package to the cache file."""
        return self.has_cached_file or self.download_python_package(
            self.cache_file,
            self.download_urls,
            self.parent.offline,
        )

    @cached_property
    def has_cached_file(self) -> bool:
        """Check if the embeddable package is already cached."""
        if not self.cache_file.exists():
            return False

        is_valid = zipfile.is_zipfile(self.cache_file)
        if not is_valid:
            logger.warning(f"Corrupted cache file detected: {self.cache_file}")
            logger.info("Deleting corrupted cache file and re-downloading...")
            try:
                self.cache_file.unlink()
            except OSError as e:
                logger.warning(f"Failed to delete corrupted cache file: {e}")
        else:
            logger.debug(f"Using cached file: {self.cache_file}")

        return is_valid

    @cached_property
    def latest_path_version(self) -> str | None:
        """Get the latest version of Python available for download."""
        version = self.get_latest_patch_version(
            self.version,
            self.parent.arch,
            self.parent.cache_dir,
        )
        return version if "." in version else None

    def download_with_progress(self, url: str, dest_path: Path) -> None:
        """Download file with progress bar and integrity check."""
        logger.info(f"Downloading from: {url}")

        req = Request(url, headers={"User-Agent": USER_AGENT})
        try:
            with urlopen(req, timeout=self.parent.timeout) as response:
                total_size = int(response.headers.get("content-length", 0))
                dest_path.parent.mkdir(parents=True, exist_ok=True)

                downloaded = self.download_file_chunks(response, dest_path, total_size)
                logger.info(f"Download completed: {downloaded / 1024 / 1024:.2f} MB")
        except HTTPError as e:
            logger.exception(f"HTTP error {e.code} while downloading {url}")
            raise
        except URLError as e:
            logger.exception(f"Failed to download {url}: {e}")
            raise

    def download_file_chunks(self, response, dest_path: Path, total_size: int) -> int:
        """Download file chunks and return downloaded size."""
        downloaded = 0
        last_progress = 0
        last_log_time = 0
        chunk_size = 65536

        with dest_path.open("wb") as f:
            while chunk := response.read(chunk_size):
                f.write(chunk)
                downloaded += len(chunk)

                if total_size > 0:
                    progress = (downloaded / total_size) * 100
                    current_time = time.time()
                    # Update progress every 1% or every 0.5 seconds, whichever comes first
                    if (
                        int(progress) > last_progress and current_time - last_log_time >= 0.5
                    ) or downloaded == total_size:
                        downloaded_mb = downloaded / 1024 / 1024
                        total_mb = total_size / 1024 / 1024
                        logger.info(
                            f"Progress: {downloaded_mb:.2f} MB / {total_mb:.2f} MB ({progress:.1f}%)",
                        )
                        last_progress = int(progress)
                        last_log_time = current_time

        if total_size > 0 and downloaded != total_size:
            msg = f"Incomplete download: expected {total_size} bytes, got {downloaded} bytes"
            raise URLError(
                msg,
            )
        return downloaded

    def try_download_from_url(self, cache_file: Path, url: str) -> bool:
        """Try to download from a single URL and validate."""
        try:
            self.download_with_progress(url, cache_file)
        except (URLError, HTTPError):
            # Remove partial file on download failure
            if cache_file.exists():
                with suppress(OSError):
                    cache_file.unlink()
            raise

        is_valid = zipfile.is_zipfile(cache_file)

        if is_valid:
            logger.info("Downloaded file is valid")
        else:
            logger.warning("Downloaded file is corrupted, trying next URL...")
            if cache_file.exists():
                with suppress(OSError):
                    cache_file.unlink()

        return is_valid

    def download_python_package(
        self,
        cache_file: Path,
        urls: list[str],
        offline: bool,
    ) -> bool:
        """Download Python package from available URLs."""
        if offline:
            logger.error("Offline mode: no cached file found")
            return False

        if not urls:
            logger.error("No available URLs to download from")
            return False

        # Attempt download from each URL
        for i, url in enumerate(urls, 1):
            try:
                logger.info(f"Attempting download {i}/{len(urls)}: {url}")
                if self.try_download_from_url(cache_file, url):
                    return True
            except (URLError, HTTPError) as e:
                logger.warning(f"Download failed from {url}: {e}")
                if i < len(urls):
                    logger.info("Retrying with next available URL...")

        # All downloads failed
        logger.error(
            "Failed to download Python from all available URLs. Please check your internet connection and version.",
        )
        return False

    def get_official_url_template(self, arch: str | None = None) -> str:
        """Get official URL template based on architecture."""
        arch = arch or self.parent.arch
        return f"https://www.python.org/ftp/python/{{version}}/python-{{version}}-embed-{arch}.zip"

    def get_mirror_url_templates(self, arch: str | None = None) -> list[str]:
        """Get mirror URL templates based on architecture."""
        arch = arch or self.parent.arch
        return [
            f"https://mirrors.huaweicloud.com/python/{{version}}/python-{{version}}-embed-{arch}.zip",
            f"https://mirrors.aliyun.com/python-release/{{version}}/python-{{version}}-embed-{arch}.zip",
            f"https://mirrors.tuna.tsinghua.edu.cn/python/{{version}}/python-{{version}}-embed-{arch}.zip",
            f"https://mirrors.pku.edu.cn/python/{{version}}/python-{{version}}-embed-{arch}.zip",
        ]

    def test_url_speed(
        self,
        url: str,
        timeout: int | None = None,
        use_get: bool = False,
    ) -> float:
        """Test URL speed by making a HEAD or GET request."""
        timeout = timeout or self.parent.timeout
        start_time = time.time()

        # First attempt HEAD request
        if not use_get:
            req = Request(url, headers={"User-Agent": USER_AGENT}, method="HEAD")
            try:
                with urlopen(req, timeout=timeout) as response:
                    if response.status == 200:
                        elapsed = time.time() - start_time
                        logger.debug(f"URL {url}: {elapsed:.3f}s")
                        return elapsed
                    logger.debug(f"URL {url}: HTTP {response.status}")
            except HTTPError as e:
                if e.code in {405, 403}:
                    use_get = True  # Fall back to GET request
                else:
                    logger.debug(f"URL {url}: HTTP {e.code}")
                    return float("inf")
            except (URLError, TimeoutError) as e:
                logger.debug(f"URL {url}: {type(e).__name__}")
                return float("inf")

        # Fallback to GET request if needed
        if use_get:
            req = Request(
                url,
                headers={"User-Agent": USER_AGENT, "Range": "bytes=0-1023"},
                method="GET",
            )
            try:
                with urlopen(req, timeout=timeout) as response:
                    if response.status in {206, 200}:
                        elapsed = time.time() - start_time
                        logger.debug(f"URL {url}: {elapsed:.3f}s (GET fallback)")
                        return elapsed
                    logger.debug(f"URL {url}: HTTP {response.status} (GET fallback)")
            except (URLError, TimeoutError) as e:
                logger.debug(f"URL {url}: {type(e).__name__} (GET fallback)")

        return float("inf")

    def get_urls_to_test(
        self,
        version: str | None = None,
        arch: str | None = None,
    ) -> list[str]:
        """Get list of URLs to test for speed."""
        version = version or self.version
        arch = arch or self.parent.arch
        return [
            template.format(version=version)
            for template in [
                *self.get_mirror_url_templates(arch),
                self.get_official_url_template(arch),
            ]
        ]

    def find_available_urls(
        self,
        version: str | None = None,
        arch: str | None = None,
        timeout: int | None = None,
    ) -> list[str]:
        """Find all available URLs for downloading Python embeddable package, sorted by speed."""
        version = version or self.version
        arch = arch or self.parent.arch
        timeout = timeout or self.parent.timeout
        official_url = self.get_official_url_template(arch).format(version=version)

        logger.debug("Testing mirror speeds...")
        all_tests = [(url, self.test_url_speed(url, timeout)) for url in self.get_urls_to_test(version, arch)]
        speed_results = [(speed, url) for url, speed in all_tests if speed != float("inf")]

        # Log results
        for speed, url in speed_results:
            logger.debug(f"  ✓ {url} ({speed:.3f}s)")
        for url, speed in all_tests:
            if speed == float("inf"):
                logger.debug(f"  ✗ {url} (unavailable)")

        if not speed_results:
            logger.warning("All mirrors failed, falling back to official URL")
            return [official_url]

        # Sort URLs by speed and return
        available_urls = [url for _, url in sorted(speed_results)]
        if available_urls and logger.isEnabledFor(logging.DEBUG):
            logger.debug(
                f"Available URLs (sorted by speed): {len(available_urls)} found",
            )
            for i, url in enumerate(available_urls, 1):
                logger.debug(f"  {i}. {url}")
        return available_urls

    # Methods moved from EmbedInstallConfig
    def is_version_complete(self, version: str) -> bool:
        """Check if version string is complete (has 3 or more parts)."""
        version_parts = version.split(".")
        return len(version_parts) >= 3

    def validate_version_format(self, version: str) -> bool:
        """Validate if version has correct major.minor format."""
        version_parts = version.split(".")
        return "." in version and len(version_parts) == 2

    def extract_version_from_filename(self, filename: str, arch: str) -> str:
        """Extract version from cache filename."""
        return filename.replace("python-", "").replace(f"-embed-{arch}", "")

    def get_cached_version(
        self,
        major: str,
        minor: str,
        arch: str,
        cache_dir: Path,
    ) -> str | None:
        """Get the highest cached version for a given major.minor."""
        if not cache_dir.exists():
            return None

        pattern = f"python-{major}.{minor}.*-embed-{arch}.zip"
        cached_versions = [
            self.extract_version_from_filename(cache_file.stem, arch)
            for cache_file in cache_dir.glob(pattern)
            if cache_file.stem and zipfile.is_zipfile(cache_file)
        ]

        return max(cached_versions, default=None) if cached_versions else None

    def check_version_url(self, version: str, arch: str, timeout: int) -> bool:
        """Check if version URL is accessible."""
        base_url = self.get_official_url_template(arch)
        test_url = base_url.format(version=version)
        req = Request(test_url, headers={"User-Agent": USER_AGENT}, method="HEAD")

        try:
            with urlopen(req, timeout=timeout) as response:
                if response.status == 200:
                    return True
                logger.debug(f"Version {version}: HTTP {response.status}")
                return False
        except HTTPError as e:
            log_level = logger.info if e.code == 404 else logger.debug
            log_level(f"Version {version}: HTTP {e.code}")
        except (URLError, TimeoutError) as e:
            logger.debug(f"Version {version}: {type(e).__name__}")

        return False

    def find_latest_patch_online(
        self,
        major: str,
        minor: str,
        arch: str,
        timeout: int,
    ) -> str | None:
        """Find latest patch version by checking online."""
        # Use intelligent patch range based on Python version patterns
        max_patch = self._get_max_patch_for_version(major, minor)

        # Check patches from highest to lowest
        for patch in range(max_patch, -1, -1):
            version = f"{major}.{minor}.{patch}"
            if self.check_version_url(version, arch, timeout):
                logger.info(f"Latest version found: {version}")
                return version
        return None

    def _get_max_patch_for_version(self, major: str, minor: str) -> int:
        """Get maximum patch number for a given Python version based on historical data."""
        version_key = f"{major}.{minor}"

        # Known maximum patch versions for Python embeddable packages
        # These are the actual highest embed versions available
        known_max_patches = {
            "3.8": 10,  # Python 3.8 embed最高版本是3.8.10
            "3.9": 18,  # Python 3.9 embed最高版本是3.9.18
            "3.10": 15,  # Python 3.10 embed最高版本是3.10.15
            "3.11": 9,  # Python 3.11 embed最高版本是3.11.9
            "3.12": 3,  # Python 3.12 embed最高版本是3.12.3
            "3.13": 1,  # Python 3.13 embed最高版本是3.13.1
        }

        # Return known max patch or default to 15 for unknown versions
        return known_max_patches.get(version_key, 15)

    def resolve_patch_version(
        self,
        major_minor: str,
        arch: str,
        cache_dir: Path,
        timeout: int,
    ) -> str:
        """Resolve version to latest patch or return original."""
        major, minor = major_minor.split(".")

        # Check for cached version first
        if cached_version := self.get_cached_version(major, minor, arch, cache_dir):
            logger.info(f"Using cached version: {cached_version}")
            return cached_version

        # Otherwise find latest online
        logger.info(f"Checking latest version for {major_minor}...")
        latest_version = self.find_latest_patch_online(major, minor, arch, timeout)

        if latest_version:
            return latest_version

        logger.warning(
            f"Could not find any patch version for {major_minor}, using as-is",
        )
        return major_minor

    def get_latest_patch_version(
        self,
        major_minor: str,
        arch: str,
        cache_dir: Path,
        timeout: int = 5,
    ) -> str:
        """Get the latest patch version for a given major.minor version.

        Args:
            major_minor: Major.minor version (e.g., '3.13') or full version (e.g., '3.13.1')
            arch: Architecture (amd64 or arm64)
            cache_dir: Cache directory to check for cached versions
            timeout: Request timeout in seconds

        Returns
        -------
            Full version string (e.g., '3.13.1') or original if already complete
        """
        # Return early if version is already complete or invalid format
        if self.is_version_complete(major_minor) or not self.validate_version_format(
            major_minor,
        ):
            log_msg = (
                f"Version already complete: {major_minor}, skipping auto-completion"
                if self.is_version_complete(major_minor)
                else f"Invalid version format: {major_minor}, using as-is"
            )
            logger.debug(log_msg)
            return major_minor

        return self.resolve_patch_version(major_minor, arch, cache_dir, timeout)


@dataclass(frozen=False)
class EmbedInstaller:
    """Class for installing Python embeddable package."""

    root_dir: Path
    cache_dir: Path | None = field(default_factory=lambda: _DEFAULT_CACHE_DIR)
    offline: bool = False
    skip_speed_test: bool = False
    timeout: int = 5

    def __post_init__(self):
        """Initialize the cache directory if it doesn't exist."""
        if not self.cache_dir:
            object.__setattr__(self, "cache_dir", _DEFAULT_CACHE_DIR)

    @cached_property
    def solution(self) -> Solution:
        """Get the solution from the target directory."""
        return Solution.from_directory(self.root_dir)

    @cached_property
    def arch(self) -> str:
        """Get the architecture of the machine."""
        machine_arch = platform.machine().lower()
        return ARCH_DICT.get(machine_arch, "amd64")

    @cached_property
    def runtime_dir(self) -> Path:
        """Get the runtime directory."""
        runtime_path = self.root_dir / "dist" / "runtime"
        runtime_path.mkdir(parents=True, exist_ok=True)
        return runtime_path

    @cached_property
    def python_exe_path(self) -> Path:
        """Get the path to python.exe in the target directory."""
        return self.runtime_dir / "python.exe"

    def extract_package(self, downloader: EmbedDownloader) -> bool:
        """Extract package to target directory."""
        if not zipfile.is_zipfile(downloader.cache_file):
            logger.error(f"Invalid zip file: {downloader.cache_file}")
            return False

        try:
            logger.info(f"Extracting to: {self.runtime_dir}")
            self.runtime_dir.mkdir(parents=True, exist_ok=True)

            # First get file count for progress
            with zipfile.ZipFile(downloader.cache_file, "r") as zip_ref:
                file_count = len(zip_ref.namelist())

            # Extract the archive
            shutil.unpack_archive(
                str(downloader.cache_file),
                str(self.runtime_dir),
                "zip",
            )
            logger.info(f"Extracted {file_count} files")
            return True
        except (zipfile.BadZipFile, OSError) as e:
            logger.exception(f"Failed to extract: {e}")
            return False

    def run(self) -> bool:
        """Install Python embeddable package to target directory using embedded methods."""
        # Download the package (latest_version is resolved automatically during download)
        results = []
        total_processed = 0
        extracted = False  # Track if we've already extracted for any project

        for project in self.solution.projects.values():
            if not project.min_python_version:
                logger.warning(
                    f"Project {project.name} does not have a minimum Python version",
                )
                continue

            logger.info(f"Processing project: `{project.name}`")
            total_processed += 1
            downloader = EmbedDownloader(
                parent=self,
                version=project.min_python_version or _DEFAULT_PYTHON_VER,
            )

            if not downloader.download():
                logger.error("Failed to download Python embeddable package")
                continue

            # Extract the package to runtime directory only once
            # All projects share the same embed Python runtime
            if not extracted:
                if self.python_exe_path.exists():
                    logger.info(
                        f"Python runtime already exists at {self.runtime_dir}, skipping extraction",
                    )
                    extracted = True
                elif self.extract_package(downloader):
                    extracted = True
                else:
                    logger.error("Failed to extract Python embeddable package")
                    continue

            # Verify installation by checking for python.exe
            results.append(self.python_exe_path.exists())

        if total_processed == 0:
            logger.error("No projects with minimum Python version found to process")
            return False

        if all(results) and len(results) > 0:
            logger.info("Python embeddable package installed successfully")
            return True
        logger.error("Failed to install Python embeddable package")
        return False


def parse_args() -> argparse.Namespace:
    """Create and configure argument parser for CLI."""
    parser = argparse.ArgumentParser(
        prog="pyembedinstall",
        description="Download and install Python embeddable package to a specific directory.",
    )
    parser.add_argument(
        "directory",
        type=str,
        nargs="?",
        default=str(cwd),
        help="Directory to install Python embeddable package (default: current directory)",
    )
    parser.add_argument("--debug", "-d", action="store_true", help="Enable debug mode")
    parser.add_argument(
        "--offline",
        "-o",
        action="store_true",
        help="Offline mode (use cached files only)",
    )
    parser.add_argument(
        "--version",
        "-v",
        type=str,
        default=_DEFAULT_PYTHON_VER,
        help="Python version to install (default: 3.8.10)",
    )
    parser.add_argument(
        "--cache-dir",
        "-C",
        type=str,
        default=str(_DEFAULT_CACHE_DIR),
        help="Cache directory for downloaded files",
    )
    parser.add_argument(
        "--skip-speed-test",
        "-s",
        action="store_true",
        help="Skip speed test and use official URL directly",
    )
    parser.add_argument(
        "--timeout",
        "-t",
        type=int,
        default=5,
        help="Timeout in seconds for URL speed test (default: 5)",
    )
    return parser.parse_args()


def main() -> None:
    """Run main entry point for pyembedinstall CLI."""
    args = parse_args()

    if args.debug:
        logger.setLevel(logging.DEBUG)

    cache_dir = Path(args.cache_dir)
    cache_dir.mkdir(parents=True, exist_ok=True)

    t0 = time.perf_counter()

    if not EmbedInstaller(
        root_dir=Path(args.directory),
        cache_dir=cache_dir,
        offline=args.offline,
        skip_speed_test=args.skip_speed_test,
        timeout=args.timeout,
    ).run():
        sys.exit(1)

    logger.info(f"Installation completed in {time.perf_counter() - t0:.4f} seconds")
