"""Deterministic hashing and durable JSON helpers for SR artifacts."""
from __future__ import annotations

import hashlib
import json
import os
import tempfile
from pathlib import Path
from typing import Any, Mapping, Union

PathLike = Union[str, os.PathLike]


def safe_path(base_dir: PathLike, relative_path: PathLike) -> Path:
    """Resolve a relative artifact path and ensure it remains below ``base_dir``."""
    base = Path(base_dir).expanduser().resolve()
    candidate_input = Path(relative_path)
    if candidate_input.is_absolute():
        raise ValueError("artifact path must be relative to base_dir")
    candidate = (base / candidate_input).resolve()
    try:
        candidate.relative_to(base)
    except ValueError as exc:
        raise ValueError("artifact path escapes base_dir") from exc
    return candidate


def canonical_json_bytes(value: Any) -> bytes:
    """Serialize JSON data deterministically for content addressing."""
    return json.dumps(
        value,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
        allow_nan=False,
    ).encode("utf-8")


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def sha256_text(text: str) -> str:
    if not isinstance(text, str):
        raise TypeError("text must be a string")
    return sha256_bytes(text.encode("utf-8"))


def hash_json(value: Any) -> str:
    return sha256_bytes(canonical_json_bytes(value))


def hash_file(path: PathLike, chunk_size: int = 1024 * 1024) -> str:
    digest = hashlib.sha256()
    with Path(path).open("rb") as stream:
        for chunk in iter(lambda: stream.read(chunk_size), b""):
            digest.update(chunk)
    return digest.hexdigest()


def atomic_write_json(path: PathLike, value: Mapping[str, Any]) -> Path:
    """Atomically replace a JSON file, including an fsync of file contents."""
    destination = Path(path)
    destination.parent.mkdir(parents=True, exist_ok=True)
    descriptor, temporary_name = tempfile.mkstemp(
        prefix=f".{destination.name}.", suffix=".tmp", dir=str(destination.parent)
    )
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "wb") as stream:
            stream.write(canonical_json_bytes(value) + b"\n")
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(str(temporary), str(destination))
    except BaseException:
        temporary.unlink(missing_ok=True)
        raise
    return destination


def atomic_write_json_below(
    base_dir: PathLike, relative_path: PathLike, value: Mapping[str, Any]
) -> Path:
    return atomic_write_json(safe_path(base_dir, relative_path), value)
