"""Resumable + integrity-verified HTTP download — `AsyncHttpClient.download()`.

Closes [PAPER-NN H-NEW-1] from mgf-common's
`pre_release_additions.md` §5 — two-consumer signal (figrecovery
IPSW restoration + VManager VM-image downloads).

Both consumers need:

- **Chunked write to disk** (gigabyte-scale files can't be
  buffered in memory).
- **Integrity verification** (figrecovery checks IPSWs against the
  SHA-1 digests their publisher ships; VManager could check SHA-256
  of image vendors' published sums).
- **Resumption** (consumers don't want to re-download 9 GB
  after a connection blip; the IPSW shape DEMANDS resume).
- **Atomic-rename-on-success** — the destination path either
  has the verified-complete file OR nothing; never a
  half-written corpse (per SC-13).

Design
------

The download writes to a sidecar ``<dest>.partial`` file. On
resume, the existing partial is hashed (so verification covers
the FULL file, not just the post-resume chunk), then a Range
request fetches the rest. On verified completion, the partial
renames to the final dest atomically; on integrity failure, the
partial stays for inspection (the consumer's call whether to
delete + retry).

Why pre-hashing the partial: integrity verification must cover
the whole file. The alternative (hash only the newly-downloaded
chunk) gives a false-positive when the partial was corrupted by
an earlier download attempt or filesystem error.

Returned ``DownloadResult`` carries:
- ``dest`` (the final path, only valid on success).
- ``bytes_downloaded`` (total — partial + post-resume).
- ``sha256`` always computed.
- ``sha1`` computed iff ``expected_sha1`` was supplied
  (otherwise None — saves the hash-table-construction cost).
- ``resumed_from`` (0 = fresh download; >0 = bytes already
  present at start).
"""

from __future__ import annotations

import contextlib
import hashlib
import logging
import os
import tempfile
from dataclasses import dataclass
from typing import TYPE_CHECKING

from mgf.http._errors import HttpTransportError

if TYPE_CHECKING:
    from collections.abc import Callable, Mapping
    from pathlib import Path

    from mgf.http._client import AsyncHttpClient

_LOG = logging.getLogger(__name__)

__all__ = [
    "DownloadResult",
    "IntegrityError",
    "download",
]

# Default chunk size: 1 MiB. Tradeoff: smaller = more update granularity
# for on_progress callbacks; larger = fewer syscalls. 1 MiB is the
# common practical sweet spot.
_DEFAULT_CHUNK_SIZE = 1024 * 1024


@dataclass(frozen=True)
class DownloadResult:
    """Outcome of a completed (integrity-verified) download.

    Constructed only on success — callers never see a partial
    DownloadResult. On integrity failure :class:`IntegrityError`
    is raised; on transport failure :class:`HttpTransportError`.
    """

    dest: Path
    """The final path. The file is present + complete + verified."""

    bytes_downloaded: int
    """Total bytes in the final file (partial + post-resume chunk).
    Matches `dest.stat().st_size`."""

    sha256: str
    """Hex digest. Always computed."""

    sha1: str | None
    """Hex digest. Computed iff `expected_sha1` was supplied; else
    ``None``."""

    resumed_from: int
    """How many bytes were already present in the partial at the
    start of this call. ``0`` = fresh download; ``>0`` = resumed
    after a previous attempt."""


class IntegrityError(HttpTransportError):
    """Raised when an integrity check fails.

    Subclass of :class:`HttpTransportError` so consumers wrapping
    in ``except HttpTransportError`` catch it. The
    ``cause_summary`` is ``"integrity-mismatch"``; the
    ``expected`` / ``actual`` digests are on the exception
    instance.
    """

    def __init__(
        self,
        *,
        url: str,
        algorithm: str,
        expected: str,
        actual: str,
    ) -> None:
        super().__init__(
            url=url,
            cause_summary="integrity-mismatch",
            attempts=1,
        )
        self.algorithm = algorithm
        self.expected = expected
        self.actual = actual

    def __str__(self) -> str:
        return (
            f"integrity mismatch on {self.url}: {self.algorithm} expected "
            f"{self.expected}, got {self.actual}"
        )


async def download(
    client: AsyncHttpClient,
    url: str,
    dest: Path,
    *,
    expected_sha256: str | None = None,
    expected_sha1: str | None = None,
    expected_size: int | None = None,
    resume: bool = True,
    chunk_size: int = _DEFAULT_CHUNK_SIZE,
    on_progress: Callable[[int, int | None], None] | None = None,
    headers: Mapping[str, str] | None = None,
) -> DownloadResult:
    """Stream a URL to ``dest`` with optional resume + integrity verification.

    Implementation lives in this module to keep ``_client.py``
    manageable; consumers call ``client.download(...)`` which
    forwards to here.

    See module docstring for the full design rationale.
    """
    if expected_sha256 is not None and len(expected_sha256) != 64:
        raise ValueError(
            f"expected_sha256 must be a 64-char hex digest; got {len(expected_sha256)} chars",
        )
    if expected_sha1 is not None and len(expected_sha1) != 40:
        raise ValueError(
            f"expected_sha1 must be a 40-char hex digest; got {len(expected_sha1)} chars",
        )

    if not dest.parent.is_dir():
        raise FileNotFoundError(
            f"download dest parent directory {dest.parent!r} does not "
            f"exist. We don't create arbitrary directory trees per "
            f"SC-13 — create the parent first.",
        )

    partial_path = dest.with_suffix(dest.suffix + ".partial")
    sha256 = hashlib.sha256()
    sha1 = hashlib.sha1() if expected_sha1 is not None else None  # noqa: S324 — explicit caller opt-in

    # --- Pre-hash any existing partial (resume support) ---
    resumed_from = 0
    if resume and partial_path.exists():
        resumed_from = partial_path.stat().st_size
        if resumed_from > 0:
            _LOG.info(
                "http download resume: hashing %d existing bytes of %r",
                resumed_from,
                str(partial_path),
                extra={
                    "event": "mgf.http.download_resume_hash",
                    "url": url,
                    "partial_bytes": resumed_from,
                },
            )
            with partial_path.open("rb") as fh:
                while True:
                    block = fh.read(chunk_size)
                    if not block:
                        break
                    sha256.update(block)
                    if sha1 is not None:
                        sha1.update(block)
    elif not resume and partial_path.exists():
        # Consumer asked for a fresh download but a partial is here.
        # Truncate it (per the contract: resume=False = always start over).
        partial_path.unlink()

    # --- Fetch the rest ---
    request_headers: dict[str, str] = {}
    if headers:
        request_headers.update(headers)
    if resumed_from > 0:
        request_headers["Range"] = f"bytes={resumed_from}-"

    total_bytes = resumed_from
    if on_progress is not None:
        on_progress(total_bytes, expected_size)

    # Open the partial in append mode. The async-stream loop writes
    # chunks; on success we rename to dest, on failure we leave the
    # partial (consumer can retry with resume=True OR delete it).
    with partial_path.open("ab") as fh:
        async with client.stream("GET", url, headers=request_headers) as response:
            # 206 Partial Content = resume worked; 200 = server sent
            # the whole thing (server doesn't support Range). If 200
            # AND we have a partial, the server is replaying from
            # zero — that's a server-decided invalidation. Reset.
            if response.status_code == 200 and resumed_from > 0:
                _LOG.warning(
                    "http download resume rejected by server (status 200, "
                    "not 206); discarding partial and starting over",
                    extra={
                        "event": "mgf.http.download_resume_rejected",
                        "url": url,
                        "discarded_bytes": resumed_from,
                    },
                )
                # Reset everything: re-init hashers, truncate partial.
                # Re-opening the file handle is the cleanest way to
                # truncate; close + reopen.
                fh.truncate(0)
                fh.seek(0)
                sha256 = hashlib.sha256()
                sha1 = hashlib.sha1() if expected_sha1 is not None else None  # noqa: S324
                total_bytes = 0
                resumed_from = 0
                if on_progress is not None:
                    on_progress(total_bytes, expected_size)
            elif response.status_code not in (200, 206):
                raise HttpTransportError(
                    url=url,
                    cause_summary=f"download-status-{response.status_code}",
                    attempts=1,
                )

            async for chunk in response.aiter_bytes(chunk_size=chunk_size):
                fh.write(chunk)
                sha256.update(chunk)
                if sha1 is not None:
                    sha1.update(chunk)
                total_bytes += len(chunk)
                if on_progress is not None:
                    on_progress(total_bytes, expected_size)

    # --- Verify ---
    actual_sha256 = sha256.hexdigest()
    actual_sha1 = sha1.hexdigest() if sha1 is not None else None

    if expected_size is not None and total_bytes != expected_size:
        raise IntegrityError(
            url=url,
            algorithm="size",
            expected=str(expected_size),
            actual=str(total_bytes),
        )

    if expected_sha256 is not None and actual_sha256.lower() != expected_sha256.lower():
        raise IntegrityError(
            url=url,
            algorithm="sha256",
            expected=expected_sha256,
            actual=actual_sha256,
        )

    if (
        expected_sha1 is not None
        and actual_sha1 is not None
        and (actual_sha1.lower() != expected_sha1.lower())
    ):
        raise IntegrityError(
            url=url,
            algorithm="sha1",
            expected=expected_sha1,
            actual=actual_sha1,
        )

    # --- Atomic rename on success ---
    # Move .partial → dest. Per SC-13 atomic write.
    partial_path.replace(dest)

    _LOG.info(
        "http download complete: %d bytes → %r",
        total_bytes,
        str(dest),
        extra={
            "event": "mgf.http.download_complete",
            "url": url,
            "bytes": total_bytes,
            "resumed_from": resumed_from,
        },
    )

    return DownloadResult(
        dest=dest,
        bytes_downloaded=total_bytes,
        sha256=actual_sha256,
        sha1=actual_sha1,
        resumed_from=resumed_from,
    )


# Suppress unused-import warnings in stub-only mode.
_ = (tempfile, os, contextlib)
