"""Download and sync helpers for managebac-fetch.

This module is responsible for taking discovered file entries and syncing them
to disk with predictable, user-friendly progress reporting.

Goals
-----
- Provide a typed download pipeline.
- Skip existing files when sync mode is enabled.
- Keep progress indicators smooth and accurate by advancing one step per file.
- Return detailed summary information for CLI reporting.
- Support optional multiprocessing while still behaving correctly in serial mode.
- Expose progress callbacks so higher-level UI can blend scanning and downloading.

The public entry point is ``download_files``.
"""

from __future__ import annotations

import time
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from http.cookiejar import CookieJar
from pathlib import Path
from urllib.parse import urljoin

import requests
from tqdm import tqdm

from .browser import FileEntry

DEFAULT_TIMEOUT = 60
CHUNK_SIZE = 1024 * 64

DEFAULT_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/120.0.0.0 Safari/537.36"
    )
}


@dataclass(frozen=True, slots=True)
class PreparedDownload:
    """A fully prepared download task."""

    source: FileEntry
    destination: Path
    url: str


@dataclass(frozen=True, slots=True)
class DownloadResult:
    """The result of a single download attempt."""

    success: bool
    path: str
    message: str
    bytes_downloaded: int = 0


@dataclass(frozen=True, slots=True)
class DownloadSummary:
    """Aggregated sync results for a class or run."""

    total: int
    queued: int
    downloaded: int
    skipped: int
    failed: int


def download_files(
    files: Sequence[FileEntry],
    output_dir: str | Path,
    school_code: str,
    cookies: CookieJar | None,
    *,
    sync: bool = True,
    job_pool=None,
    show_progress: bool = True,
    progress_callback: Callable[[int, int], None] | None = None,
) -> DownloadSummary:
    """Sync discovered files to disk.

    Args:
        files: File entries discovered from ManageBac.
        output_dir: Destination directory for the class.
        school_code: ManageBac school code used to resolve relative URLs.
        cookies: Authenticated cookie jar.
        sync: When True, skip files that already exist locally.
        job_pool: Optional multiprocessing pool exposing ``imap``.
        show_progress: Whether to show a tqdm progress bar.
        progress_callback: Optional callback invoked as ``(completed, total)``
            while files are processed, including skipped files.

    Returns:
        A ``DownloadSummary`` describing the sync results.
    """
    destination_root = Path(output_dir)
    prepared_downloads, skipped_count = _prepare_downloads(
        files=files,
        output_dir=destination_root,
        school_code=school_code,
        sync=sync,
    )

    total_count = len(files)
    queued_count = len(prepared_downloads)

    if progress_callback is not None:
        progress_callback(skipped_count, total_count)

    if queued_count == 0:
        return DownloadSummary(
            total=total_count,
            queued=0,
            downloaded=0,
            skipped=skipped_count,
            failed=0,
        )

    results_iterable: Iterable[DownloadResult]
    if job_pool is None:
        results_iterable = (
            _download_one(download, cookies) for download in prepared_downloads
        )
    else:
        payloads = [
            {
                "download": download,
                "cookies": cookies,
            }
            for download in prepared_downloads
        ]
        results_iterable = job_pool.imap(_download_one_from_payload, payloads)

    results: list[DownloadResult] = []
    completed_count = skipped_count
    total_bytes = 0
    start_time = time.time()

    if show_progress:
        with tqdm(
            total=total_count,
            initial=skipped_count,
            desc=_progress_description(destination_root),
            unit="file",
            leave=False,
            smoothing=0.08,
            bar_format=(
                "{desc}: {percentage:3.0f}%|{bar}| "
                "{n_fmt}/{total_fmt} [{elapsed}{postfix}]"
            ),
        ) as progress:
            for result in results_iterable:
                results.append(result)
                completed_count += 1
                total_bytes += result.bytes_downloaded

                elapsed = time.time() - start_time
                speed = total_bytes / elapsed if elapsed > 0 else 0
                if speed > 1024 * 1024:
                    speed_str = f"{speed / (1024 * 1024):.1f} MB/s"
                elif speed > 1024:
                    speed_str = f"{speed / 1024:.1f} KB/s"
                else:
                    speed_str = f"{speed:.1f} B/s"

                progress.set_postfix_str(speed_str)
                progress.update(1)
                if progress_callback is not None:
                    progress_callback(completed_count, total_count)
    else:
        for result in results_iterable:
            results.append(result)
            completed_count += 1
            if progress_callback is not None:
                progress_callback(completed_count, total_count)

    downloaded_count = sum(1 for result in results if result.success)
    failed_count = queued_count - downloaded_count

    return DownloadSummary(
        total=total_count,
        queued=queued_count,
        downloaded=downloaded_count,
        skipped=skipped_count,
        failed=failed_count,
    )


def _prepare_downloads(
    *,
    files: Sequence[FileEntry],
    output_dir: Path,
    school_code: str,
    sync: bool,
) -> tuple[list[PreparedDownload], int]:
    """Prepare download tasks and count skipped files."""
    prepared: list[PreparedDownload] = []
    skipped_count = 0

    for file_entry in files:
        relative_parts = [part for part in file_entry.name.split("/") if part]
        destination = output_dir.joinpath(*relative_parts)
        absolute_url = _absolute_url(school_code, file_entry.url)

        if sync and destination.exists():
            skipped_count += 1
            continue

        prepared.append(
            PreparedDownload(
                source=file_entry,
                destination=destination,
                url=absolute_url,
            )
        )

    return prepared, skipped_count


def _download_one_from_payload(payload: dict[str, object]) -> DownloadResult:
    """Wrapper for multiprocessing-friendly download execution."""
    download = payload["download"]
    cookies = payload.get("cookies")

    if not isinstance(download, PreparedDownload):
        return DownloadResult(
            success=False,
            path="",
            message="Invalid download payload",
        )

    if cookies is not None and not isinstance(cookies, CookieJar):
        cookies = None

    return _download_one(download, cookies)


def _download_one(
    download: PreparedDownload,
    cookies: CookieJar | None,
) -> DownloadResult:
    """Download a single file."""
    bytes_count = 0
    try:
        download.destination.parent.mkdir(parents=True, exist_ok=True)

        with (
            requests.get(
                download.url,
                cookies=cookies,
                headers=DEFAULT_HEADERS,
                stream=True,
                timeout=DEFAULT_TIMEOUT,
                allow_redirects=True,
            ) as response,
            download.destination.open("wb") as output_file,
        ):
            response.raise_for_status()
            for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
                if chunk:
                    output_file.write(chunk)
                    bytes_count += len(chunk)

        return DownloadResult(
            success=True,
            path=str(download.destination),
            message="downloaded",
            bytes_downloaded=bytes_count,
        )
    except Exception as exc:  # noqa: BLE001
        return DownloadResult(
            success=False,
            path=str(download.destination),
            message=str(exc),
            bytes_downloaded=bytes_count,
        )


def _progress_description(destination_root: Path) -> str:
    """Return a short progress description for tqdm."""
    return f"Downloading {destination_root.name or destination_root}"


def _absolute_url(school_code: str, path_or_url: str) -> str:
    """Convert a relative ManageBac path to an absolute URL."""
    if path_or_url.startswith(("http://", "https://")):
        return path_or_url

    base_url = f"https://{school_code}.managebac.com"
    return urljoin(base_url, path_or_url)
