#!/usr/bin/env python3
"""Atomic file writes for session state (FLY-1293).

Session state is small, frequently rewritten, and read by a different process
than the one that wrote it — hooks read what `issues.py` writes. A plain
``write_text`` truncates the file first and fills it second, so a process
killed between those two steps leaves a *zero-byte* `status` behind, and the
next reader sees "no status" rather than the previous one. Under the bridge
that stopped being theoretical: an MCP client cancels a call, the CLI sends
SIGTERM, and whatever the Python side was mid-write is what survives.

The fix is the standard one — write a sibling temp file, flush it to disk,
then ``os.replace`` it over the target. ``os.replace`` is atomic on POSIX and
on Windows (unlike ``Path.rename``, which raises when the target exists), so a
reader sees either the whole old file or the whole new one, never a partial.

This module is deliberately dependency-free: hooks import it under a bare
``sys.path`` and it must not drag the API client in with it.
"""

import json
import os
import tempfile
from pathlib import Path

__all__ = [
    "atomic_write_text",
    "atomic_write_json",
    "atomic_unlink",
]


def atomic_write_text(path: Path, content: str, *, encoding: str = "utf-8") -> None:
    """Write ``content`` to ``path`` so no reader ever sees a partial file.

    The temp file is created in the target's own directory: ``os.replace`` is
    only atomic within a filesystem, and a temp under ``/tmp`` can easily be on
    another one.
    """
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    fd, tmp_name = tempfile.mkstemp(
        prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
    )
    try:
        with os.fdopen(fd, "w", encoding=encoding) as handle:
            handle.write(content)
            handle.flush()
            # The rename is only as durable as the bytes behind it — without
            # the fsync a crash can land the rename before the content.
            os.fsync(handle.fileno())
        os.replace(tmp_name, str(path))
    except BaseException:
        # Includes KeyboardInterrupt/SystemExit: a cancelled write must not
        # leave its scratch file behind for the next `ls` to puzzle over.
        try:
            os.unlink(tmp_name)
        except OSError:
            pass
        raise


def atomic_write_json(path: Path, data: object, *, indent: int | None = 2) -> None:
    """Serialize ``data`` and write it atomically.

    Serialization happens *before* the file is touched, so an unserializable
    value raises with the previous file still intact.
    """
    content = json.dumps(data, indent=indent) + "\n"
    json.loads(content)  # Validate before writing (FLY-718)
    atomic_write_text(path, content)


def atomic_unlink(path: Path) -> bool:
    """Remove ``path`` if present. Returns True when a file was removed.

    Deletion is already atomic; this exists so callers clearing session state
    do not need their own try/except around every unlink.
    """
    try:
        Path(path).unlink()
        return True
    except FileNotFoundError:
        return False
    except OSError:
        return False
