"""Utility functions for dotbins."""

from __future__ import annotations

import bz2
import functools
import gzip
import hashlib
import lzma
import os
import platform as platform_module
import re
import shutil
import sys
import tarfile
import textwrap
import time
import zipfile
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar

import requests
from rich.console import Console

if TYPE_CHECKING:
    from .config import ToolConfig

console = Console()

SUPPORTED_ARCHIVE_EXTENSIONS = [
    ".zip",
    ".tar",
    ".tar.gz",
    ".tgz",
    ".tar.bz2",
    ".tbz2",
    ".tar.xz",
    ".txz",
    ".gz",
    ".bz2",
    ".xz",
    ".lzma",
]

SUPPORTED_SHELLS = ["bash", "zsh", "fish", "nushell", "powershell"]

Shells = Literal["bash", "zsh", "fish", "nushell", "powershell"]


def _maybe_github_token_header(github_token: str | None) -> dict[str, str]:  # pragma: no cover
    return {} if github_token is None else {"Authorization": f"token {github_token}"}


def _github_api_get(
    url: str,
    headers: dict[str, str],
    *,
    _retries: int = 0,
) -> requests.Response:
    """Make a GitHub API request with automatic rate limit handling."""
    response = requests.get(url, headers=headers, timeout=30)

    # Handle rate limiting (max 3 retries)
    if response.status_code == 403 and "rate limit" in response.text.lower():
        if _retries >= 3:
            return response  # Give up after 3 retries
        reset_time = int(response.headers.get("X-RateLimit-Reset", 0))
        wait_seconds = max(0, reset_time - time.time()) + 1
        log(f"Rate limited. Waiting {wait_seconds:.0f}s until reset...", "warning")
        log(
            "Tip: Use `GITHUB_TOKEN=$(gh auth token) dotbins sync` for higher rate limits",
            "info",
        )
        time.sleep(wait_seconds)
        return _github_api_get(url, headers, _retries=_retries + 1)

    return response


_DEFAULT_API_URL = "https://api.github.com"


@functools.cache
def fetch_release_info(
    repo: str,
    tag: str | None = None,
    tag_pattern: str | None = None,
    github_token: str | None = None,
    api_url: str | None = None,
) -> dict | None:
    """Fetch release information from a GitHub-compatible API for a single repository.

    Supports GitHub, Gitea, Forgejo, Gogs, and other forges with compatible APIs.

    Args:
        repo: Repository in format "owner/repo"
        tag: Exact tag to fetch (e.g., "v1.2.3")
        tag_pattern: Regex pattern to filter releases (e.g., "^cli-" for bitwarden/clients)
        github_token: GitHub API token for authentication
        api_url: Base API URL (default: "https://api.github.com").
            For Gitea/Forgejo, use e.g. "https://gitea.com/api/v1".

    Returns:
        Release information dict from GitHub-compatible API

    """
    base = (api_url or _DEFAULT_API_URL).rstrip("/")
    headers = _maybe_github_token_header(github_token)

    if tag is not None:
        url = f"{base}/repos/{repo}/releases/tags/{tag}"
        log(f"Fetching release from {url}", "info")
    elif tag_pattern is not None:
        # Fetch releases and find first matching the pattern
        url = f"{base}/repos/{repo}/releases?per_page=30"
        log(f"Fetching releases matching pattern '{tag_pattern}' from {url}", "info")
        try:
            response = _github_api_get(url, headers)
            response.raise_for_status()
            releases = response.json()
            for release in releases:
                if re.search(tag_pattern, release["tag_name"]):
                    log(f"Found release matching '{tag_pattern}': {release['tag_name']}", "success")
                    return release
            msg = f"No release matching pattern '{tag_pattern}' found in {repo}"
            raise RuntimeError(msg)
        except requests.RequestException as e:
            msg = f"Failed to fetch releases for {repo}: {e}"
            raise RuntimeError(msg) from e
    else:
        url = f"{base}/repos/{repo}/releases/latest"
        log(f"Fetching release from {url}", "info")

    try:
        response = _github_api_get(url, headers)
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        msg = f"Failed to fetch release for {repo}: {e}"
        raise RuntimeError(msg) from e


def download_file(url: str, destination: str, github_token: str | None, verbose: bool) -> str:
    """Download a file from a URL to a destination path."""
    log(f"Downloading from [b]{url}[/]", "info", "📥")
    # Already verbose when fetching release info
    headers = _maybe_github_token_header(github_token)
    try:
        response = requests.get(url, stream=True, timeout=30, headers=headers)
        response.raise_for_status()
        with open(destination, "wb") as f:
            for chunk in response.iter_content(chunk_size=8192):
                f.write(chunk)
        return destination
    except requests.RequestException as e:
        log(f"Download failed: {e}", "error", print_exception=verbose)
        msg = f"Failed to download {url}: {e}"
        raise RuntimeError(msg) from e


def current_platform() -> tuple[str, str]:
    """Detect the current platform and architecture.

    Returns:
        Tuple containing (platform, architecture)
        platform: 'linux' or 'macos' or 'windows'
        architecture: 'amd64' or 'arm64'

    """
    sys_platform = sys.platform
    platform = {
        "darwin": "macos",
        "win32": "windows",  # Returns "win32" on Windows on 32-bit and 64-bit
    }.get(sys_platform, sys_platform)

    machine = platform_module.machine().lower()

    arch = {
        "aarch64": "arm64",
        "x86_64": "amd64",
    }.get(machine, machine)

    return platform, arch


def replace_home_in_path(path: Path, home: str = "$HOME") -> str:
    """Replace ~ with $HOME in a path."""
    abs_path = str(path.absolute())
    home_path = os.path.expanduser("~")

    # Convert Windows backslashes to forward slashes for consistent display
    if os.name == "nt":
        abs_path = abs_path.replace("\\", "/")
        home_path = home_path.replace("\\", "/")

    return abs_path.replace(home_path, home)


def _format_shell_instructions(
    tools_dir: Path,
    shell: Shells,
    tools: dict[str, ToolConfig],
) -> str:
    """Format shell instructions for a given shell."""
    tools_dir_str = replace_home_in_path(tools_dir)

    # Base script that sets up PATH
    if shell in {"bash", "zsh"}:
        base_script = textwrap.dedent(
            f"""\
            # dotbins - Add platform-specific binaries to PATH
            _os=$(uname -s | tr '[:upper:]' '[:lower:]')
            [[ "$_os" == "darwin" ]] && _os="macos"

            _arch=$(uname -m)
            [[ "$_arch" == "x86_64" ]] && _arch="amd64"
            [[ "$_arch" == "aarch64" || "$_arch" == "arm64" ]] && _arch="arm64"

            export PATH="{tools_dir_str}/$_os/$_arch/bin:$PATH"
            """,
        )
        if_start = "if command -v {name} >/dev/null 2>&1; then"
        if_end = "fi"
        base_script += _add_shell_code_to_script(tools, shell, if_start, if_end)

        return base_script

    if shell == "fish":
        base_script = textwrap.dedent(
            f"""\
            # dotbins - Add platform-specific binaries to PATH
            set -l _os (uname -s | tr '[:upper:]' '[:lower:]')
            test "$_os" = "darwin"; and set _os "macos"

            set -l _arch (uname -m)
            test "$_arch" = "x86_64"; and set _arch "amd64"
            test "$_arch" = "aarch64" -o "$_arch" = "arm64"; and set _arch "arm64"

            fish_add_path {tools_dir_str}/$_os/$_arch/bin
            """,
        )

        if_start = "if command -v {name} >/dev/null 2>&1"
        if_end = "end"
        base_script += _add_shell_code_to_script(tools, shell, if_start, if_end)
        return base_script

    if shell == "nushell":
        tools_dir_str_nu = tools_dir_str.replace("$HOME", "($nu.home-path)")
        script_lines = [
            "# dotbins - Add platform-specific binaries to PATH",
            "let _os = $nu.os-info | get name",
            'let _os = if $_os == "darwin" { "macos" } else { $_os }',
            "",
            "let _arch = $nu.os-info | get arch",
            'let _arch = if $_arch == "x86_64" { "amd64" } else if $_arch in ["aarch64", "arm64"] { "arm64" } else { $_arch }',
            "",
            f'$env.PATH = ($env.PATH | prepend $"{tools_dir_str_nu}/($_os)/($_arch)/bin")',
        ]
        base_script = "\n".join(script_lines)
        if_start = "if (which {name}) != null {{"
        if_end = "}"
        base_script += _add_shell_code_to_script(tools, shell, if_start, if_end)
        return base_script

    if shell == "powershell":
        base_script = textwrap.dedent(
            f"""\
            # dotbins - Add platform-specific binaries to PATH
            $os = "windows"

            $arch = (Get-CimInstance -Class Win32_Processor).AddressWidth -eq 64 ? "amd64" : "386"

            $env:PATH = "{tools_dir_str}\\$os\\$arch\\bin" + [System.IO.Path]::PathSeparator + $env:PATH
            """,
        )
        if_start = "if (Get-Command {name} -ErrorAction SilentlyContinue) {{"
        if_end = "}"
        base_script += _add_shell_code_to_script(tools, shell, if_start, if_end)
        return base_script

    msg = f"Unsupported shell: {shell}"  # pragma: no cover
    raise ValueError(msg)  # pragma: no cover


def _add_shell_code_to_script(
    tools: dict[str, ToolConfig],
    shell: Shells,
    if_start: str,
    if_end: str,
) -> str:
    lines = []
    for name, config in tools.items():
        shell_code = config.shell_code.get(shell)
        if shell_code:
            config_lines = [
                f"# Configuration for {name}",
                if_start.format(name=name),
                *[f"    {line}" for line in shell_code.strip().split("\n")],
                if_end,
                "",
            ]
            lines.extend(config_lines)
    if lines:
        return "\n# Tool-specific configurations\n" + "\n".join(lines)
    return ""


def write_shell_scripts(
    tools_dir: Path,
    tools: dict[str, ToolConfig],
    print_shell_setup: bool = False,
) -> None:
    """Generate shell script files for different shells.

    Creates a 'shell' directory in the tools_dir and writes script files
    for bash, zsh, fish, and nushell that users can source in their shell
    configuration files.

    Args:
        tools_dir: The base directory where tools are installed
        print_shell_setup: Whether to print the shell setup instructions
        tools: Dictionary of tool configurations with shell_code to include

    """
    # Create shell directory
    shell_dir = tools_dir / "shell"
    shell_dir.mkdir(parents=True, exist_ok=True)

    # Generate scripts for each supported shell
    shell_files = {
        "bash": "bash.sh",
        "zsh": "zsh.sh",
        "fish": "fish.fish",
        "nushell": "nushell.nu",
        "powershell": "powershell.ps1",
    }

    for shell, filename in shell_files.items():
        script_content = _format_shell_instructions(tools_dir, shell, tools)  # type: ignore[arg-type]

        if shell in ["bash", "zsh"]:
            script_content = f"#!/usr/bin/env {shell}\n{script_content}"

        script_path = shell_dir / filename
        with open(script_path, "w", encoding="utf-8") as f:
            f.write(script_content + "\n")

        if os.name != "nt":  # Skip on Windows
            script_path.chmod(script_path.stat().st_mode | 0o755)

    tools_dir1 = replace_home_in_path(tools_dir, "~")
    log(f"Generated shell scripts in {tools_dir1}/shell/", "success", "📝")
    if print_shell_setup:
        tools_dir2 = replace_home_in_path(tools_dir, "$HOME")
        tools_dir2_nu = tools_dir2.replace("$HOME", "~")
        log("Add this to your shell config:", "info")
        log(f"  [b]Bash:[/]       [yellow]source {tools_dir2}/shell/bash.sh[/]", "info", "👉")
        log(f"  [b]Zsh:[/]        [yellow]source {tools_dir2}/shell/zsh.sh[/]", "info", "👉")
        log(f"  [b]Fish:[/]       [yellow]source {tools_dir2}/shell/fish.fish[/]", "info", "👉")
        log(f"  [b]Nushell:[/]    [yellow]source {tools_dir2_nu}/shell/nushell.nu[/]", "info", "👉")
        log(f"  [b]PowerShell:[/] [yellow]. {tools_dir2}/shell/powershell.ps1[/]", "info", "👉")


STYLE_EMOJI_MAP = {
    "success": "✅",
    "error": "❌",
    "warning": "⚠️",
    "info": "🔍",
    "default": "",
}

STYLE_FORMAT_MAP = {
    "success": "green",
    "error": "bold red",
    "warning": "yellow",
    "info": "cyan",
    "default": "",
}


def log(
    message: str,
    style: str = "default",
    emoji: str = "",
    *,
    print_exception: bool = False,
) -> None:
    """Print a formatted message to the console."""
    if not emoji:
        emoji = STYLE_EMOJI_MAP.get(style, "")

    prefix = f"{emoji} " if emoji else ""

    if style != "default":
        rich_format = STYLE_FORMAT_MAP.get(style, "")
        console.print(f"{prefix}[{rich_format}]{message}[/{rich_format}]")
    else:
        console.print(f"{prefix}{message}")
    if style == "error" and print_exception:
        console.print_exception()


def calculate_sha256(file_path: str | Path) -> str:
    """Calculate SHA256 hash of a file.

    Args:
        file_path: Path to the file

    Returns:
        Hexadecimal SHA256 hash string

    """
    sha256_hash = hashlib.sha256()
    with open(file_path, "rb") as f:
        # Read the file in chunks to handle large files efficiently
        for byte_block in iter(lambda: f.read(4096), b""):
            sha256_hash.update(byte_block)
    return sha256_hash.hexdigest()


def extract_archive(archive_path: str | Path, dest_dir: str | Path) -> None:
    """Extract an archive to a destination directory.

    Supports zip, tar, tar.gz, tar.bz2, tar.xz, gz, bz2, and xz formats.
    """
    archive_path = Path(archive_path)
    dest_dir = Path(dest_dir)

    try:
        filename = archive_path.name.lower()

        # Handle zip files
        if filename.endswith(".zip"):
            with zipfile.ZipFile(archive_path) as zip_file:
                zip_file.extractall(path=dest_dir)
            return

        # Define mappings for tar-based archives
        tar_formats = {
            ".tar": "r",
            ".tar.gz": "r:gz",
            ".tgz": "r:gz",
            ".tar.bz2": "r:bz2",
            ".tbz2": "r:bz2",
            ".tar.xz": "r:xz",
            ".txz": "r:xz",
        }

        # Handle tar archives
        for ext, mode in tar_formats.items():
            if filename.endswith(ext):
                with tarfile.open(archive_path, mode=mode) as tar:  # type: ignore[call-overload]
                    tar.extractall(path=dest_dir)
                return

        # Get file magic header for compression detection
        with open(archive_path, "rb") as f:
            header = f.read(6)

        # Helper function for single-file decompression
        def extract_compressed(open_func: Callable[[Path, str], Any]) -> None:
            output_path = dest_dir / archive_path.stem
            with (
                open_func(archive_path, "rb") as f_in,
                open(output_path, "wb") as f_out,
            ):
                shutil.copyfileobj(f_in, f_out)
            if os.name != "nt":  # Skip on Windows
                output_path.chmod(output_path.stat().st_mode | 0o755)

        # Try each compression format based on extension or file header
        if filename.endswith(".gz") or header.startswith(b"\x1f\x8b"):
            extract_compressed(gzip.open)
            return

        if filename.endswith(".bz2") or header.startswith(b"BZh"):
            extract_compressed(bz2.open)
            return

        if filename.endswith((".xz", ".lzma")) or header.startswith(b"\xfd\x37\x7a\x58\x5a\x00"):
            extract_compressed(lzma.open)
            return

        # Unsupported format
        msg = f"Unsupported archive format: {archive_path}"
        raise ValueError(msg)  # noqa: TRY301

    except Exception as e:
        log(f"Extraction failed: {e}", "error", print_exception=True)
        raise


def github_url_to_raw_url(repo_url: str) -> str:
    """Convert a GitHub repository URL to a raw URL."""
    # e.g.,
    # https://github.com/basnijholt/dotbins/blob/main/dotbins.yaml
    # becomes
    # https://raw.githubusercontent.com/basnijholt/dotbins/refs/heads/main/dotbins.yaml
    if "github.com" not in repo_url or "/blob/" not in repo_url:
        return repo_url
    return repo_url.replace(
        "github.com",
        "raw.githubusercontent.com",
    ).replace(
        "/blob/",
        "/refs/heads/",
    )


T = TypeVar("T")
R = TypeVar("R")


def execute_in_parallel(
    items: list[T],
    process_func: Callable[[T], R],
    max_workers: int = 16,
) -> list[R]:
    """Execute a function over a list of items in parallel.

    Args:
        items: List of items to process
        process_func: Function to apply to each item
        max_workers: Maximum number of parallel workers

    Returns:
        List of results from process_func applied to each item

    """
    with ThreadPoolExecutor(max_workers=min(max_workers, len(items) or 1)) as ex:
        futures = ex.map(process_func, items)
        return list(futures)


def humanize_time_ago(date_str: str) -> str:
    """Humanize a time ago string showing two largest time components."""
    # Note: Function doesn't properly handle future dates.
    date = datetime.fromisoformat(date_str)
    now = datetime.now()
    diff = now - date

    days = diff.days
    hours = diff.seconds // 3600
    minutes = (diff.seconds % 3600) // 60
    seconds = diff.seconds % 60

    if days > 0:
        return f"{days}d{hours}h" if hours > 0 else f"{days}d"
    if hours > 0:
        return f"{hours}h{minutes}m" if minutes > 0 else f"{hours}h"
    if minutes > 0:
        return f"{minutes}m{seconds}s" if seconds > 0 else f"{minutes}m"
    if seconds > 0:
        return f"{seconds}s"
    return "0s"


def tag_to_version(tag: str) -> str:
    """Convert a Git tag string to a version string.

    Specifically targeting tags that start with 'v' followed immediately by
    a digit (like typical semantic version tags).

    Args:
        tag: The input tag string.

    Returns:
        The version string (tag without the leading 'v') if the tag matches
        the pattern 'v' + digit + anything. Otherwise, returns the original
        tag unchanged.

    Examples:
        >>> tag_to_version("v0.1.0")
        '0.1.0'
        >>> tag_to_version("v1.2.3-alpha.1+build.123")
        '1.2.3-alpha.1+build.123'
        >>> tag_to_version("v22.10")
        '22.10'
        >>> tag_to_version("vacation") # Does not start with 'v' + digit
        'vacation'
        >>> tag_to_version("latest") # Does not start with 'v'
        'latest'
        >>> tag_to_version("1.0.0") # Does not start with 'v'
        '1.0.0'
        >>> tag_to_version("v-invalid") # Does not start with 'v' + digit
        'v-invalid'

    """
    # Regex explanation:
    # ^       - Anchor to the start of the string
    # v       - Match the literal character 'v'
    # (\d.*) - Capture group 1:
    #   \d    - Match exactly one digit (ensures it's not like "vacation")
    #   .*    - Match any character (except newline) zero or more times
    # $       - Anchor to the end of the string
    match = re.match(r"^v(\d.*)$", tag)
    if match:
        # If the pattern matches, return the captured group (the part after 'v')
        return match.group(1)
    # If the pattern does not match, return the original tag
    return tag
