"""Generic toolchain installation helpers shared across framework implementations."""

from collections.abc import Iterable
from contextlib import ExitStack
import hashlib
import io
import json
import logging
import os
from pathlib import Path
import subprocess
import sys
import time
from typing import IO, TYPE_CHECKING

from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.helpers import ProgressBar, rmtree

if TYPE_CHECKING:
    import requests

PathType = str | os.PathLike

_LOGGER = logging.getLogger(__name__)

# Attempts per mirror URL before falling through to the next mirror; only
# mid-stream drops retry (resuming when the server gave a validator),
# connect errors move on to the next mirror immediately.
_MIRROR_ATTEMPTS = 3

# Passes over the whole mirror list when a transient network error is in
# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff).
_MIRROR_SWEEP_ATTEMPTS = 3


def get_project_link_flags() -> list[str]:
    """Return the sorted -Wl, linker flags from the current build."""
    from esphome.core import CORE  # local import to avoid circular dependency

    return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,"))


def get_project_compile_flags() -> list[str]:
    """Return the sorted -D and -W (non-linker) flags from the current build."""
    from esphome.core import CORE  # local import to avoid circular dependency

    return [
        flag
        for flag in sorted(CORE.build_flags)
        if flag.startswith("-D")
        or (flag.startswith("-W") and not flag.startswith("-Wl,"))
    ]


def get_project_cxx_compile_flags() -> list[str]:
    """Return the sorted flags that apply to C++ compiles only."""
    from esphome.core import CORE  # local import to avoid circular dependency

    return sorted(CORE.cxx_build_flags)


def str_to_lst_of_str(a: str | list[str]) -> list[str]:
    """
    Convert a string to a list of string

    Args:
        a: A string containing semicolon-separated values, or an already-split list

    Returns:
        list of strings
    """
    if isinstance(a, list):
        return a
    return [f.strip() for f in a.split(";") if f.strip()]


def rmdir(directory: PathType, msg: str | None = None):
    """
    Remove a directory and its contents recursively if it exists.

    Args:
        directory: Path to the directory to be removed
        msg: Optional debug message to log before removal or it an error occurs

    Returns:
        None

    Raises:
        RuntimeError: If directory removal fails
    """
    if Path(directory).is_dir():
        try:
            if msg:
                _LOGGER.debug(msg)
            rmtree(directory)
        except OSError as e:
            raise RuntimeError(
                f"Error during {msg}: can't remove `{directory}`. Please remove it manually!"
            ) from e


def get_system_python_path() -> str:
    """
    Get the path to the Python executable.

    Returns:
        Path to Python executable as string
    """
    # Try to get PYTHONEXEPATH environment variable
    # Fallback to sys.executable if not set
    return os.environ.get("PYTHONEXEPATH", os.path.normpath(sys.executable))


def get_python_env_executable_path(root: PathType, binary: str) -> Path:
    """
    Get the path to a Python environment executable file.

    Args:
        root: Root directory of the Python environment
        binary: Name of the executable binary

    Returns:
        Path object pointing to the executable file
    """
    if os.name == "nt":
        return Path(root) / "Scripts" / f"{binary}.exe"
    return Path(root) / "bin" / binary


def run_command(
    cmd: list[str],
    msg: str | None = None,
    env: dict[str, str] | None = None,
    stream_output: bool = False,
    cwd: PathType | None = None,
) -> tuple[bool, str | None, str | None]:
    """
    Execute a command and return results.

    Args:
        cmd: list of command arguments
        msg: Optional custom message for logging
        env: Optional dictionary of environment variables to set
        stream_output: If True, inherit parent stdio so the subprocess prints
            directly to the terminal (useful for commands that produce their
            own progress output). stdout/stderr are not captured in this mode.
        cwd: Optional working directory for the subprocess.

    Returns:
        tuple of (success: bool, stdout: str or None, stderr: str or None).
        When stream_output is True, stdout and stderr are always None.
    """
    cmd_str = msg or " ".join(cmd)
    try:
        _LOGGER.debug("%s - running ...", cmd_str)

        run_env = os.environ.copy()
        # Do not leak PYTHONPATH
        run_env.pop("PYTHONPATH", None)
        if env:
            run_env.update(env)

        if stream_output:
            result = subprocess.run(cmd, check=False, env=run_env, cwd=cwd)
            stdout = stderr = None
        else:
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                check=False,
                env=run_env,
                cwd=cwd,
            )
            stdout = result.stdout
            stderr = result.stderr

        if result.returncode != 0:
            if stream_output:
                _LOGGER.error("%s - failed (returncode=%s)", cmd_str, result.returncode)
            else:
                tail = (stderr or stdout or "").strip()[-1000:]
                _LOGGER.error(
                    "%s - failed (returncode=%s). Tail:\n%s",
                    cmd_str,
                    result.returncode,
                    tail,
                )
            return False, stdout, stderr

        _LOGGER.debug("%s - executed successfully", cmd_str)
        return True, stdout, stderr

    except (subprocess.SubprocessError, OSError) as e:
        _LOGGER.error("%s - error: %s", cmd_str, str(e))
        return False, None, None


def run_command_ok(*args, **kwargs) -> bool:
    """
    Execute a command and return only the success status.

    Args:
        *args: Positional arguments to pass to run_command
        **kwargs: Keyword arguments to pass to run_command

    Returns:
        True if command executed successfully, False otherwise
    """
    return run_command(*args, **kwargs)[0]


def create_venv(root: PathType, msg: str | None = None):
    """
    Create a Python virtual environment.

    Args:
        root: Path to the virtual environment directory
        msg: Optional message for logging

    Returns:
        None

    Raises:
        RuntimeError: If virtual environment creation fails
    """
    cmd = [get_system_python_path(), "-m", "venv", "--clear", root]
    if not run_command_ok(cmd, msg=f"Create Python virtual environment for {msg}"):
        raise RuntimeError(f"Can't create Python virtual environment for {msg}")


def _detect_archive_root(names: Iterable[str]) -> str | None:
    """Detect a single top-level directory shared by all archive entries.

    Returns the directory name if every non-empty entry sits under the same
    top-level directory, else ``None``. Extraction helpers use this to strip
    the wrapper directory commonly found in source archives during extraction
    rather than renaming it afterwards — post-extraction renames are
    unreliable on Windows because antivirus and the search indexer briefly
    hold handles on freshly written files.
    """
    root: str | None = None
    has_descendant = False
    for raw in names:
        name = raw.replace("\\", "/").strip("/")
        if not name:
            continue
        first, sep, _ = name.partition("/")
        if root is None:
            root = first
        elif root != first:
            return None
        if sep:
            has_descendant = True
    return root if has_descendant else None


def _tar_extract_all(
    data: io.BufferedIOBase,
    extract_dir: PathType = ".",
    progress_header: str | None = None,
):
    """
    Extract a TAR archive to the specified directory.

    Path-traversal, link, permission and ownership sanitization is delegated to
    the stdlib ``tarfile.data_filter`` (PEP 706). We keep the wrapper-directory
    stripping (no stdlib equivalent) and the absolute-path reject (data_filter's
    check is os.path-dependent and would miss a Windows drive path when
    extracting on POSIX).

    Args:
        data: File-like object containing the TAR archive
        extract_dir: Directory to extract contents to
        progress_header: If set, show a progress bar with this header
    """
    import tarfile

    extract_dir = os.fspath(extract_dir)
    abs_dest = os.path.abspath(extract_dir)  # noqa: PTH100

    with tarfile.open(fileobj=data, mode="r") as tar_ref:
        all_members = tar_ref.getmembers()

        # Detect a single common top-level directory and strip it during
        # extraction so we don't have to flatten it via a rename afterwards.
        strip_root = _detect_archive_root(m.name for m in all_members)
        strip_prefix = f"{strip_root}/" if strip_root is not None else None

        safe_members = []

        for member in all_members:
            # Strip leading slashes, then reject absolute / Windows-drive paths
            name = member.name.lstrip("/" + os.sep)
            if Path(name).is_absolute() or (
                os.name == "nt" and ":" in name.split(os.sep)[0]  # noqa: PTH206
            ):
                continue

            # Strip wrapper directory if one was detected
            if strip_prefix is not None:
                norm = name.replace("\\", "/")
                if norm in (strip_root, strip_prefix):
                    continue
                if not norm.startswith(strip_prefix):
                    continue
                name = norm[len(strip_prefix) :]
            member.name = name

            # Hard-link linknames reference another archive member by its
            # archive name; strip the wrapper prefix here too so
            # tarfile._find_link_target can resolve the target during
            # extraction. Symlink linknames are filesystem-relative paths,
            # not archive-member references, so they don't need this.
            if member.islnk() and strip_prefix is not None:
                norm_link = member.linkname.replace("\\", "/")
                if norm_link in (strip_root, strip_prefix):
                    continue
                if not norm_link.startswith(strip_prefix):
                    continue
                member.linkname = norm_link[len(strip_prefix) :]

            # Delegate traversal, link, permission and ownership sanitization
            # to the stdlib data filter; it raises FilterError for unsafe
            # members (path traversal, links outside dest, special files).
            try:
                member = tarfile.data_filter(member, abs_dest)
            except tarfile.FilterError:
                continue

            safe_members.append(member)

        total = len(safe_members)
        progress = (
            ProgressBar(progress_header) if progress_header and total > 0 else None
        )
        for i, member in enumerate(safe_members, 1):
            tar_ref.extract(member, abs_dest)
            if progress is not None:
                progress.update(i / total)
        if progress is not None:
            progress.update(1)


def _zip_extract_all(
    data: io.BufferedIOBase,
    extract_dir: PathType = ".",
    progress_header: str | None = None,
):
    """
    Extract a ZIP archive to the specified directory.

    Args:
        data: File-like object containing the ZIP archive
        extract_dir: Directory to extract contents to
        progress_header: If set, show a progress bar with this header
    """
    import zipfile

    # See note in _tar_extract_all: os.path is used intentionally for
    # the security-sensitive abspath/commonpath checks below.
    extract_dir = os.path.abspath(extract_dir)  # noqa: PTH100

    with zipfile.ZipFile(data, "r") as zip_ref:
        all_members = zip_ref.infolist()

        # Detect a single common top-level directory and strip it during
        # extraction so we don't have to flatten it via a rename afterwards.
        strip_root = _detect_archive_root(m.filename for m in all_members)
        strip_prefix = f"{strip_root}/" if strip_root is not None else None

        total = len(all_members)
        progress = (
            ProgressBar(progress_header) if progress_header and total > 0 else None
        )

        for i, member in enumerate(all_members, 1):
            # 1. Normalize name
            name = member.filename.lstrip("/\\")

            # 2. Reject absolute paths / Windows drives
            if Path(name).is_absolute() or (
                os.name == "nt" and ":" in name.split(os.sep)[0]  # noqa: PTH206
            ):
                continue

            # 3. Strip wrapper directory if one was detected
            if strip_prefix is not None:
                norm = name.replace("\\", "/")
                if norm in (strip_root, strip_prefix):
                    continue
                if not norm.startswith(strip_prefix):
                    continue
                name = norm[len(strip_prefix) :]

            # 4. Compute safe target path
            target_path = os.path.abspath(os.path.join(extract_dir, name))  # noqa: PTH100, PTH118

            if os.path.commonpath([extract_dir, target_path]) != extract_dir:
                raise ValueError(f"Unsafe path detected: {member.filename}")

            # 5. Assign sanitized name back
            member.filename = name

            # 6. Extract
            zip_ref.extract(member, extract_dir)

            if progress is not None:
                progress.update(i / total)
        if progress is not None:
            progress.update(1)


def _rename_with_retry(
    src: Path, dst: Path, attempts: int = 5, overwrite: bool = False
) -> None:
    """Rename ``src`` to ``dst`` with backoff retries on Windows sharing violations.

    Antivirus/indexer handles on freshly-written files can briefly block
    ``os.rename`` with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED. The
    handle is released within tens of ms in practice, so exponential backoff
    works. With ``overwrite`` an existing ``dst`` is replaced instead of
    failing.
    """
    for i in range(attempts):
        try:
            if overwrite:
                src.replace(dst)
            else:
                src.rename(dst)
            return
        except PermissionError:
            if i == attempts - 1:
                raise
            time.sleep(0.1 * (2**i))


def _7z_extract_all(
    data: io.BufferedIOBase,
    extract_dir: PathType = ".",
    progress_header: str | None = None,
):
    """
    Extract a 7z archive to the specified directory.

    py7zr only supports bulk extraction (no per-member rename hook like
    tarfile/zipfile), so we extract into a unique staging subdir of
    ``extract_dir`` and then move children up. This keeps everything on
    the same volume and sidesteps wrapper-vs-child name collisions
    (e.g. ``arm-zephyr-eabi/`` containing another ``arm-zephyr-eabi/``).

    Args:
        data: File-like object containing the 7z archive (must be seekable)
        extract_dir: Directory to extract contents to
        progress_header: If set, show a progress bar with this header
    """
    import py7zr

    extract_dir = os.path.abspath(extract_dir)  # noqa: PTH100
    Path(extract_dir).mkdir(parents=True, exist_ok=True)

    suffix = 0
    while True:
        staging = Path(extract_dir) / f".extract_tmp_{suffix}"
        if not staging.exists():
            break
        suffix += 1
    staging.mkdir()

    try:
        with py7zr.SevenZipFile(data, "r") as z:
            all_names = z.getnames()

            # Detect a single common top-level directory to flatten.
            strip_root = _detect_archive_root(all_names)

            # Validate names: reject absolute paths, Windows drives, and
            # path traversal. Filter via targets= since py7zr can't rename
            # per-member.
            safe_targets: list[str] = []
            for raw in all_names:
                name = raw.lstrip("/\\")
                if not name:
                    continue
                if Path(name).is_absolute() or (
                    os.name == "nt" and ":" in name.split(os.sep)[0]  # noqa: PTH206
                ):
                    continue
                target_path = os.path.abspath(os.path.join(staging, name))  # noqa: PTH100, PTH118
                if os.path.commonpath([str(staging), target_path]) != str(staging):
                    continue
                safe_targets.append(raw)

            progress = (
                ProgressBar(progress_header)
                if progress_header and safe_targets
                else None
            )

            if len(safe_targets) == len(all_names):
                z.extractall(path=staging)
            else:
                z.extract(path=staging, targets=safe_targets)

            if progress is not None:
                progress.update(1)

        src_root = staging / strip_root if strip_root else staging
        for item in src_root.iterdir():
            dest = Path(extract_dir) / item.name
            if dest.exists():
                if dest.is_dir():
                    rmtree(dest)
                else:
                    dest.unlink()
            _rename_with_retry(item, dest)
    finally:
        # staging is created before the try, so it always exists here; the
        # guard is defensive cleanup and its False branch is unreachable.
        if staging.exists():  # pragma: no cover
            rmtree(staging)


_ARCHIVE_MAGIC_MAP = {
    b"\x1f\x8b\x08": _tar_extract_all,
    b"\x42\x5a\x68": _tar_extract_all,
    b"\xfd\x37\x7a\x58\x5a\x00": _tar_extract_all,
    b"\x50\x4b\x03\x04": _zip_extract_all,
    b"\x37\x7a\xbc\xaf\x27\x1c": _7z_extract_all,
}


def archive_extract_all(
    archive: PathType | io.RawIOBase | IO[bytes],
    extract_dir: PathType = ".",
    progress_header: str | None = None,
):
    """
    Extract an archive file to the specified directory.

    Args:
        archive: Path to archive file or file-like object
        extract_dir: Directory to extract contents to
        progress_header: If set, show a progress bar with this header

    Raises:
        TypeError: If archive is not a valid type
        ValueError: If archive format is unsupported
    """

    with ExitStack() as stack:
        # 1. Handle different archive input types
        archive_ref: io.BufferedIOBase
        if isinstance(archive, (str, os.PathLike)):
            archive_ref = stack.enter_context(Path(archive).open("rb"))
        elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)):
            archive_ref = archive
        elif isinstance(archive, io.RawIOBase):
            archive_ref = io.BufferedReader(archive)
        else:
            raise TypeError(
                f"archive must be str, Path, or file-like object: {type(archive)}"
            )

        # 2. Detect archive format and select appropriate extraction function
        matched_fct = None
        magic_len = max(len(k) for k in _ARCHIVE_MAGIC_MAP)
        header = archive_ref.peek(magic_len)
        for magic, fct in _ARCHIVE_MAGIC_MAP.items():
            if header.startswith(magic):
                matched_fct = fct
                break
        if matched_fct is None:
            raise ValueError("Unsupported archive format")
        matched_fct(archive_ref, extract_dir, progress_header=progress_header)


def _open_ranged(
    url: str, offset: int, timeout: int, validator: str | None = None
) -> tuple["requests.Response | None", int]:
    """Open a streaming GET, asking the server to resume at ``offset``.

    ``validator`` is an ETag or Last-Modified value from the interrupted
    response; it is sent as ``If-Range`` so the server only honors the Range
    when the content is unchanged, replying 200 (full body, restart) if the
    file was replaced between requests — the resumed bytes can then never be
    stitched onto a different file's prefix.

    Returns ``(response, effective_offset)``. The response is None when the
    server answered 416 Range Not Satisfiable: the file holds every byte the
    server has (a previous attempt was interrupted after the last byte), so
    there is nothing to stream and the caller's verification decides whether
    the file is good. The offset drops to 0 when the server ignored the
    ``Range`` header (no 206), meaning the caller must restart the file.
    Raises on connect errors and HTTP error statuses; the response is closed
    on failure.
    """
    import requests

    headers = {"Range": f"bytes={offset}-"} if offset else {}
    if offset and validator:
        headers["If-Range"] = validator
    resp = requests.get(url, stream=True, timeout=timeout, headers=headers)
    if offset and resp.status_code == 416:
        resp.close()
        return None, offset
    if offset and resp.status_code != 206:
        _LOGGER.debug(
            "Server did not resume %s (HTTP %s), restarting", url, resp.status_code
        )
        offset = 0
    if not resp.ok:
        resp.close()
    resp.raise_for_status()
    if offset:
        _LOGGER.info("Resuming download at %d bytes ...", offset)
    return resp, offset


def _verify_file(path: Path, sha256: str | None, size: int | None) -> None:
    """Raise EsphomeError when ``path`` fails an available sha256/size check."""
    from esphome.core import EsphomeError

    if size is not None and path.stat().st_size != size:
        raise EsphomeError(f"size mismatch: expected {size}, got {path.stat().st_size}")
    if sha256 is not None:
        with path.open("rb") as f:
            digest = hashlib.file_digest(f, "sha256").hexdigest()
        if digest != sha256:
            raise EsphomeError(f"sha256 mismatch: got {digest}")


def _load_download_meta(meta: Path, url: str) -> tuple[str | None, int]:
    """Return the ``(validator, total)`` a previous run recorded for ``url``.

    ``(None, 0)`` when there is no sidecar, it is unreadable, or it belongs
    to a different URL (e.g. a different mirror was tried last time).
    """
    try:
        with meta.open(encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return None, 0
    if not isinstance(data, dict) or data.get("url") != url:
        return None, 0
    validator = data.get("validator")
    total = data.get("total")
    return (
        validator if isinstance(validator, str) else None,
        total if isinstance(total, int) else 0,
    )


def _write_download_meta(
    meta: Path, url: str, validator: str | None, total: int
) -> None:
    """Persist resume metadata next to the part file; best-effort.

    Without a validator there is nothing a later run could resume against,
    so any stale sidecar is removed instead.
    """
    try:
        if validator is None:
            meta.unlink(missing_ok=True)
        else:
            meta.write_text(
                json.dumps({"url": url, "validator": validator, "total": total}),
                encoding="utf-8",
            )
    except OSError as e:
        _LOGGER.debug("Could not update download metadata %s: %s", meta, e)


def _content_length(resp: "requests.Response") -> int:
    """Return the response's Content-Length, or 0 when absent or malformed.

    0 means "unknown", which downstream disables the progress bar and the
    resume/completeness logic — a garbage header from a broken proxy must
    degrade to a plain single-stream download, not crash the attempt.
    """
    try:
        return int(resp.headers.get("content-length", 0))
    except ValueError:
        return 0


def _response_validator(resp: "requests.Response") -> str | None:
    """Return the response's strong validator for ``If-Range`` resumes.

    Weak ETags (``W/...``) are not usable for byte-range conditionals, so
    fall back to Last-Modified, or None when the server offers neither.
    """
    etag = resp.headers.get("ETag")
    if etag and not etag.startswith("W/"):
        return etag
    return resp.headers.get("Last-Modified")


def _stream_response_to_file(
    resp: "requests.Response", f: IO[bytes], offset: int, size: int | None = None
) -> None:
    """Stream an open ``_open_ranged`` response body into ``f`` at ``offset``.

    Truncates ``f`` to ``offset`` first, so a server-rejected resume
    (effective offset 0) discards the stale bytes. ``offset`` also seeds the
    progress bar so a resumed download shows overall progress. ``size`` is
    the known full file size; when None it is derived from the response's
    content-length, and without either there is no progress bar.
    """
    f.seek(offset)
    f.truncate(offset)
    total_size = size or offset + _content_length(resp)
    downloaded = offset
    progress = ProgressBar("Downloading") if total_size > 0 else None
    for chunk in resp.iter_content(chunk_size=256 * 1024):
        if chunk:
            f.write(chunk)
            downloaded += len(chunk)
        if progress is not None:
            progress.update(downloaded / total_size)
    if progress is not None:
        progress.update(1)


def download_with_resume(
    url: str,
    dest: PathType,
    sha256: str | None = None,
    size: int | None = None,
    # More attempts than _MIRROR_ATTEMPTS: a single-URL download has no
    # mirror fallback, and each retry only re-fetches the remainder.
    attempts: int = 5,
    timeout: int = 30,
    retry_connect_errors: bool = True,
) -> None:
    """Download ``url`` to ``dest``, resuming partial downloads.

    The body streams into ``<dest>.part``, which persists across attempts and
    esphome runs: a mid-stream connection drop only costs one attempt and the
    next continues from where it stopped, so an unstable connection converges
    on a complete file instead of restarting from zero each retry (#17703).
    When ``size`` / ``sha256`` are given the completed file is verified and a
    mismatch restarts from scratch; success renames the part file into place.
    An already-present ``dest`` that passes verification is kept as-is.

    Resuming a part file from an earlier run needs proof the content is
    unchanged: ``sha256`` when the caller has one, or otherwise the server's
    If-Range validator recorded in a ``<dest>.part.meta`` sidecar by the run
    that started the download — a size alone cannot detect a same-length
    content change on the server.

    With ``retry_connect_errors`` disabled, a failure before any body bytes
    flow (connect error, HTTP error status) propagates immediately instead
    of consuming attempts — for callers with their own fallback, like
    ``download_from_mirrors``.

    Raises EsphomeError when all attempts are exhausted.
    """
    # Imported lazily: requests is a heavy import (~85ms) and is only needed
    # when actually downloading a toolchain, never during config validation.
    import requests

    from esphome.core import EsphomeError

    ensure_happy_eyeballs()

    dest = Path(dest)
    part = dest.with_name(dest.name + ".part")
    meta = part.with_name(part.name + ".meta")
    dest.parent.mkdir(parents=True, exist_ok=True)
    last_error: Exception | None = None

    # An earlier run already completed this download. Only trust it when
    # there is something to verify it against; without sha/size the remote
    # content may have changed (e.g. a refreshed constraints file), so
    # re-download and atomically replace it.
    if dest.is_file() and (sha256 is not None or size is not None):
        try:
            _verify_file(dest, sha256, size)
            return
        except EsphomeError:
            dest.unlink()

    # Adopt the validator/total the run that started this part file recorded,
    # so an unfinished download resumes across runs even without a sha256.
    validator, expected_total = _load_download_meta(meta, url)

    for _ in range(attempts):
        streamed = False
        try:
            offset = part.stat().st_size if part.is_file() else 0
            # A stitched resume needs two proofs: content identity (the
            # bytes being appended belong to the same file as the prefix)
            # and completeness. sha256 provides both, across runs. Without
            # it, identity needs this run's If-Range validator — a size
            # alone cannot detect a same-length content change, so a
            # leftover part file from an earlier run must restart — and
            # completeness needs a known total length.
            if (
                offset
                and sha256 is None
                and (validator is None or not (size or expected_total))
            ):
                _LOGGER.debug(
                    "Restarting %s from zero: cannot prove a resumed "
                    "file correct (no sha256, validator=%s, total=%s)",
                    url,
                    validator is not None,
                    size or expected_total,
                )
                offset = 0
            if size is None or offset < size:
                resp, offset = _open_ranged(url, offset, timeout, validator)
                # A None response means HTTP 416: the part file already holds
                # every byte the server has; fall through to verification.
                if resp is not None:
                    with resp, part.open("ab") as f:
                        streamed = True
                        if offset == 0:
                            validator = _response_validator(resp)
                            expected_total = _content_length(resp)
                            # Recorded so a later run can prove an If-Range
                            # resume of this part file safe.
                            _write_download_meta(meta, url, validator, expected_total)
                        _stream_response_to_file(resp, f, offset, size)
            # else: a previous run already wrote every byte (or more) but
            # was killed before the rename below. Skip the network entirely
            # — a Range request past EOF would draw HTTP 416 — and let
            # verification decide whether to promote the file or discard it
            # and start over.

            expected_size = size if size is not None else expected_total
            _verify_file(part, sha256, expected_size or None)
            if not expected_size and sha256 is None:
                # No sha, no size, and the server sent no usable
                # content-length: nothing can prove the download complete
                # (urllib3 still errors on most short bodies, but not on a
                # cleanly closed chunked stream). Promote with a debug
                # note rather than fail or warn: some servers (e.g. the
                # Espressif constraints host) never send a length, the user
                # can do nothing about it, and every current caller
                # extracts or parses the file afterwards, where corruption
                # fails loudly.
                _LOGGER.debug(
                    "Downloaded %s without any way to verify completeness",
                    dest.name,
                )
            # Retry on Windows sharing violations: an antivirus handle on the
            # freshly-written file must not get the verified download deleted
            # as corrupt by the except clause below. If even the backoff
            # retries fail, keep the verified part so the next attempt (or
            # run) only has to redo the rename, not the download.
            try:
                _rename_with_retry(part, dest, overwrite=True)
            except PermissionError as e:
                _LOGGER.debug("Could not move %s into place: %s", part, e)
                last_error = e
                continue
            meta.unlink(missing_ok=True)
            return
        except requests.RequestException as e:
            # Network failures — including connect errors, since a single
            # URL has no mirror-list fallback — keep the part file for the
            # next attempt (or the next esphome run) to resume from. Checked
            # before OSError: RequestException subclasses IOError.
            if not retry_connect_errors and not streamed:
                # The caller falls back to another URL on pre-body failures.
                raise
            _LOGGER.debug("Download of %s interrupted: %s", url, e)
            last_error = e
        except (OSError, EsphomeError) as e:
            # A completed-but-corrupt file (or local disk error) can't be
            # trusted for resume; start over.
            _LOGGER.debug("Discarding %s: %s", part, e)
            part.unlink(missing_ok=True)
            meta.unlink(missing_ok=True)
            last_error = e

    raise EsphomeError(
        f"Failed to download {url} after {attempts} attempts: "
        f"{_failure_reason(last_error)}"
    ) from last_error


def _failure_reason(e: Exception) -> str:
    """Format a download exception for the aggregated error message.

    ``requests`` appends " for url: <url>" to HTTP errors; the URL is already
    printed on the line above, so strip the suffix to keep lines short. Falls
    back to the repr for exceptions with no message (e.g. ``TimeoutError()``)
    so the line always names the failure.
    """
    return str(e).split(" for url: ", maxsplit=1)[0] or repr(e)


def _spent_attempts_error(e: Exception, attempts: int) -> Exception:
    """Wrap a failure whose mirror already consumed download attempts, so
    the sweep classifies it as permanent."""
    from esphome.core import EsphomeError

    err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}")
    err.__cause__ = e
    return err


def _is_transient_download_error(e: Exception) -> bool:
    """Return True when a download failure is worth retrying.

    Connection-level failures and HTTP 429/5xx are transient. Other HTTP
    errors, local errors, and exhausted-attempts EsphomeError wrappers
    (their per-mirror retries are already spent) are permanent.
    """
    # Imported lazily: requests is a heavy import (~85ms) and is only
    # needed when actually downloading, never during config validation.
    import requests

    if isinstance(e, requests.exceptions.HTTPError):
        resp = e.response
        return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
    return isinstance(
        e,
        (
            requests.exceptions.ConnectionError,
            requests.exceptions.Timeout,
            requests.exceptions.ChunkedEncodingError,
        ),
    )


def _try_mirrors_once(
    urls: list[str],
    path_target: Path | None,
    f: IO[bytes] | None,
    timeout: int,
    failures: list[tuple[str, Exception]],
) -> str | None:
    """Single pass over the resolved mirror ``urls``, one try per URL.

    Returns the source URL on success, or None with each URL's exception
    appended to ``failures``.
    """
    # Imported lazily: requests is a heavy import (~85ms) and is only
    # needed when actually downloading, never during config validation.
    import requests

    from esphome.core import EsphomeError

    for url in urls:
        _LOGGER.debug("Trying to download from %s", url)

        # Path targets delegate to download_with_resume so a partial
        # download persists (and resumes) across esphome runs.
        if path_target is not None:
            try:
                download_with_resume(
                    url,
                    path_target,
                    attempts=_MIRROR_ATTEMPTS,
                    timeout=timeout,
                    # Pre-body failures (connect/HTTP errors) fall to the
                    # next mirror immediately; only mid-stream drops
                    # retry-with-resume on the same URL.
                    retry_connect_errors=False,
                )
                return url
            except (requests.RequestException, OSError, EsphomeError) as e:
                # Everything download_with_resume classifies as a download
                # failure; programming errors propagate.
                _LOGGER.debug("Failed to download %s: %s", url, str(e))
                failures.append((url, e))
                continue

        # File-like targets download here; mid-stream failures retry the
        # same mirror with resume (see download_with_resume) instead of
        # starting over. There is no checksum to verify a resumed file
        # against, so a stitch is only trusted when the server proves
        # consistency: the If-Range validator guarantees 206 only for
        # unchanged content, and the expected total length (when the first
        # response carried one) guards against short or shifted bodies.
        # Without a validator the retry restarts from zero.
        offset = 0
        expected_total = 0
        validator = None
        for attempt in range(_MIRROR_ATTEMPTS):
            try:
                resp, offset = _open_ranged(url, offset, timeout, validator)
            except (requests.RequestException, OSError) as e:
                # Connect/HTTP error, no bytes flowed — next mirror. Wrap
                # when earlier attempts were already spent on this mirror.
                _LOGGER.debug("Failed to download %s: %s", url, str(e))
                failures.append(
                    (url, _spent_attempts_error(e, attempt + 1) if attempt else e)
                )
                break

            try:
                # A None response means HTTP 416: the file already holds
                # every byte the server has (a drop after the last byte);
                # only the length check below remains.
                if resp is not None:
                    with resp:
                        if offset == 0:
                            validator = _response_validator(resp)
                            expected_total = _content_length(resp)
                        _stream_response_to_file(resp, f, offset)

                if expected_total and f.tell() != expected_total:
                    raise EsphomeError(
                        f"size mismatch: expected {expected_total}, got {f.tell()}"
                    )
                if not expected_total:
                    # Same trust decision as download_with_resume's
                    # unverifiable promotion; surface it at the same level.
                    _LOGGER.debug(
                        "Downloaded %s without any way to verify completeness",
                        url,
                    )

                _LOGGER.debug("Downloaded successfully from: %s", url)

                # Reset file pointer and return
                f.seek(0)
                return url

            except (requests.RequestException, OSError, EsphomeError) as e:
                # Mid-stream drop: keep the received bytes and retry this
                # mirror from the current position — but only when the
                # server gave a validator to resume against safely AND a
                # total length to prove the stitched file complete (the
                # length check above is the only verification here).
                _LOGGER.debug("Failed to download %s: %s", url, str(e))
                if validator and expected_total:
                    offset = f.tell()
                else:
                    _LOGGER.debug(
                        "Restarting %s from zero: cannot prove a "
                        "resumed file complete (validator=%s, total=%s)",
                        url,
                        validator is not None,
                        expected_total,
                    )
                    offset = 0
                if attempt == _MIRROR_ATTEMPTS - 1:
                    failures.append((url, _spent_attempts_error(e, _MIRROR_ATTEMPTS)))

    return None


def download_from_mirrors(
    mirrors: list[str],
    substitutions: dict[str, str],
    target: io.RawIOBase | IO[bytes] | PathType,
    timeout: int = 30,
) -> str:
    """
    Download file from multiple mirrors with substitution support.

    Args:
        mirrors: list of mirror URLs
        substitutions: Dictionary of substitutions to apply to URLs
        target: Target file path or file-like object
        timeout: Download timeout in seconds

    Returns:
        The source URL.

    Mirror URL templates that reference a substitution not present in
    ``substitutions`` are skipped, so callers can offer templates that only
    apply to some downloads.

    A path target downloads through ``download_with_resume``, so an
    interrupted download resumes on the next esphome run; a file-like target
    only resumes mid-stream drops within this call.

    When every mirror fails and at least one failure is transient (dropped
    connection, timeout, HTTP 429/5xx), the whole list is retried with a
    short backoff; permanent failures (e.g. 404) raise immediately.

    Raises:
        ValueError: If mirrors list is empty.
        EsphomeError: If all download attempts fail; the message lists every
            attempted URL with its individual failure reason. Also raised if
            no template matched the provided substitutions.
    """
    from esphome.core import EsphomeError

    ensure_happy_eyeballs()

    # 1. Classify the target: filesystem path or open file object
    path_target: Path | None = None
    f: IO[bytes] | None = None
    if isinstance(target, (str, os.PathLike)):
        path_target = Path(target)
    elif isinstance(target, (io.RawIOBase, io.IOBase)):
        f = target
    else:
        raise TypeError(
            f"target must be str, Path, or file-like object: {type(target)}"
        )

    # 2. Resolve the mirror templates (invariant across retry sweeps)
    urls: list[str] = []
    skipped: list[tuple[str, str]] = []
    for mirror in mirrors:
        try:
            urls.append(mirror.format(**substitutions))
        except KeyError as e:
            # The template references a substitution not provided for
            # this download (e.g. SHORT_VERSION only exists for x.y.0
            # versions) - expected, the template just doesn't apply.
            _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e)
            skipped.append((mirror, f"not applicable ({e.args[0]} not available)"))
        except (IndexError, ValueError) as e:
            # A malformed template (unbalanced braces, bad format spec)
            # is an authoring error, not an expected fallthrough - warn
            # even if a later mirror succeeds.
            _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e)
            skipped.append((mirror, f"skipped ({e!r})"))

    # 3. Sweep the mirror list, retrying transient failures with backoff:
    # a single pass keeps mirror failover fast, re-sweeping keeps one
    # network blip from failing the build when only one mirror applies.
    failures: list[tuple[str, Exception]] = []
    for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1):
        sweep_failures: list[tuple[str, Exception]] = []
        if (
            url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures)
        ) is not None:
            return url
        failures.extend(sweep_failures)
        # Permanent failures (404, verification mismatch) won't heal;
        # only retry when a transient error is in the mix (as git.py does).
        transient = next(
            ((u, e) for u, e in sweep_failures if _is_transient_download_error(e)),
            None,
        )
        if transient is None:
            break
        if sweep < _MIRROR_SWEEP_ATTEMPTS:
            delay = 2**sweep
            _LOGGER.warning(
                "Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)",
                transient[0],
                _failure_reason(transient[1]),
                delay,
                sweep + 1,
                _MIRROR_SWEEP_ATTEMPTS,
            )
            time.sleep(delay)

    # 4. Report every attempted URL if all mirrors failed. failures spans
    # all sweeps (deduplicated by URL and reason), so neither an early
    # mirror's failure nor an earlier sweep's failure mode is hidden.
    if failures:
        seen: set[tuple[str, str]] = set()
        attempts = ""
        for url, e in failures:
            reason = _failure_reason(e)
            if (url, reason) not in seen:
                seen.add((url, reason))
                attempts += f"\n  {url}\n    {reason}"
        attempts += "".join(f"\n  {mirror}\n    {reason}" for mirror, reason in skipped)
        raise EsphomeError(
            f"Failed to download from all mirrors:{attempts}"
        ) from failures[0][1]
    if skipped:
        details = "".join(f"\n  {mirror}\n    {reason}" for mirror, reason in skipped)
        raise EsphomeError(
            f"No mirror URL template matched the provided substitutions:{details}"
        )
    raise ValueError("download_from_mirrors called with an empty mirrors list")
