"""The release seal: the one file that pairs this core with one engine build.

seal.json is generated by scripts/make_seal.py in the Firefox source repo's
release pipeline, published as a release asset, and copied verbatim into this
package by scripts/roll_seal.py. DO NOT EDIT IT BY HAND.

Everything downstream is derived from it: the archive names, the cache
directory name, the spoofed User-Agent, this package's pip-visible version,
and the launch-time engine check.

Two facts about real releases shape the schema, both measured off the five
published firefox-18 archives on 2026-07-25:

  * The legs are independent CI builds, so they carry different
    application.ini BuildIDs (they do agree, and must agree, on Version). The
    BuildID therefore lives in the PER-ASSET record: a client verifies the leg
    it actually runs. A single top-level build_id refused every launch that was
    not on the platform whose leg happened to be copied into it.
  * The Linux archives ship the juggler UNPACKED at chrome/juggler/ and carry
    no omni.ja at all (scripts/linux_release.sh tars the pre-package dist/bin
    layout). Provenance is therefore read from EITHER layout, and a missing
    omni.ja is never by itself a pass: the marker count is what decides.
"""
from __future__ import annotations

import hashlib
import json
import os
import platform as _platform
import re
import sys
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional, Tuple

# 1 -> 2 (2026-07-25): BuildID moved into the per-asset record. A schema-1 seal
# cannot be read forward, because its single BuildID matches at most one leg
# (see SUPPORTED_LEGS below) and there is no way to recover the others from it.
SUPPORTED_SEAL_SCHEMA = 2

# The legs we publish: the (platform, arch) pairs an asset can be downloaded
# for. THE ONE DECLARATION - the refusal in download.py reads it, and so does
# the release gate (scripts/roll_seal.py), which until 2026-08-26 only counted:
# EXPECTED_ASSETS = 5, a hand-written number that the first three-leg release
# would have failed on, and which could not see WHICH platform was missing
# anyway. A count cannot tell "three right legs" from "three legs, one of them
# downloadable by nobody".
# The two macOS ones went out on 2026-08-26; seals already published still
# carry them and stay readable as history.
SUPPORTED_LEGS = (("linux", "arm64"), ("linux", "x86_64"), ("win32", "x86_64"))
SUPPORTED_PLATFORMS = tuple(dict.fromkeys(p for p, _ in SUPPORTED_LEGS))

#: Hosts with no asset of their own that run another leg's, by a guarantee of
#: the operating system and not by hope. Windows 11 on ARM runs x64 programs
#: through its emulator, so an ARM64 Python - the one uv installs on those
#: machines - gets the x86_64 engine. Until 2026-09-25 that host was refused
#: with `NotImplementedError: no asset for platform=win32 arch=arm64` and no
#: remedy, while the engine it could have run was one line away. Declared
#: here, in one place, and measured by the `arm64-host` job of ci.yml on a
#: `windows-11-arm` runner, which launches that engine from an ARM64 Python.
#: A native asset for a host, if one is ever published, wins over this.
EMULATED_LEGS = {("win32", "arm64"): "x86_64"}
READABLE_SEAL_SCHEMAS = (2,)
SEAL_FILE_ENV = "INVISIBLE_SEAL_FILE"
STAMP_NAME = ".invisible-seal.json"

# The shape of a RELEASE tag. Anything else is a local seal's tag: `python -m
# invisible_core seal` defaults to "local", and a user naming their own build is
# expected. That is a supported state, not an error - see Seal.contract_n.
RELEASE_TAG_RE = re.compile(r"^firefox-(\d+)$")

# The one remedy line printed whenever this package itself is the thing that has
# to move. It must stay an INDEX install: `git+https://...` installs a PEP 508
# direct reference, which carries no version specifier, so it silently defeats
# the `invisible-core==N.N.N` every consumer declares and leaves `pip check`
# with nothing to compare (measured: a violated direct reference reports
# healthy).
CORE_INSTALL_HINT = "pip install --upgrade invisible-core"
CORE_REINSTALL_HINT = "pip install --force-reinstall invisible-core"

# Provenance: the four juggler entries. Every build we ship carries at least one
# of the two marker strings in each of them (measured 2026-07-25: PageHandler.js
# -> "stealthfox"; TargetRegistry.js, content/main.js, content/Runtime.js ->
# "zoom.stealth"). A stock build carries neither in any.
# validate_release.py requires 4/4 before publishing; the runtime tolerates a
# drop to 2/4 so a partial juggler restructure cannot brick every user at once.
# The paths are the same in both layouts: inside omni.ja (win, macOS) and loose
# under the tree root (linux), which is why one tuple serves both.
JUGGLER_ENTRIES = (
    "chrome/juggler/content/protocol/PageHandler.js",
    "chrome/juggler/content/TargetRegistry.js",
    "chrome/juggler/content/content/main.js",
    "chrome/juggler/content/content/Runtime.js",
)
JUGGLER_DIR_REL = "chrome/juggler"
JUGGLER_MARKERS = (b"stealthfox", b"zoom.stealth")
JUGGLER_MIN_MARKED = 2

DEFAULT_ENTRY_REL = {
    "win32": "firefox.exe",
    "linux": "firefox",
    "darwin": "Firefox.app/Contents/MacOS/firefox",
}


class SealError(RuntimeError):
    """The seal itself is missing, unreadable, or of an unsupported schema."""


class SealMismatch(RuntimeError):
    """Something asked for a build this core is not sealed to."""


class EngineMismatch(RuntimeError):
    """An on-disk engine does not match the seal. Never launch it.

    The rendered message is for a human reading a traceback. Every caller that
    shows a SHORT reason instead - the adoption log line, and until its
    2026-08-18 deletion the profile manager's status pill - reads `.summary` or
    `.problems`, never an index into the rendered text. Two of them used to do
    `args[0].splitlines()[3]`, which lands on the `engine says: Firefox X build
    Y` line: an observation that reads like a success, not the problem, and one
    line away from moving again the next time the layout is edited.
    """

    def __init__(self, message: str, *, problems: Tuple[str, ...] = (),
                 entry: Optional[Path] = None, seal_tag: str = "") -> None:
        super().__init__(message)
        # Why the engine was refused, in the order engine_problems() found them.
        self.problems: Tuple[str, ...] = tuple(problems)
        self.entry = entry
        self.seal_tag = seal_tag

    @property
    def summary(self) -> str:
        """One line, naming the PROBLEM. Safe to render in a UI."""
        if not self.problems:
            first = str(self.args[0]).splitlines()[0].strip() if self.args else ""
            return first or self.__class__.__name__
        rest = len(self.problems) - 1
        return f"{self.problems[0]} (+{rest} more)" if rest else self.problems[0]


def normalize_arch(machine: str) -> str:
    m = (machine or "").lower()
    if m in {"amd64", "x86_64"}:
        return "x86_64"
    if m in {"arm64", "aarch64"}:
        return "arm64"
    raise NotImplementedError(f"unsupported arch: {machine}")


def _host_arch() -> Optional[str]:
    try:
        return normalize_arch(_platform.machine())
    except NotImplementedError:
        return None


@dataclass(frozen=True)
class Asset:
    name: str
    platform: str
    arch: str
    sha256: str
    size: int
    entry_rel: str
    omni_sha256: str
    # This leg's application.ini BuildID. Every leg is its own CI run, so this is
    # the authority for the launch-time check, never a seal-wide scalar.
    build_id: str = ""


@dataclass(frozen=True)
class Seal:
    tag: str
    upstream_version: str
    source_commit: str
    playwright_min: str
    playwright_max: str
    assets: Dict[str, Asset]
    digest: str
    origin: str
    # Top-level "build_id", present only on a seal with no assets (one tree, one
    # build). A release seal MUST NOT carry one: there is no single value that is
    # true for every leg in SUPPORTED_LEGS, and a scalar that is true for one
    # platform is a refusal for all the others.
    build_id_declared: str = ""

    @property
    def contract_n(self) -> int:
        """N of the `firefox-N` release this seal pairs with, or 0 for none.

        0 means "not a numbered release contract": a local seal (the `python -m
        invisible_core seal` default tag is "local"), or any tag that is not
        firefox-N. It NEVER raises, because constants.py evaluates it at import
        time: the old `int(self.tag.split("-")[1])` turned the escape hatch this
        package prints in its own refusal text - generate a local seal, export
        INVISIBLE_SEAL_FILE - into an IndexError on `import invisible_core`.
        Following our own documented advice broke the package.
        """
        m = RELEASE_TAG_RE.match(self.tag)
        return int(m.group(1)) if m else 0

    @property
    def is_release(self) -> bool:
        """True when this seal names a published firefox-N release.

        The tag test, not the assets test: a seal built by --from-tree against a
        release checkout can be tagged firefox-N and still carry no assets.
        """
        return bool(RELEASE_TAG_RE.match(self.tag))

    @property
    def is_local(self) -> bool:
        return not self.assets

    @property
    def build_id(self) -> str:
        """The BuildID for THIS host: display, log lines and the cache-dir name.

        Informational only. Verification never goes through here, because the
        tree being verified is not always the host's platform; it uses
        expected_build_ids() with the tree's own platform.
        """
        ids = self.expected_build_ids(platform_key=sys.platform, arch=_host_arch())
        if len(ids) == 1:
            return ids[0]
        return self.build_id_declared or (ids[0] if ids else "")

    def expected_build_ids(self, *, platform_key: Optional[str] = None,
                           arch: Optional[str] = None) -> Tuple[str, ...]:
        """Every BuildID this seal accepts for a tree of the given platform/arch.

        Narrowed as far as the caller can say: an exact leg when the platform and
        the arch are both known, that platform's legs when only the platform is,
        and the whole release when the seal has no leg for it at all (a
        cross-platform inspection of a sealed release, e.g. checking a Linux tree
        from a Windows workbench). A seal with no assets falls back to its
        declared top-level value, which is the local/--from-tree case.
        """
        if not self.assets:
            return (self.build_id_declared,) if self.build_id_declared else ()
        cands = [a for a in self.assets.values() if a.build_id]
        if platform_key is not None:
            same_plat = [a for a in cands if a.platform == platform_key]
            if same_plat:
                cands = same_plat
                if arch is not None:
                    exact = [a for a in cands if a.arch == arch]
                    if exact:
                        cands = exact
        return tuple(sorted({a.build_id for a in cands}))

    def asset_for(self, platform_key: str, machine: str) -> Asset:
        arch = normalize_arch(machine)
        for want in (arch, EMULATED_LEGS.get((platform_key, arch))):
            if want is None:
                continue
            for a in self.assets.values():
                if a.platform == platform_key and a.arch == want:
                    return a
        raise NotImplementedError(
            f"seal {self.tag} has no asset for platform={platform_key} arch={arch}")

    def entry_rel_for(self, platform_key: str) -> str:
        for a in self.assets.values():
            if a.platform == platform_key:
                return a.entry_rel
        rel = DEFAULT_ENTRY_REL.get(platform_key)
        if rel is None:
            raise NotImplementedError(f"no binary entry for platform {platform_key}")
        return rel

    def describe(self) -> str:
        bid = self.build_id
        build = f" build {bid}" if bid else ""
        # Say which kind of seal this is. A local seal can verify the tree it was
        # generated from but can never download anything, and every line that
        # shows a seal (doctor, the substitution notice, and until its
        # 2026-08-18 deletion the profile manager's status) should make that
        # visible rather than let it be inferred from a tag.
        kind = "" if self.assets else ", local seal"
        return (f"{self.tag} (Firefox {self.upstream_version}{build}, "
                f"seal {self.digest[:12]}{kind})")


def _parse_seal_bytes(raw: bytes, origin: str) -> Seal:
    try:
        data = json.loads(raw.decode("utf-8"))
    except Exception as e:
        raise SealError(f"seal at {origin} is not valid JSON: {e}") from e
    schema = int(data.get("schema", 0))
    if schema not in READABLE_SEAL_SCHEMAS:
        readable = ", ".join(str(s) for s in READABLE_SEAL_SCHEMAS)
        why = ""
        if schema < SUPPORTED_SEAL_SCHEMA:
            why = (" A schema-1 seal carries one BuildID for every platform leg "
                   "it covers, and those legs are separate CI builds; it cannot "
                   "be read forward, because the BuildIDs of the other legs are "
                   "simply not in it. Regenerate it (scripts/make_seal.py, or "
                   "`python -m invisible_core seal` for a local one).")
        raise SealError(
            f"seal at {origin} has schema {schema}, this invisible-core reads "
            f"{readable}.{why} Upgrade invisible-core: {CORE_INSTALL_HINT}")
    assets = {}
    for name, a in (data.get("assets") or {}).items():
        build_id = (a.get("build_id") or "").strip()
        if not build_id:
            raise SealError(
                f"seal at {origin}: asset {name!r} carries no build_id. Since schema 2 the "
                f"per-asset BuildID is the authority for the launch-time check (the legs "
                f"this build publishes are separate CI builds, each with its own BuildID), "
                f"so an asset without one would verify against nothing. Regenerate the seal with "
                f"scripts/make_seal.py.")
        assets[name] = Asset(
            name=name, platform=a["platform"], arch=a["arch"], sha256=a["sha256"],
            size=int(a.get("size") or 0), entry_rel=a["entry_rel"],
            omni_sha256=a.get("omni_sha256") or "", build_id=build_id,
        )
    declared = (data.get("build_id") or "").strip()
    if not assets and not declared:
        raise SealError(
            f"seal at {origin} has neither assets nor a top-level build_id, so there is no "
            f"BuildID to verify any engine against. Regenerate it with scripts/make_seal.py.")
    pw = data.get("playwright") or {}
    return Seal(
        tag=data["tag"], upstream_version=data["upstream_version"],
        source_commit=data.get("source_commit") or "",
        playwright_min=pw.get("min") or "", playwright_max=pw.get("max") or "",
        assets=assets, digest=hashlib.sha256(raw).hexdigest(), origin=origin,
        build_id_declared=declared,
    )


def packaged_seal_path() -> Path:
    return Path(__file__).with_name("seal.json")


def load_seal(path: "str | os.PathLike[str]") -> Seal:
    p = Path(path)
    try:
        raw = p.read_bytes()
    except OSError as e:
        raise SealError(
            f"cannot read the release seal at {p}: {e}. A packaged invisible-core always "
            f"ships one; reinstall it: {CORE_REINSTALL_HINT}") from e
    return _parse_seal_bytes(raw, str(p))


_ACTIVE: Optional[Seal] = None


def active_seal() -> Seal:
    """The seal this process runs under. Resolved once, at first use.

    INVISIBLE_SEAL_FILE substitutes a different seal. It is NOT an off switch:
    the substituted seal drives the User-Agent too, so the claim always moves
    with the engine. It always announces itself on stderr.
    """
    global _ACTIVE
    if _ACTIVE is None:
        override = os.environ.get(SEAL_FILE_ENV)
        if override:
            s = load_seal(override)
            print(f"invisible-core: substituted seal in effect: {s.describe()} "
                  f"[{SEAL_FILE_ENV}={override}]", file=sys.stderr)
        else:
            s = load_seal(packaged_seal_path())
        _ACTIVE = s
    return _ACTIVE


def _reset_active_seal_for_tests() -> None:
    global _ACTIVE
    _ACTIVE = None


# ---------------------------------------------------------------- engine facts

@dataclass(frozen=True)
class EngineIdentity:
    entry: Path
    root: Path
    version: Optional[str]
    build_id: Optional[str]
    milestone: Optional[str]
    platform_build_id: Optional[str]
    juggler_present: bool
    marked_entries: int
    notes: Tuple[str, ...]
    # "omni.ja" (win/macOS packaged tree), "loose" (the Linux archives, which
    # ship chrome/juggler/ unpacked and no omni.ja), or "none".
    juggler_layout: str = "none"


def resource_root(entry: "str | os.PathLike[str]") -> Path:
    """Directory holding application.ini / platform.ini / omni.ja for `entry`.

    Flat next to the executable on win32 and linux; Contents/Resources on
    darwin (browser/installer/Makefile.in sets RESPATH to the Resources dir
    only for cocoa). Derived from the executable we were handed, so it works
    for an arbitrary binary_path= tree, not just our cache layout.
    """
    p = Path(entry)
    parent = p.parent
    if parent.name == "MacOS" and (parent.parent / "Resources").is_dir():
        return parent.parent / "Resources"
    return parent


def _parse_ini(path: Path) -> Dict[str, Dict[str, str]]:
    out: Dict[str, Dict[str, str]] = {}
    sect = ""
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return out
    for raw in text.splitlines():
        line = raw.strip()
        if not line or line[0] in ";#":
            continue
        if line.startswith("[") and line.endswith("]"):
            sect = line[1:-1].strip()
            out.setdefault(sect, {})
            continue
        if "=" in line:
            k, _, v = line.partition("=")
            out.setdefault(sect, {})[k.strip()] = v.strip()
    return out


def _platform_key_for_entry(entry: Path) -> Optional[str]:
    """Which platform's leg this tree is, read off the executable we were handed.

    Needed because the BuildID now lives per leg: verifying a tree means
    comparing it against ITS leg, which is not always the host's (the release
    gate checks a Linux tarball from a Windows workbench).
    """
    p = Path(entry)
    if p.suffix.lower() == ".exe":
        return "win32"
    if p.parent.name == "MacOS" and p.parent.parent.name == "Contents":
        return "darwin"
    if p.name == "firefox":
        return "linux"
    return None


def _read_juggler_provenance(root: Path) -> Tuple[str, bool, int, list]:
    """(layout, juggler_present, marked_entries, notes) for a tree.

    Two shipped layouts, one marker semantic. Windows and macOS pack the juggler
    into omni.ja; the Linux archives ship it loose under the tree root and carry
    no omni.ja at all. Either way the answer is "how many of the four known
    entries carry a stealth marker", and no absence is ever a pass.
    """
    notes: list = []
    omni = root / "omni.ja"
    if omni.exists():
        try:
            with zipfile.ZipFile(omni) as zf:
                names = set(zf.namelist())
                present = any(n.startswith(JUGGLER_DIR_REL + "/") for n in names)
                marked = 0
                for e in JUGGLER_ENTRIES:
                    if e in names and any(m in zf.read(e) for m in JUGGLER_MARKERS):
                        marked += 1
            return ("omni.ja", present, marked, notes)
        except Exception as e:  # truncated / not a zip
            notes.append(f"omni.ja unreadable ({e})")
            return ("omni.ja", False, 0, notes)

    jdir = root / "chrome" / "juggler"
    if jdir.is_dir():
        marked = 0
        found = 0
        for e in JUGGLER_ENTRIES:
            try:
                blob = (root / Path(e)).read_bytes()
            except OSError:
                continue
            found += 1
            if any(m in blob for m in JUGGLER_MARKERS):
                marked += 1
        present = found > 0 or any(p.is_file() for p in jdir.rglob("*"))
        return ("loose", present, marked, notes)

    notes.append(f"no omni.ja and no {JUGGLER_DIR_REL}/ directory in {root}")
    return ("none", False, 0, notes)


_IDENT_CACHE: Dict[tuple, EngineIdentity] = {}


def read_engine_identity(entry: "str | os.PathLike[str]") -> EngineIdentity:
    """What the engine says about itself. One ini read plus one zip central
    directory read (measured 7-12 ms warm on Windows), memoized per process."""
    p = Path(entry)
    try:
        st = p.stat()
    except OSError as e:
        raise EngineMismatch(
            f"no executable at {p}\n"
            f"  (nothing to verify: the path does not exist or is not readable: {e})",
            problems=(f"no executable at {p} ({e})",), entry=p)
    key = (str(p), st.st_mtime_ns, st.st_size)
    hit = _IDENT_CACHE.get(key)
    if hit is not None:
        return hit

    root = resource_root(p)
    notes = []
    app = _parse_ini(root / "application.ini")
    plat = _parse_ini(root / "platform.ini")
    if not app:
        notes.append(f"no readable application.ini in {root}")
    if not plat:
        notes.append(f"no readable platform.ini in {root}")

    layout, juggler_present, marked, jnotes = _read_juggler_provenance(root)
    notes.extend(jnotes)

    ident = EngineIdentity(
        entry=p, root=root,
        version=app.get("App", {}).get("Version"),
        build_id=app.get("App", {}).get("BuildID"),
        milestone=plat.get("Build", {}).get("Milestone"),
        platform_build_id=plat.get("Build", {}).get("BuildID"),
        juggler_present=juggler_present, marked_entries=marked,
        notes=tuple(notes), juggler_layout=layout,
    )
    _IDENT_CACHE[key] = ident
    return ident


def expected_build_ids_for(ident: EngineIdentity, seal: Seal,
                           asset: Optional[Asset] = None) -> Tuple[str, ...]:
    """The BuildIDs `seal` accepts for the tree `ident` describes.

    Exact when the caller already knows which leg it fetched (every download,
    adoption and status route does, and passes it). Otherwise resolved from the
    tree's OWN platform, read off the executable.

    Deliberately not narrowed by the host's arch: the tree's arch is not
    knowable from the files being read, and narrowing by the host's would make
    the same tree verify differently on different machines. The exact-leg answer
    is available wherever the arch is actually known, through `asset`.
    """
    if asset is not None and asset.build_id:
        return (asset.build_id,)
    plat_key = _platform_key_for_entry(ident.entry) or sys.platform
    return seal.expected_build_ids(platform_key=plat_key)


def _build_id_problem(label: str, got: Optional[str], expected: Tuple[str, ...]) -> Optional[str]:
    if not expected or got in expected:
        return None
    want = expected[0] if len(expected) == 1 else "one of " + ", ".join(expected)
    return f"{label} BuildID {got!r} != {want}"


def engine_problems(ident: EngineIdentity, seal: Seal,
                    asset: Optional[Asset] = None) -> list:
    probs = list(ident.notes)
    if ident.version != seal.upstream_version:
        probs.append(f"application.ini Version {ident.version!r} != {seal.upstream_version!r}")
    # One leg, one CI build, one BuildID: the tree is checked against the leg it
    # IS, not against a seal-wide scalar (which matched one platform and refused
    # every other).
    expected = expected_build_ids_for(ident, seal, asset)
    p = _build_id_problem("application.ini", ident.build_id, expected)
    if p:
        probs.append(p)
    if ident.milestone is not None and ident.milestone != seal.upstream_version:
        probs.append(f"platform.ini Milestone {ident.milestone!r} != {seal.upstream_version!r}")
    if ident.platform_build_id is not None:
        p = _build_id_problem("platform.ini", ident.platform_build_id, expected)
        if p:
            probs.append(p)
    # Three layout values, three branches. This was
    #     "omni.ja" if ident.juggler_layout != "loose" else "the unpacked tree"
    # which folded `none` in with `omni.ja`, so a tree carrying NEITHER produced
    # "no chrome/juggler/ in omni.ja" two lines below a note saying there is no
    # omni.ja at all. The block contradicted itself, and this is the message a
    # user pastes into an issue when their engine will not drive.
    where = {
        "omni.ja": "omni.ja",
        "loose": "the unpacked tree",
        "none": "this tree (which has no omni.ja either)",
    }.get(ident.juggler_layout, f"this tree (layout {ident.juggler_layout!r})")
    if not ident.juggler_present:
        probs.append(f"no {JUGGLER_DIR_REL}/ in {where} (Playwright cannot drive this build)")
    elif ident.marked_entries < JUGGLER_MIN_MARKED:
        probs.append(
            f"{where} carries the stealth marker in only {ident.marked_entries}/"
            f"{len(JUGGLER_ENTRIES)} juggler entries (a stock build scores 0): this is not "
            f"one of our patched builds")
    return probs


def verify_engine(entry: "str | os.PathLike[str]", seal: Optional[Seal] = None,
                  *, source: str, asset: Optional[Asset] = None) -> Path:
    """Refuse to hand back `entry` unless the engine matches `seal`.

    Called on every route that turns a path into a running Firefox. Returns the
    path so it can wrap an expression in place. `asset`, when the caller knows
    which leg it fetched, pins the BuildID comparison to that exact leg.

    The path handed back is the one the FILE SYSTEM says the executable is at,
    not the one it was asked about, because that is the path a launch needs.
    Under an MSIX host (Claude Desktop, the Microsoft Store Python) writes into
    AppData are silently redirected into the package's own LocalCache folder:
    the process that downloaded the engine sees it at the AppData path, but
    nothing is there on disk. CreateProcessW accepts that path and then fails
    with ERROR_SXS_CANT_GEN_ACTCTX (14001), because Windows resolves the
    `mozglue` assembly that firefox.exe declares against the directory as it
    really is, outside the redirection. Retail Firefox fails identically when
    installed that way, so this is not about our archive. `os.path.realpath`
    asks the file system through the handle and returns the LocalCache path,
    from which the same engine starts. Reported as invisible_playwright
    discussion #256 on 2026-09-25, and issue #22 in May was the same failure
    under the Store Python.

    It sits HERE, and not in the downloader, because this is the one function
    both routes cross: the cached engine from `ensure_binary` and a caller's
    own `binary_path=`. Anywhere else would need a second copy of the same fact.
    """
    seal = seal or active_seal()
    ident = read_engine_identity(entry)
    probs = engine_problems(ident, seal, asset)
    if not probs:
        return Path(os.path.realpath(entry))
    expected = expected_build_ids_for(ident, seal, asset)
    want = " or ".join(expected) if expected else "?"
    lines = [
        "engine/seal mismatch - refusing to launch",
        f"  executable : {ident.entry}",
        f"  origin     : {source}",
        f"  engine says: Firefox {ident.version or '?'} build {ident.build_id or '?'}",
        f"  seal says  : Firefox {seal.upstream_version} build {want} "
        f"(tag {seal.tag}, seal {seal.digest[:12]}, from {seal.origin})",
    ]
    lines += [f"  problem    : {p}" if i == 0 else f"               {p}"
              for i, p in enumerate(probs)]
    lines += [
        "  why        : the prefs and the spoofed User-Agent this package ships describe",
        "               the sealed build. Running a different engine under them is a",
        "               disagreement a page can observe.",
        # Deliberately the CORE's own command, not a consumer's. This module is
        # shared, and naming one consumer's CLI here would hand readers of any
        # other consumer a command they cannot run - true when this was written
        # against two consumers (invisible-firefox did not depend on
        # invisible-playwright) and still the right shape with one, because the
        # core does not get to assume it is only ever imported by the consumer
        # that happens to exist today. `doctor --fix` clears the cached tree and
        # re-fetches the sealed one, and it exists wherever this message can be
        # printed.
        f"  fix        : python -m invisible_core doctor --fix",
        "               (deliberately driving another build? generate a seal for it:",
        "                python -m invisible_core seal --binary <path> -o my.seal.json",
        "                and export INVISIBLE_SEAL_FILE=my.seal.json - the User-Agent",
        "                then moves with it, so the pair stays coherent)",
    ]
    # The reason travels as DATA. Callers that print one line read .summary; the
    # rendered text above is then free to change without silently changing what
    # they show (it did: an index chosen for one layout pointed at "engine says",
    # an observation that reads like a success).
    raise EngineMismatch("\n".join(lines), problems=tuple(probs),
                         entry=ident.entry, seal_tag=seal.tag)


# ------------------------------------------------------------------- the stamp

def stamp_path(version_dir: Path) -> Path:
    return Path(version_dir) / STAMP_NAME


def read_stamp(version_dir: Path) -> Optional[dict]:
    try:
        return json.loads(stamp_path(version_dir).read_text(encoding="utf-8"))
    except Exception:
        return None


def write_stamp(version_dir: Path, seal: Seal, *, asset: str,
                asset_sha256: Optional[str], adopted: bool) -> None:
    """Written LAST, after everything else is in place, and atomically."""
    import time
    from ._version import __version__ as core_version
    sealed_asset = seal.assets.get(asset)
    payload = {
        "schema": SUPPORTED_SEAL_SCHEMA,
        "seal_digest": seal.digest,
        "tag": seal.tag,
        "upstream_version": seal.upstream_version,
        # The BuildID of the leg that produced THIS tree, not a seal-wide one.
        "build_id": (sealed_asset.build_id if sealed_asset else "") or seal.build_id,
        "asset": asset,
        "asset_sha256": asset_sha256,
        "adopted": adopted,
        "written_by": f"invisible-core {core_version}",
        "written_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    }
    dst = stamp_path(version_dir)
    tmp = dst.with_suffix(".tmp")
    tmp.write_text(json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8")
    os.replace(tmp, dst)
