"""The encrypted store — age ciphertext at rest + the value verbs' engine core.

Every secret value lives as one age-encrypted file ``secrets/<id>.age`` in the
``mgf-secrets-store`` repo, encrypted to BOTH recipients (founder + operator;
:mod:`recipients.txt`) so either secret key decrypts (age multi-recipient). A
``manifest.json`` binds each ``<id>.age`` to its registry id, family, kind,
ciphertext sha256, and the CLI-stamped ``encrypted_at`` timestamp.

**AG-18 is absolute here.** Plaintext is ``bytes`` in-process only: it is never
logged, never placed in an exception message, and never written anywhere but the
caller-declared destination. Encryption needs only PUBLIC keys — importing a value
never requires a secret key. ``decrypt``/``store_get`` need a secret identity and
are the two functions that hold plaintext; their docstrings restate the rule.

Determinism (SPEC §8): the library core takes ``encrypted_at`` as an argument —
it never calls ``datetime.now()`` itself; the CLI stamps the time. age's own
per-message randomness (file key + nonce) is inherent to the format and is why the
roundtrip property pins ``decrypt(encrypt(v)) == v``, not ciphertext equality.
"""

from __future__ import annotations

import hashlib
import json
import os
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING, Any

import pyrage
from pyrage import x25519

from .errors import CredentialError, UsageError

if TYPE_CHECKING:
    from collections.abc import Iterable

# --------------------------------------------------------------------------- #
# Locations.
# --------------------------------------------------------------------------- #

#: Env override for the store checkout; else ``<fed_root>/mgf-secrets-store``.
STORE_ENV = "MGF_SECRETS_STORE"
#: Env override for the decrypting identity; else the operator key below.
IDENTITY_ENV = "MGF_SECRETS_IDENTITY"
#: The operator secret key (0600), the default ``store_get`` identity (SPEC §2).
DEFAULT_IDENTITY = "~/.config/mgf-secrets/operator.key"
#: The store repo directory name under the federation root.
STORE_DIRNAME = "mgf-secrets-store"

#: age v1 binary + armored file magics — the no-plaintext guard's positive test.
_AGE_BINARY_MAGIC = b"age-encryption.org/v1"
_AGE_ARMOR_MAGIC = b"-----BEGIN AGE ENCRYPTED FILE-----"
#: The secret-key line prefix in an age identity file (bech32, uppercase). This is a
#: FORMAT MARKER, not a credential — S105 flags the literal shape, hence the ignore.
_AGE_SECRET_PREFIX = "AGE-SECRET-KEY-"  # noqa: S105 - age identity line prefix, not a secret

MANIFEST_VERSION = 1


def _fed_root(explicit: str | Path | None) -> Path:
    if explicit is not None:
        return Path(explicit)
    env = os.environ.get("MGF_ROOT")
    if env:
        return Path(env)
    return Path.home() / "PycharmProjects"


def store_root(explicit: str | Path | None = None, *, fed_root: str | Path | None = None) -> Path:
    """Resolve the store checkout: explicit → ``$MGF_SECRETS_STORE`` → sibling repo."""
    if explicit is not None:
        return Path(explicit)
    env = os.environ.get(STORE_ENV)
    if env:
        return Path(env)
    return _fed_root(fed_root) / STORE_DIRNAME


def default_identity_path(explicit: str | Path | None = None) -> Path:
    """Resolve the decrypting identity: explicit → ``$MGF_SECRETS_IDENTITY`` → operator."""
    if explicit is not None:
        return Path(explicit).expanduser()
    env = os.environ.get(IDENTITY_ENV)
    if env:
        return Path(env).expanduser()
    return Path(DEFAULT_IDENTITY).expanduser()


def secrets_dir(root: str | Path) -> Path:
    return Path(root) / "secrets"


def cipher_path(root: str | Path, entry_id: str) -> Path:
    return secrets_dir(root) / f"{entry_id}.age"


def manifest_path(root: str | Path) -> Path:
    return Path(root) / "manifest.json"


def recipients_path(root: str | Path) -> Path:
    return Path(root) / "recipients.txt"


# --------------------------------------------------------------------------- #
# Recipients (public keys only — safe to read/print/commit).
# --------------------------------------------------------------------------- #


def parse_recipients(text: str) -> list[tuple[str, str]]:
    """Parse ``recipients.txt`` → ``[(role, age1pubkey), …]`` (public keys only).

    Lines are ``role<whitespace>age1…``; ``#`` comments and blanks are ignored.
    """
    out: list[tuple[str, str]] = []
    for raw in text.splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split()
        if len(parts) == 1 and parts[0].startswith("age1"):
            out.append(("", parts[0]))
        elif len(parts) >= 2 and parts[1].startswith("age1"):
            out.append((parts[0], parts[1]))
    return out


def load_recipients(root: str | Path) -> list[str]:
    """Every active recipient public key from the store's ``recipients.txt``."""
    path = recipients_path(root)
    if not path.is_file():
        raise CredentialError(
            f"no recipients.txt in the store ({path}) — the store's public keys are "
            "required to encrypt (RECIPIENTS.md; MGF_INFRA §2)"
        )
    keys = [key for _role, key in parse_recipients(path.read_text(encoding="utf-8"))]
    if not keys:
        raise CredentialError(f"recipients.txt at {path} declares no age recipients")
    return keys


def founder_recipient(root: str | Path) -> str:
    """The founder (root/break-glass) recipient — used alone by ``export-offline``."""
    path = recipients_path(root)
    if not path.is_file():
        raise CredentialError(f"no recipients.txt in the store ({path})")
    pairs = parse_recipients(path.read_text(encoding="utf-8"))
    for role, key in pairs:
        if role.lower().startswith("founder"):
            return key
    raise CredentialError(
        "recipients.txt has no 'founder' recipient — export-offline encrypts to the "
        "founder's offline key only (SPEC §4)"
    )


# --------------------------------------------------------------------------- #
# Encrypt / decrypt (the crypto boundary).
# --------------------------------------------------------------------------- #


def is_age_ciphertext(data: bytes) -> bool:
    """True iff ``data`` begins with an age v1 (binary or armored) file header.

    The store's no-plaintext guard: a ``secrets/*`` blob that is NOT age ciphertext
    is a plaintext leak and must never be committed.
    """
    return data.startswith((_AGE_BINARY_MAGIC, _AGE_ARMOR_MAGIC))


def encrypt(plaintext: bytes, recipients: Iterable[str]) -> bytes:
    """Encrypt ``plaintext`` to every recipient public key (age multi-recipient).

    ``recipients`` are ``age1…`` PUBLIC strings — no secret key is needed to import.
    Never logs ``plaintext``; on a bad recipient the error names the key, not the value.
    """
    recs = []
    for key in recipients:
        try:
            recs.append(x25519.Recipient.from_str(key))
        except Exception as exc:
            raise UsageError(f"invalid age recipient {key!r}: {type(exc).__name__}") from None
    if not recs:
        raise UsageError("encrypt requires at least one recipient")
    return bytes(pyrage.encrypt(plaintext, recs))


def _identities_from_file(identity_path: str | Path) -> list[Any]:
    """Load age secret identities from a keygen file. The secret NEVER leaves here.

    Tolerates age-keygen comment lines (``# public key: …``): only the
    ``AGE-SECRET-KEY-`` line(s) are parsed. The parsed key strings are not logged.
    """
    path = Path(identity_path).expanduser()
    if not path.is_file():
        raise CredentialError(
            f"identity key not found at {path} — the operator key is "
            f"{DEFAULT_IDENTITY} (0600); override with --identity / ${IDENTITY_ENV}"
        )
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        raise CredentialError(f"cannot read identity {path}: {exc.strerror}") from None
    ids = []
    for raw in text.splitlines():
        line = raw.strip()
        if line.startswith(_AGE_SECRET_PREFIX):
            try:
                ids.append(x25519.Identity.from_str(line))
            except Exception:
                raise CredentialError(f"identity file {path} holds a malformed age key") from None
    if not ids:
        raise CredentialError(
            f"identity file {path} contains no AGE-SECRET-KEY- line (not an age identity)"
        )
    return ids


def decrypt(ciphertext: bytes, identity_path: str | Path) -> bytes:
    """Decrypt with the secret key(s) in ``identity_path``. Returns plaintext bytes.

    AG-18: the returned value is a secret — the caller must never log it. On the
    wrong-key path the error says "no identity matched", never anything derived
    from the value.
    """
    ids = _identities_from_file(identity_path)
    try:
        return bytes(pyrage.decrypt(ciphertext, ids))
    except Exception as exc:
        raise CredentialError(
            f"decrypt failed: no identity in {Path(identity_path).expanduser()} matched "
            f"the ciphertext's recipients ({type(exc).__name__})"
        ) from None


# --------------------------------------------------------------------------- #
# Manifest.
# --------------------------------------------------------------------------- #


def empty_manifest() -> dict[str, Any]:
    return {"version": MANIFEST_VERSION, "entries": {}}


def manifest_load(root: str | Path) -> dict[str, Any]:
    path = manifest_path(root)
    if not path.is_file():
        return empty_manifest()
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise CredentialError(f"manifest.json at {path} is unreadable: {exc}") from None
    if not isinstance(data, dict) or "entries" not in data:
        raise CredentialError(f"manifest.json at {path} is not a valid manifest table")
    return data


def manifest_save(root: str | Path, manifest: dict[str, Any]) -> None:
    path = manifest_path(root)
    path.parent.mkdir(parents=True, exist_ok=True)
    text = json.dumps(manifest, indent=2, sort_keys=True) + "\n"
    path.write_text(text, encoding="utf-8")


def manifest_verify(root: str | Path) -> list[str]:
    """Consistency check (SPEC §1): .age ↔ row bijection + recipients ⊆ recipients.txt.

    Returns a list of human-readable problems (empty == consistent). Reads only
    ciphertext + metadata; decrypts nothing.
    """
    problems: list[str] = []
    root = Path(root)
    manifest = manifest_load(root)
    entries = manifest.get("entries", {})
    if not isinstance(entries, dict):
        return [f"manifest entries is {type(entries).__name__}, want a table"]

    declared_recipients = set()
    rpath = recipients_path(root)
    if rpath.is_file():
        declared_recipients = {k for _r, k in parse_recipients(rpath.read_text(encoding="utf-8"))}
    else:
        problems.append("recipients.txt is missing")

    sdir = secrets_dir(root)
    on_disk = {p.stem for p in sdir.glob("*.age")} if sdir.is_dir() else set()

    # Every manifest row has its .age file + valid recipients.
    for entry_id, row in entries.items():
        cpath = cipher_path(root, entry_id)
        if not cpath.is_file():
            problems.append(f"manifest row {entry_id!r} has no secrets/{entry_id}.age")
            continue
        if isinstance(row, dict):
            for rec in row.get("recipients", []) or []:
                if declared_recipients and rec not in declared_recipients:
                    problems.append(
                        f"{entry_id}: encrypted to recipient {rec!r} absent from recipients.txt"
                    )
            declared_sha = row.get("sha256")
            if declared_sha:
                actual = hashlib.sha256(cpath.read_bytes()).hexdigest()
                if actual != declared_sha:
                    problems.append(f"{entry_id}: ciphertext sha256 != manifest ({actual})")

    # Every .age file has a manifest row.
    for stem in sorted(on_disk):
        if stem not in entries:
            problems.append(f"orphan secrets/{stem}.age has no manifest row")

    return problems


def plaintext_guard(root: str | Path) -> list[str]:
    """Every ``secrets/*`` blob that is NOT age ciphertext (a plaintext leak).

    The store never commits anything but ciphertext (SPEC §1). Returns the offending
    relative paths (empty == clean). Reads only the file header — never a value.
    The store repo's CI reimplements this same age-magic check in stdlib.
    """
    offenders: list[str] = []
    sdir = secrets_dir(root)
    if not sdir.is_dir():
        return offenders
    for p in sorted(sdir.iterdir()):
        if not p.is_file():
            continue
        with p.open("rb") as fh:
            head = fh.read(len(_AGE_ARMOR_MAGIC))
        if not is_age_ciphertext(head):
            offenders.append(f"secrets/{p.name}")
    return offenders


# --------------------------------------------------------------------------- #
# Store put / get / resolve.
# --------------------------------------------------------------------------- #


def store_put(
    entry_id: str,
    plaintext: bytes,
    *,
    root: str | Path,
    encrypted_at: str,
    recipients: Iterable[str] | None = None,
    family: str | None = None,
    kind: str | None = None,
) -> dict[str, Any]:
    """Encrypt ``plaintext`` to every recipient and write ``secrets/<id>.age`` (0600).

    Updates ``manifest.json`` with the ciphertext sha256, ``encrypted_at`` (stamped
    by the caller — deterministic core), family, kind, and the recipient set. Returns
    the new manifest row (metadata only, no value). AG-18: ``plaintext`` is never logged.
    """
    root = Path(root)
    recs = list(recipients) if recipients is not None else load_recipients(root)
    ciphertext = encrypt(plaintext, recs)
    if not is_age_ciphertext(ciphertext):  # pragma: no cover - defensive
        raise CredentialError("encrypt did not produce an age header — refusing to write")

    sdir = secrets_dir(root)
    sdir.mkdir(parents=True, exist_ok=True)
    cpath = cipher_path(root, entry_id)
    # 0600 file: write via a private-mode fd so the ciphertext is never group/other-readable.
    fd = os.open(cpath, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    try:
        os.write(fd, ciphertext)
    finally:
        os.close(fd)
    cpath.chmod(0o600)

    row: dict[str, Any] = {
        "file": f"secrets/{entry_id}.age",
        "sha256": hashlib.sha256(ciphertext).hexdigest(),
        "encrypted_at": encrypted_at,
        "recipients": list(recs),
    }
    if family is not None:
        row["family"] = family
    if kind is not None:
        row["kind"] = kind

    manifest = manifest_load(root)
    manifest.setdefault("entries", {})[entry_id] = row
    manifest_save(root, manifest)
    return row


def uncommitted(root: str | Path) -> list[str]:
    """Store paths git reports as modified/untracked — ``[]`` when clean or not a repo.

    The store's git history IS the rotation/audit record (`history <id>` reads it), so a
    value that never reached a commit is both unprotected and invisible to that record
    (FEEDBACK PAPER-03). Read-only: never stages, never commits.
    """
    try:
        out = subprocess.run(  # noqa: S603 - fixed argv, no shell
            # -uall: list untracked FILES individually. Without it git collapses a
            # wholly-untracked `secrets/` to the directory name and the per-id report
            # comes back empty — which is how this check would have under-reported the
            # very case it exists to catch.
            [  # noqa: S607 - `git` from PATH, as elsewhere in this repo
                "git",
                "-C",
                str(root),
                "status",
                "--porcelain",
                "-uall",
                "--",
                "secrets",
                "manifest.json",
            ],
            capture_output=True,
            text=True,
            timeout=20,
            check=True,
        ).stdout
    except (OSError, subprocess.SubprocessError):
        return []
    return sorted(line[3:].strip() for line in out.splitlines() if line.strip())


def store_commit(root: str | Path, message: str) -> bool:
    """Stage ``secrets/`` + ``manifest.json`` and commit. True if a commit was made.

    False when there was nothing to commit or the store is not a git repo — both are
    non-fatal: the ciphertext is already written either way. AG-18: ``message`` is
    caller-supplied metadata (an id and a verb), never a value.
    """
    root = str(root)
    try:
        if not uncommitted(root):
            return False
        subprocess.run(  # noqa: S603 - fixed argv, no shell
            ["git", "-C", root, "add", "--", "secrets", "manifest.json"],  # noqa: S607
            capture_output=True,
            timeout=20,
            check=True,
        )
        subprocess.run(  # noqa: S603 - fixed argv, no shell
            ["git", "-C", root, "commit", "-q", "-m", message],  # noqa: S607
            capture_output=True,
            timeout=30,
            check=True,
        )
    except (OSError, subprocess.SubprocessError):
        return False
    return True


def store_get(
    entry_id: str,
    *,
    root: str | Path,
    identity_path: str | Path | None = None,
) -> bytes:
    """Decrypt ``secrets/<id>.age`` with the operator key (or ``identity_path``).

    AG-18: returns a secret value — the caller must never log it.
    """
    cpath = cipher_path(root, entry_id)
    if not cpath.is_file():
        raise CredentialError(
            f"no stored value for {entry_id!r} (expected {cpath}) — `add`/`set` it first"
        )
    identity = default_identity_path(identity_path)
    return decrypt(cpath.read_bytes(), identity)


def resolve(
    entry_id: str,
    *,
    root: str | Path | None = None,
    identity_path: str | Path | None = None,
    fed_root: str | Path | None = None,
) -> bytes:
    """Return a stored secret value in-process for sibling tools (SPEC §3).

    The value MUST NEVER be logged, echoed, or placed in an exception/transcript —
    this is the in-process replacement for ``$(cat my_stuff/…)`` recipes (AG-18).
    Decrypts with the operator key by default (override via ``identity_path`` /
    ``$MGF_SECRETS_IDENTITY``).
    """
    root_path = store_root(root, fed_root=fed_root)
    return store_get(entry_id, root=root_path, identity_path=identity_path)
