"""Runtime banner and linked-library version reporter.

Printed at the top of any serious vibe-qc text output so a user can always
identify exactly which build of the program produced a given log.

Programmatic API
----------------

``banner()``
    Return the full multi-line banner as a string.

``print_banner()``
    Convenience: ``print(banner(), **kwargs)``.

``library_versions()``
    Dict of the versions of linked native dependencies (libint, libxc,
    spglib) plus the package version. Useful in tests and bug reports.

``build_info()``
    Dict describing the current source build: git branch, short commit
    SHA, working-tree dirty flag, and exact-tag (if any). Empty when
    running from an installed wheel without a ``.git/`` available.

The ``format_scf_trace()`` helper in :mod:`vibeqc.scf_log` prepends the
banner automatically so a persisted SCF run log carries its provenance —
critical for matching a calculation result back to the exact build that
produced it (see ``docs/release_process.md``).
"""

from __future__ import annotations

import os
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
from typing import Mapping, TextIO

# Package version — reads the installed metadata so it tracks pyproject.toml
# automatically at build/install time. Falls back to an explicit label if the
# package isn't found (e.g. running from a source tree without installation).
try:
    from importlib.metadata import PackageNotFoundError, version as _pkg_version
except ImportError:  # pragma: no cover — Python 3.11+ always ships this
    _pkg_version = None
    PackageNotFoundError = Exception  # type: ignore[misc, assignment]


__all__ = [
    "RELEASE_CODENAMES",
    "VIBEQC_VERSION",
    "banner",
    "build_info",
    "codename_for_version",
    "library_versions",
    "print_banner",
]


# --- Release codename catalog --------------------------------------------
#
# Every major + minor release carries a "Scientist's Animal" codename —
# see docs/roadmap.md § "Release codenames" for the full policy. The
# catalog is keyed by the X.Y.Z release version; minor releases (X.Y.0)
# are always present, and patch releases (X.Y.Z, Z > 0) MAY add their
# own override here if they have a distinct theme worth surfacing
# separately (e.g. v0.7.1 "Pulay's Triangle", v0.7.2 "Boys' Crucible",
# v0.7.3 "Whitten's Bridge" all override v0.7.0 "Löwdin's Compass").
# Patch versions WITHOUT an entry inherit the parent minor's codename
# via the fallback in :func:`codename_for_version`. Dev builds
# (.devN / aN / bN / rcN) inherit the codename of the upcoming release
# after PEP-440 suffix stripping.
#
# When cutting a new MINOR release (X.Y.0), add the entry here in the
# same commit that bumps ``pyproject.toml``. Patches need an entry
# here only if they get a distinct codename; otherwise they inherit.
#
# This catalog is also imported by ``docs/conf.py`` to render the
# landing-page "Current release" tip and the codename in MyST
# substitutions ({{codename}}). One source of truth, two consumers.
RELEASE_CODENAMES: dict[str, str] = {
    "0.1.0": "First Light",          # bootstrap tag; pre-codename era
    "0.4.0": "Schrödinger's Llama",  # retroactively assigned for fun
    "0.5.0": "Wilson's Otter",       # vibrations / Hessian flagship
                                     # (E. B. Wilson 1955)
    "0.6.0": "Pulay's Owl",          # periodic atomic gradients —
                                     # Pulay correction terms (1969)
                                     # are the conceptual core of
                                     # this release; Pulay also gave
                                     # us DIIS (1980) which underpins
                                     # the SCF stack the gradients
                                     # build on. Owl: nocturnal,
                                     # sees what others miss
                                     # (sub-meV displacement-derived
                                     # forces).
    "0.7.0": "Löwdin's Compass",     # periodic SCF correctness
                                     # flagship: linear-dependence
                                     # diagnostic + screening
                                     # optimiser + Lehtola pivoted
                                     # Cholesky + transparent
                                     # primitive filtering. Per-Olov
                                     # Löwdin gave us canonical
                                     # orthogonalisation (Adv. Quantum
                                     # Chem. 5, 185, 1970) and the
                                     # symmetric S^{-½} that anchors
                                     # this release's overlap-matrix
                                     # work. Compass: navigates the
                                     # basis-set / cutoff space and
                                     # finds the loosest PSD-keeping
                                     # configuration without silent
                                     # truncation. Aligns with
                                     # CRYSTAL's "refuse to silently
                                     # truncate; push the problem to
                                     # basis-set design" philosophy.
    "0.7.1": "Pulay's Triangle",     # cross-code parity layer:
                                     # automated regression suite
                                     # (examples/regression/) extended
                                     # to molecules + DF + open-shell
                                     # + MP2 + B3LYP/G; vibe-qc, PySCF
                                     # and ORCA driven from a single
                                     # spec and Δ-compared per case.
                                     # Peter Pulay invented DIIS (1980)
                                     # which makes molecular SCF
                                     # converge tightly enough for
                                     # µHa cross-code parity, the
                                     # Pulay-Vosko RI / density-fitting
                                     # framework (1990) that anchors
                                     # the DF layer, and the analytical-
                                     # gradient theory the suite will
                                     # exercise next. Triangle: the
                                     # three-way reference geometry
                                     # vibe-qc / PySCF / ORCA.
    "0.7.3": "Whitten's Bridge",     # bridge the v0.7.2 DF feature
                                     # into the molecular regression
                                     # runner: `density_fit=True` +
                                     # `aux_basis=...` (with autodetect
                                     # via `default_aux_basis_for`)
                                     # threaded through the SCF and
                                     # post-SCF MP2 paths. John L.
                                     # Whitten introduced
                                     # density-fitting / RI in 1973
                                     # (J. Chem. Phys. 58, 4496),
                                     # giving the field the trick that
                                     # makes def2-style RI auxiliary
                                     # bases tractable. Bridge: the
                                     # (case, code) wiring from spec to
                                     # vibe-qc DF kernel that this
                                     # patch closes — and the bug
                                     # discovered by closing it
                                     # (def2-svp DF SCF SIGSEGVs in
                                     # `run_rhf`, surfaced cleanly via
                                     # the v0.7.2 subprocess-isolation
                                     # layer rather than bricking the
                                     # whole suite).
    "0.10.0": "Pisani's Penguin",    # periodic-SCF maturity:
                                     # multi-k EDIIS + Python
                                     # accelerator family; SYM6
                                     # crystal symmetry (full
                                     # character tables + M3b per-
                                     # cell-pair J/K kernel); BIPOLE
                                     # L_max>=2 Cartesian->spherical
                                     # multipole; GPW M2-full
                                     # iterative SCF; CRYSTAL-alpha /
                                     # Ewald-omega gauge unification
                                     # (cluster-A1 fix at 859efe0a);
                                     # end-to-end CRYSTAL parity test
                                     # suite (P1-P5). Alongside the
                                     # periodic-SCF spine: a
                                     # semiempirical-methods
                                     # diversification (GFN2-xTB,
                                     # PM6, OM1/2/3 in addition to
                                     # DFTB), DFT+U for periodic
                                     # Gamma-only RHF/RKS (Increments
                                     # 1+2+4), native NEB transition-
                                     # state search with density
                                     # warm-start, PWPB95 double
                                     # hybrid, periodic D3-BJ, slab
                                     # + adsorbate builder, relaxed
                                     # scans, QVF spec v1.1. Cesare
                                     # Pisani — founder of CRYSTAL,
                                     # the canonical all-electron
                                     # Gaussian-orbital periodic-SCF
                                     # code that vibe-qc's BIPOLE /
                                     # multi-k Ewald / crystal-
                                     # symmetry arc descends from.
                                     # Penguin: lives at extremes
                                     # (solid-state, low-T,
                                     # structured); unmatched in
                                     # the cold-crystalline regime
                                     # even if it waddles in the
                                     # gas phase. Periodic GDF
                                     # deferred to v0.11.0 (fourth
                                     # cycle: v0.8.0 -> v0.9.0 ->
                                     # v0.10.0 -> v0.11.0).
    "0.9.0": "Knowles's Kingfisher", # wavefunction methods — FCI /
                                     # Selected-CI / DMRG / v2RDM via
                                     # vibeqc.solvers — plus native
                                     # D4 dispersion (MPL-2.0), the
                                     # semiempirical DFTB structure-
                                     # optimization stack (DFTB0 /
                                     # SCC-DFTB across 87 elements),
                                     # M-series crystal symmetry, and
                                     # the BIPOLE periodic driver
                                     # (Gamma-only). Peter Knowles —
                                     # the Knowles-Handy determinant-
                                     # driven Full-CI algorithm
                                     # (1984) — is the conceptual
                                     # core of vibe-qc's FCI solver.
                                     # Kingfisher: a precise, fast
                                     # hunter that catches its exact
                                     # prey — exact diagonalisation.
    "0.8.0": "Grimme's Gecko",       # molecular methods polish:
                                     # B2PLYP + DSD-PBEP86-D4 double
                                     # hybrids, D4 dispersion via dftd4,
                                     # EEQ atomic-charge model
                                     # (Caldeweyher/Grimme D4a-i,
                                     # Apache-2.0 port), direct SCF for
                                     # all four molecular drivers,
                                     # RIJCOSX performance closure
                                     # vs ORCA, Lebedev-Laikov angular
                                     # grids + Stratmann-Scuseria-Frisch
                                     # atomic partition, B3LYP-VWN5
                                     # convention alignment, libecpint
                                     # banner integration, vqfetch
                                     # external-data Phase 1. Stefan
                                     # Grimme's group's lineage runs
                                     # through every major v0.8.0
                                     # methods deliverable: D3(BJ) → D4
                                     # → EEQ; the B2PLYP family (2006);
                                     # DSD-PBEP86 (Kozuch/Martin/Grimme);
                                     # composite "3c" methods (queued
                                     # for v0.9.0). Gecko: small, fast,
                                     # surfaces-stick — the mol-methods
                                     # arc that ships an entire class
                                     # of well-validated post-HF methods
                                     # built on a clean dispersion +
                                     # double-hybrid foundation.
    "0.7.2": "Boys' Crucible",       # density-fitting flagship:
                                     # 2c + 3c integral kernels +
                                     # DensityFitting object + def2 /
                                     # cc-pV*Z / Pople-RIFIT aux
                                     # libraries + DF wired into RHF /
                                     # UHF / RKS / UKS / MP2 / UMP2
                                     # energy and J / K analytic
                                     # gradients. S. F. Boys (1911-1972)
                                     # introduced Gaussian-type orbitals
                                     # in 1950 (Proc. R. Soc. A 200, 542),
                                     # the foundation that makes RI /
                                     # density-fitting numerically
                                     # tractable: the exp(-α r²)
                                     # primitives admit Gaussian-product
                                     # 2- and 3-centre overlap and
                                     # Coulomb integrals in closed form.
                                     # Crucible: per-case subprocess
                                     # isolation in the regression
                                     # suite — every (case, code) pair
                                     # runs in its own contained
                                     # subprocess so a C-level crash
                                     # (PySCF segfault, ORCA non-zero
                                     # exit, libxc abort) is captured
                                     # as a single error row instead of
                                     # bricking the whole dispatcher.
}


def _strip_dev_suffix(version: str) -> str:
    """Return the release version a dev build is heading toward, by
    stripping any ``.devN``, ``aN``, ``bN``, ``rcN`` (PEP 440)
    pre-release suffix. ``"0.5.0.dev0"`` → ``"0.5.0"``."""
    for sep in (".dev", "a", "b", "rc"):
        idx = version.find(sep)
        if idx > 0 and version[idx - 1].isdigit():
            return version[:idx]
    return version


def codename_for_version(version: str) -> str | None:
    """Return the codename for a given vibe-qc release version, or
    ``None`` if no codename is assigned.

    Resolution order:

      1. Strip ``.devN`` / ``aN`` / ``bN`` / ``rcN`` pre-release
         suffix (PEP 440 dev builds inherit the upcoming release's
         codename).
      2. Direct lookup of the resulting ``X.Y.Z`` in
         :data:`RELEASE_CODENAMES`.
      3. Fall back to the parent minor's codename: look up
         ``f"{X}.{Y}.0"``. So v0.4.1 / v0.4.2 / ... automatically
         inherit v0.4.0's codename (matches the policy in
         ``docs/roadmap.md § Release codenames``: "Patch releases
         inherit their parent minor's codename").
      4. ``None`` if no codename is registered for the minor.

    Examples
    --------
    >>> codename_for_version("0.5.0")
    "Wilson's Otter"
    >>> codename_for_version("0.5.0.dev0")
    "Wilson's Otter"
    >>> codename_for_version("0.4.1")          # patch inherits v0.4.0
    "Schrödinger's Llama"
    >>> codename_for_version("99.99.99") is None
    True
    """
    stripped = _strip_dev_suffix(version)
    if stripped in RELEASE_CODENAMES:
        return RELEASE_CODENAMES[stripped]
    parts = stripped.split(".")
    if len(parts) >= 2:
        anchor = f"{parts[0]}.{parts[1]}.0"
        if anchor in RELEASE_CODENAMES:
            return RELEASE_CODENAMES[anchor]
    return None


def _compute_version() -> str:
    """Read the installed distribution metadata. The distribution is
    named ``vibe-qc`` in ``pyproject.toml`` since the rename; older
    local installs may still be listed as the legacy ``vibeqc``, so we
    try both."""
    if _pkg_version is None:
        return "0.0.0+unknown"
    for dist_name in ("vibe-qc", "vibeqc"):
        try:
            return _pkg_version(dist_name)
        except PackageNotFoundError:
            continue
    return "0.0.0+unknown"


VIBEQC_VERSION: str = _compute_version()


def _blas_backend_label(libraries: str) -> str:
    """Map the raw BLAS_LIBRARIES string from CMake's FindBLAS to a
    short human-readable backend label for the banner.

    Examples:
        "-framework Accelerate"            -> "Accelerate"
        "/usr/lib/libopenblas.so"          -> "OpenBLAS"
        "/usr/lib/libblas.so.3.12.0"       -> "netlib BLAS"
        "/opt/intel/.../libmkl_rt.so"      -> "MKL"
        ""                                 -> "none"

    The label is informational — it falls back to the raw library
    name when no pattern matches, so a build against an unexpected
    BLAS still gets a meaningful banner line.
    """
    if not libraries:
        return "none"
    haystack = libraries.lower()
    # Order matters: more specific patterns first.
    if "accelerate" in haystack:
        return "Accelerate"
    if "mkl" in haystack:
        return "MKL"
    if "openblas" in haystack:
        return "OpenBLAS"
    if "blis" in haystack:
        return "BLIS"
    if "atlas" in haystack:
        return "ATLAS"
    if "libblas" in haystack or "/blas" in haystack:
        return "netlib BLAS"
    # Unknown backend — return the basename so the user sees *something*
    # concrete in the banner instead of "unknown".
    first = libraries.split(";")[0].strip()
    if first.startswith("-framework "):
        return first[len("-framework "):]
    return os.path.basename(first) or first


def library_versions() -> Mapping[str, str]:
    """Return the version strings of vibe-qc itself and its native
    dependencies. Keys are always present; values are ``"unknown"``
    if a probe fails, ``"none"`` when the dependency was not linked
    at build / install time.

    Core (always linked at build time): ``libint``, ``libxc``,
    ``spglib``, ``libecpint``, ``fftw3``, ``blas``.

    Optional (linked at runtime only when the ``[dispersion]``
    extra is installed): ``dftd3``, ``dftd4``. Each is set to
    ``"none"`` when the corresponding PyPI package isn't installed
    in the user's environment — distinguishes "not installed" from
    "probe failed" (the latter would be ``"unknown"``).

    The ``"vibe-qc"`` key carries the project version. The legacy
    ``"vibeqc"`` key is kept as an alias so code that predates the
    rename keeps working without change.

    The ``"blas"`` key carries a short backend label (Accelerate /
    OpenBLAS / MKL / netlib BLAS / …) decorated with ``" +LAPACKE"``
    when EIGEN_USE_LAPACKE is enabled, so the banner conveys both
    the linkage and whether dense solvers delegate to LAPACK.
    """
    versions: dict[str, str] = {
        "vibe-qc": VIBEQC_VERSION,
        "vibeqc":  VIBEQC_VERSION,   # alias, retained for back-compat
    }

    # Probe the compiled extension. Each of the libraries exposes a
    # version accessor; every version is known at link time.
    try:
        from . import _vibeqc_core as _core
    except ImportError:
        versions.update({"libint": "unknown", "libxc": "unknown",
                         "spglib": "unknown", "fftw3": "unknown",
                         "blas": "unknown"})
        return versions

    try:
        versions["libint"] = _core.libint_version()
    except Exception:
        versions["libint"] = "unknown"

    try:
        versions["libxc"] = getattr(_core, "libxc_version",
                                    lambda: "unknown")()
    except Exception:
        versions["libxc"] = "unknown"

    try:
        versions["spglib"] = _core.spglib_version()
    except Exception:
        versions["spglib"] = "unknown"

    # libecpint version probe — added with the v0.8.0 banner / libecpint
    # coupled fix (prep doc § 488-501). libecpint has been bundled since
    # v0.4.0 (commit add8163) but the banner didn't surface its version
    # until v0.8.0. Defensive: an older _core lacking libecpint_version
    # falls back to "unknown" rather than failing the whole library
    # probe.
    try:
        versions["libecpint"] = getattr(_core, "libecpint_version",
                                        lambda: "unknown")()
    except Exception:
        versions["libecpint"] = "unknown"

    # FFTW3 version probe — FFTW3 has been a required build dep since
    # the FFT-Poisson Ewald long-range Hartree solver landed; the GAPW
    # route (v0.10.x) is the second consumer. Banner coverage added per
    # CLAUDE.md § 6 alongside the M1 PW-basis infrastructure. Defensive
    # getattr: an older _core lacking fftw3_version falls back to
    # "unknown".
    try:
        versions["fftw3"] = getattr(_core, "fftw3_version",
                                    lambda: "unknown")()
    except Exception:
        versions["fftw3"] = "unknown"

    # BLAS info was added with the BLAS+LAPACK linkage commit; an
    # older C extension predating that change won't have blas_info.
    # Treat that as "unknown" so the banner remains parseable across
    # builds.
    try:
        _info = _core.blas_info()
        if not _info.get("blas_enabled"):
            versions["blas"] = "none"
        else:
            label = _blas_backend_label(_info.get("libraries", ""))
            if _info.get("lapacke_enabled"):
                label = f"{label} +LAPACKE"
            versions["blas"] = label
    except Exception:
        versions["blas"] = "unknown"

    # Optional dispersion backends (dftd3, dftd4). Both ship as PyPI
    # wheels with bundled binary libraries (libdftd3 / libdftd4) that
    # vibe-qc links to at runtime via ctypes/cffi when the user calls
    # vibeqc.compute_d3 / vibeqc.compute_d4. CLAUDE.md § 6 mandates
    # banner coverage for every linked native dep; these are linked
    # *optionally* (extra `[dispersion]`) so the value is "none" when
    # the package is absent rather than "unknown" — distinguishes
    # "not installed" from "failed to probe".
    for pkg in ("dftd3", "dftd4"):
        try:
            module = __import__(pkg)
            versions[pkg] = str(getattr(module, "__version__", "unknown"))
        except ImportError:
            versions[pkg] = "none"
        except Exception:
            versions[pkg] = "unknown"

    return versions


# --- Build-provenance probe -----------------------------------------------
#
# When vibe-qc runs from an editable install or directly from a source
# checkout, we want the banner to record exactly which git revision is
# in play — branch name, short SHA, dirty-flag, and exact-tag (for
# tagged releases). When running from a wheel that has no ``.git/``
# alongside it, all of these probes return None and the banner falls
# back to the package version alone.
#
# This matters because users will run calculations against specific
# branches for testing (``release``, ``main``, or topic branches), and
# the only way to match a misbehaving SCF log to the build that
# produced it is to record the SHA in the log itself.
#
# Probes are cached at module-import time to avoid repeated subprocess
# calls; ``build_info.cache_clear()`` is available for tests.

def _find_git_root(start: Path) -> Path | None:
    """Walk upward from ``start`` looking for a ``.git`` directory or
    ``.git`` worktree-link file. Returns the parent directory of the
    first match, or None if we hit the filesystem root."""
    p = start.resolve()
    while p != p.parent:
        if (p / ".git").exists():
            return p
        p = p.parent
    return None


def _git(repo: Path, *args: str) -> str | None:
    """Run a git command in ``repo`` and return stripped stdout, or
    None on any failure (no git installed, broken repo, command
    rejected). Never raises."""
    try:
        out = subprocess.check_output(
            ["git", *args],
            cwd=str(repo),
            stderr=subprocess.DEVNULL,
            text=True,
            timeout=2.0,
        )
        return out.strip() or None
    except (subprocess.CalledProcessError, FileNotFoundError,
            subprocess.TimeoutExpired, OSError):
        return None


@lru_cache(maxsize=1)
def build_info() -> Mapping[str, str | bool | None]:
    """Return git provenance for the current build, or empty mapping if
    no ``.git/`` is available (e.g. installed wheel).

    Keys (always present, may be None / False):

    - ``branch``  : current branch name, or ``"HEAD"`` for detached.
    - ``sha``     : 7-character abbreviated commit SHA.
    - ``sha_full``: full 40-character SHA.
    - ``dirty``   : True if the working tree has uncommitted changes.
    - ``tag``     : exact tag at HEAD, e.g. ``"v0.4.1"``; None if not
                    on a tag.
    - ``is_release``: True if the current commit is exactly tagged AND
                      the working tree is clean. This is the only state
                      where the banner displays as "Release X.Y.Z".

    Empty dict (``{}``) when no git context exists at all.
    """
    repo = _find_git_root(Path(__file__).parent)
    if repo is None:
        return {}

    # Allow CI / packaging to override these values without spawning git.
    # Useful when building wheels from a tarball where .git is absent.
    env_branch = os.environ.get("VIBEQC_BUILD_BRANCH")
    env_sha = os.environ.get("VIBEQC_BUILD_SHA")
    env_tag = os.environ.get("VIBEQC_BUILD_TAG")

    sha_full = env_sha or _git(repo, "rev-parse", "HEAD")
    sha = sha_full[:7] if sha_full else None

    branch = env_branch or _git(repo, "rev-parse", "--abbrev-ref", "HEAD")
    if branch == "HEAD":
        # Detached HEAD: try to identify by tag or short ref name.
        branch = _git(repo, "describe", "--all", "--exact-match") or "HEAD"

    tag = env_tag
    if tag is None:
        # ``--exact-match`` returns non-zero when the current commit isn't
        # tagged; _git swallows that and returns None, which is the
        # correct "not a release" signal.
        tag = _git(repo, "describe", "--tags", "--exact-match")

    dirty_status = _git(repo, "status", "--porcelain")
    dirty = bool(dirty_status)

    is_release = bool(tag) and not dirty

    return {
        "branch":     branch,
        "sha":        sha,
        "sha_full":   sha_full,
        "dirty":      dirty,
        "tag":        tag,
        "is_release": is_release,
    }


_COPYRIGHT_LINE = "© Michael F. Peintinger · MPL 2.0  ·  https://vibe-qc.com"


def _version_descriptor() -> str:
    """Compose the line that identifies the current build:

    * Tagged release, clean tree    →  "Release v0.5.0 \"Wilson's Otter\""
    * Dev build, branch + sha       →  "dev 0.5.0.dev0 \"Wilson's Otter\" (main @ abc1234)"
    * Dev build, dirty tree         →  "dev 0.5.0.dev0 \"Wilson's Otter\" (main @ abc1234, dirty)"
    * Wheel install (no git info)   →  "vibe-qc 0.5.0 \"Wilson's Otter\""
    * Pre-codename releases         →  no quoted codename suffix
      (e.g. "Release v0.4.1", "dev 0.4.0.dev0 (main @ abc1234)")

    The codename is bound to the release version; dev builds against
    an upcoming release inherit its codename so the banner is fun
    even on a working tree. See ``_RELEASE_CODENAMES``.
    """
    info = build_info()
    codename = codename_for_version(VIBEQC_VERSION)
    cn_suffix = f' "{codename}"' if codename else ""

    if not info:
        return f"vibe-qc {VIBEQC_VERSION}{cn_suffix}"

    if info.get("is_release"):
        return f"Release {info['tag']}{cn_suffix}"

    branch = info.get("branch") or "HEAD"
    sha = info.get("sha") or "unknown"
    suffix = ", dirty" if info.get("dirty") else ""
    return f"dev {VIBEQC_VERSION}{cn_suffix} ({branch} @ {sha}{suffix})"


def banner(*, width: int = 72) -> str:
    """Return the full banner as a single string, no trailing newline.

    ``width`` controls the horizontal extent; defaults to a terminal-
    friendly 72 characters. The banner is pure ASCII + box-drawing
    characters so it renders cleanly in any modern UTF-8 terminal.
    """
    versions = library_versions()
    libs = (
        f"libint {versions['libint']} · "
        f"libxc {versions['libxc']} · "
        f"spglib {versions['spglib']} · "
        f"libecpint {versions['libecpint']} · "
        f"fftw3 {versions['fftw3']} · "
        f"blas {versions['blas']}"
    )
    # Optional dispersion backends. Hidden when both are absent —
    # users without the `[dispersion]` extra installed see the
    # unchanged classic banner. When at least one is installed the
    # banner gains a second linkage line.
    disp_parts = []
    if versions.get("dftd3", "none") != "none":
        disp_parts.append(f"dftd3 {versions['dftd3']}")
    if versions.get("dftd4", "none") != "none":
        disp_parts.append(f"dftd4 {versions['dftd4']}")
    disp_line = " · ".join(disp_parts) if disp_parts else None

    descriptor = _version_descriptor()
    header = f"{descriptor}  —  Quantum chemistry for molecules and solids"
    libs_line = f"linked: {libs}"

    # Adjust width if any line is wider than requested — banner should
    # never truncate content. Compose each row's full text BEFORE the
    # width calc so prefixes ("linked: ", "dispersion: ") are
    # accounted for. Before v0.8.1: width used `len(libs) + 4` but the
    # actual row is f"linked: {libs}" (8 chars longer). When libs was
    # the longest source, width was undersized by 8 → _row pad went
    # negative → closing ║ jammed up against the last char on the libs
    # row. Surfaced once libecpint joined the linked: line and OpenBLAS
    # +LAPACKE pushed libs past header in length on typical builds.
    row_widths = [
        len(header) + 4,
        len(_COPYRIGHT_LINE) + 4,
        len(libs_line) + 4,
    ]
    if disp_line is not None:
        row_widths.append(len(f"dispersion: {disp_line}") + 4)
    width = max(width, *row_widths)

    bar = "═" * (width - 2)
    top = f"╔{bar}╗"
    bot = f"╚{bar}╝"

    def _row(text: str) -> str:
        pad = (width - 2) - len(text)
        return f"║ {text}{' ' * (pad - 1)}║"

    rows = [
        top,
        _row(header),
        _row(_COPYRIGHT_LINE),
        _row(libs_line),
    ]
    if disp_line is not None:
        rows.append(_row(f"dispersion: {disp_line}"))
    rows.append(bot)
    return "\n".join(rows)


def print_banner(*, file: TextIO = sys.stdout, width: int = 72) -> None:
    """Print the banner to ``file`` (default: stdout)."""
    print(banner(width=width), file=file)
