"""The real-to-dummy mapping and the per-install salt (R-9 data-at-rest format).

The mapping that actually drives consistent replacement lives in memory for the
duration of a run (cleartext real -> dummy). What is written to disk is a sidecar keyed
by salted SHA-256 of each real value, never the cleartext; values are
{dummy, role, type, count}. The per-install salt lives at <home>/salt (mode 600),
generated on first run, never shipped or exported. The sidecar is written under a
private map directory, never under --out and never inside the package.

Internal auditability without easy reversal: to check whether a suspected real value
was mapped, hash it with the local salt and look it up. The file alone reveals nothing.
"""
from __future__ import annotations

import hashlib
import json
import os
import secrets
from dataclasses import dataclass, field
from typing import Callable, Optional

_ENV_HOME = "PRIVSWEEP_HOME"


def privsweep_home() -> str:
    base = os.environ.get(_ENV_HOME)
    if base:
        return os.path.abspath(base)
    return os.path.join(os.path.expanduser("~"), ".privsweep")


def salt_path() -> str:
    return os.path.join(privsweep_home(), "salt")


def default_map_dir() -> str:
    return os.path.join(privsweep_home(), "maps")


def get_or_create_salt() -> bytes:
    path = salt_path()
    if os.path.exists(path):
        with open(path, "rb") as fh:
            return fh.read()
    os.makedirs(os.path.dirname(path), exist_ok=True)
    salt = secrets.token_bytes(32)
    # Write at mode 600 (owner read/write only).
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    try:
        os.write(fd, salt)
    finally:
        os.close(fd)
    os.chmod(path, 0o600)
    return salt


def hash_value(salt: bytes, real: str) -> str:
    return hashlib.sha256(salt + real.encode("utf-8")).hexdigest()


# Dummy assigner signature: (role, type, seq_index) -> dummy string.
Assigner = Callable[[Optional[str], str, int], str]


@dataclass
class Mapping:
    """In-memory real->dummy mapping for one document (or one corpus run)."""

    salt: bytes
    _real_to_dummy: dict[str, str] = field(default_factory=dict)
    _by_hash: dict[str, dict] = field(default_factory=dict)
    _counters: dict[str, int] = field(default_factory=dict)

    def get_or_assign(self, real: str, *, ftype: str, role: Optional[str],
                      assigner: Assigner) -> str:
        if real in self._real_to_dummy:
            h = hash_value(self.salt, real)
            self._by_hash[h]["count"] += 1
            return self._real_to_dummy[real]
        counter_key = f"{ftype}:{role}" if role else ftype
        seq = self._counters.get(counter_key, 0)
        self._counters[counter_key] = seq + 1
        dummy = assigner(role, ftype, seq)
        self._real_to_dummy[real] = dummy
        h = hash_value(self.salt, real)
        self._by_hash[h] = {"dummy": dummy, "role": role, "type": ftype, "count": 1}
        return dummy

    def as_sidecar(self) -> dict:
        return dict(self._by_hash)

    def contains_cleartext(self, real: str) -> bool:
        """True only if the cleartext appears as a sidecar key (it must never)."""
        return real in self.as_sidecar()


def write_sidecar(map_dir: str, doc_name: str, mapping: Mapping) -> str:
    os.makedirs(map_dir, exist_ok=True)
    path = os.path.join(map_dir, f"{doc_name}.privsweep-map.json")
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(mapping.as_sidecar(), fh, indent=2, sort_keys=True)
    return path
