"""Atomic, durable file writes — body + optional parent-dir fsync.

The canonical helper for sites whose data is load-bearing across
power failure. The vault credential store and Fernet key file
(:mod:`mgf.vm.vault.keyring`) are the load-bearing callers — see
``docs/archive/2026/RELIABILITY_AND_ACCURACY_2026-05-14.md`` RA-01 for the
finding that motivated this module.

For **schema-versioned** satellite stores (anything with a
document-root ``version:``) prefer
:class:`mgf.common.versioned.VersionedFileStore` — that's the
upstream parallel of this helper plus version stamping + migration
support. Use ``atomic_write`` directly only for files that aren't
schema-versioned (the Fernet key, the encrypted vault blob, ad-hoc
binary state).

The write sequence::

    fd, tmp = tempfile.mkstemp(parent_of_target)   # same filesystem
    write(payload)
    flush()
    fsync(fd)              # body durable
    close(fd)
    chmod(tmp, mode)       # honoured before the rename
    os.replace(tmp, path)  # atomic on POSIX + Windows ≥ Vista
    if fsync_dir:
        fsync(parent_dir_fd)   # rename itself durable

If anything fails before the rename, the temp file is removed (best
effort) and the exception propagates. After a successful rename the
target file's mode is exactly the requested ``mode`` (mkstemp's
default 0o600 is overwritten by the explicit ``os.chmod`` so callers
that pass ``mode=0o644`` get what they asked for).

``fsync_dir`` is opt-in because it costs an extra fsync; pay it only
when a lost rename actually matters. The vault and the
``VersionedFileStore`` config-rewrite path turn it on; the
crash-reporter-style "one lost report is acceptable" callers leave
it off.
"""

from __future__ import annotations

import contextlib
import os
import tempfile
from pathlib import Path

__all__ = ["atomic_write"]


def atomic_write(
    path: Path,
    content: bytes | str,
    *,
    mode: int = 0o600,
    fsync_dir: bool = False,
    encoding: str = "utf-8",
) -> None:
    """Write ``content`` to ``path`` atomically and durably.

    Parameters
    ----------
    path:
        Target file. Its parent directory is created with
        ``mkdir(parents=True, exist_ok=True)`` if needed; the temp
        file is created **inside** the parent so the final
        ``os.replace`` is a same-filesystem atomic rename.
    content:
        ``bytes`` (written as-is) or ``str`` (encoded with
        ``encoding``, default UTF-8).
    mode:
        Final file mode applied via ``os.chmod`` **before** the
        rename. Default ``0o600``.
    fsync_dir:
        When ``True``, fsync the parent directory after the rename so
        the rename itself survives a power loss. Default ``False`` —
        opt in for files whose lost-rename is load-bearing (vault,
        config). Best-effort on platforms or filesystems that reject
        ``os.fsync`` on a directory fd; failures are silently ignored
        rather than raised.
    encoding:
        Used only when ``content`` is a string. Default ``utf-8``.
    """
    path = Path(path)
    parent = path.parent
    parent.mkdir(parents=True, exist_ok=True)

    payload = content.encode(encoding) if isinstance(content, str) else content

    fd, tmp_str = tempfile.mkstemp(
        prefix=f".{path.name}.",
        suffix=".tmp",
        dir=str(parent),
    )
    tmp = Path(tmp_str)
    moved = False
    try:
        with os.fdopen(fd, "wb") as fh:
            fh.write(payload)
            fh.flush()
            os.fsync(fh.fileno())
        # mkstemp creates the file at 0o600 by default. Re-chmod to the
        # caller's requested mode so the visible file (post-rename)
        # carries exactly that mode regardless of umask.
        #
        # NOTE: ``os.chmod`` + ``os.replace`` (rather than ``Path.chmod``
        # + ``Path.replace``) are deliberate — they are the spy
        # hookpoints the durability tests in
        # ``tests/unit/test_atomic_io.py::TestFsync`` monkeypatch via
        # ``mgf.vm.common.atomic_io.os.*``. Switching to the ``Path``
        # forms would break the test seam without changing behaviour.
        os.chmod(tmp, mode)  # noqa: PTH101 — deliberate, see NOTE above
        os.replace(tmp, path)  # noqa: PTH105 — deliberate, see NOTE above
        moved = True
    finally:
        if not moved:
            # Best-effort cleanup of the temp file on any failure
            # between mkstemp and replace.
            with contextlib.suppress(OSError):
                tmp.unlink()

    if fsync_dir:
        _fsync_directory(parent)


def _fsync_directory(directory: Path) -> None:
    """Fsync the directory so a rename is durable across power loss.

    Best-effort. Some platforms (Windows) and some filesystems reject
    ``os.fsync`` on a directory file descriptor; in those cases we
    swallow ``OSError`` rather than burden every caller with the
    platform check. The rename itself already happened — losing the
    extra durability guarantee is strictly less bad than aborting
    the write entirely after the rename committed.
    """
    try:
        dirfd = os.open(str(directory), os.O_RDONLY)
    except OSError:
        return
    try:
        with contextlib.suppress(OSError):
            os.fsync(dirfd)
    finally:
        os.close(dirfd)
