"""Internal I/O helpers.

The headline export is :func:`atomic_write` — the single canonical
"write to a path durably" primitive used by every disk-touching
load-bearing surface in the library (vault, crash reporter,
versioned-file store).

CF-04 (Atomicity) wants: write to a temp file → fsync the body →
rename onto the target → optionally fsync the parent directory.
Without the body fsync, a power loss between the write and the
rename can leave the file empty or truncated on the next boot.
Without the parent-dir fsync, the rename itself can be lost on
power failure even when the body is durable.

Before this module landed, three call sites implemented atomic
writes three different ways — only one fsynced the body, none
fsynced the parent dir. RA-01 consolidates them here.

Internal — leading-underscore module name, no public re-export.
Callers inside ``mgf.common.*`` import directly from
``mgf.common._io``. The surface is small enough that publishing
it would create more contract than value; lifting to public when
a second sibling needs it is one re-export and one PUBLIC_API.md
row away.
"""

from __future__ import annotations

import os
import sys
import tempfile
from pathlib import Path


def atomic_write(
    path: Path,
    content: bytes | str,
    *,
    mode: int = 0o600,
    fsync_dir: bool = False,
    encoding: str = "utf-8",
) -> None:
    """Atomically write ``content`` to ``path`` per rule CF-04.

    Sequence:

    1. Create a tempfile in the SAME directory as ``path`` (so the
       eventual rename is on the same filesystem and therefore
       atomic).
    2. Write ``content``. For ``str``, encodes via ``encoding``;
       for ``bytes``, writes directly.
    3. ``flush()`` + ``os.fsync(fd)`` so the body is durable
       BEFORE the rename. Without this, a crash between write and
       rename can leave the file empty or truncated.
    4. ``chmod`` to ``mode`` (default ``0o600`` — read/write owner
       only, the secrets-friendly default).
    5. ``os.replace(tmp, path)`` — atomic on POSIX and Windows
       when source + target are on the same filesystem.
    6. If ``fsync_dir`` is True, ``os.fsync`` the parent directory
       so the rename itself is durable. POSIX-only — no-op on
       Windows where ``os.open(dir, O_RDONLY)`` isn't supported.

    On any failure, the tempfile is cleaned up before the
    exception propagates. The target file is never partially
    written.

    Parameters
    ----------
    path
        Destination. The parent directory MUST exist; callers
        ``mkdir(parents=True, exist_ok=True)`` upstream when
        needed.
    content
        Bytes or string to write.
    mode
        Final file mode. Default ``0o600`` matches CF-04 / SC-04
        for secret-bearing files (vault, crash reports). Override
        when writing non-secret content.
    fsync_dir
        When True, fsync the parent directory after rename so the
        rename is durable across power failure. POSIX-only — the
        helper is a no-op on Windows where directory fsync isn't
        supported.

        Use ``True`` for files whose loss-on-rename would be
        load-bearing (vaults, schema-versioned configs). Use
        ``False`` (the default) for files where losing one entry
        is acceptable (crash reports — operators see the next
        one).
    encoding
        UTF-8 by default; used only when ``content`` is a string.
    """
    # mkstemp returns an OS-level fd + path. We use `text=False`
    # because we manage the encoding ourselves; this keeps the
    # mkstemp call uniform for bytes + str.
    fd, tmp_name = tempfile.mkstemp(
        prefix=f".{path.name}.",
        dir=str(path.parent),
    )
    tmp_path = Path(tmp_name)
    try:
        # Manage the fd directly so we can fsync before close.
        if isinstance(content, str):
            payload = content.encode(encoding)
        else:
            payload = content
        with os.fdopen(fd, "wb") as fh:
            fh.write(payload)
            fh.flush()
            os.fsync(fh.fileno())
        tmp_path.chmod(mode)
        # ``os.replace`` (not ``tmp_path.replace``) so tests can
        # monkeypatch ``mgf.common._io.os.replace`` to inject
        # rename failures. Path.replace internally calls
        # os.replace but bypasses this module's namespace.
        os.replace(tmp_path, path)  # noqa: PTH105
    except BaseException:
        # Clean up the partial temp file before propagating. Use
        # missing_ok=True because the failure may have been in
        # mkstemp itself (in which case tmp_path doesn't exist).
        tmp_path.unlink(missing_ok=True)
        raise

    if fsync_dir and sys.platform != "win32":
        # Best-effort directory fsync. Failure here doesn't undo
        # the rename — the file is already in place — so we
        # suppress: the worst case is the rename being lost on a
        # crash that happens within the next few milliseconds,
        # which is the regime the caller opted into by setting
        # fsync_dir=True anyway.
        try:
            dir_fd = os.open(str(path.parent), os.O_RDONLY)
            try:
                os.fsync(dir_fd)
            finally:
                os.close(dir_fd)
        except OSError:
            pass


__all__ = ["atomic_write"]
