"""Meta-repo entry file shape per §2.1.

One markdown file per RFC under rfcs/<slug>.md, frontmatter on top, body
below. The frontmatter carries the canonical RFC state — id, repo,
owners, arbiters, graduation timestamps — and the body holds the pitch
(for super-drafts) or is empty (for graduated entries per §13.3 step 3).

This module contains the parser, the serializer, and a small validator
for the frontmatter shape. The parser is intentionally lenient about
unknown keys — future fields land in frontmatter without breaking older
readers.
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from datetime import date
from typing import Any

import yaml

FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)

SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")

_ABSENT = object()


@dataclass
class Entry:
    slug: str
    title: str
    state: str = "super-draft"  # super-draft | active | withdrawn | retired (§3, §13.7)
    id: str | None = None       # 'RFC-NNNN' or None
    repo: str | None = None
    proposed_by: str = ""
    proposed_at: str = ""        # ISO date
    graduated_at: str | None = None
    graduated_by: str | None = None
    owners: list[str] = field(default_factory=list)
    arbiters: list[str] = field(default_factory=list)
    tags: list[str] = field(default_factory=list)
    # §6.6: per-RFC model availability. None means the key is absent
    # from frontmatter (inherit the operator universe). An empty list
    # means an explicit opt-out from AI on this RFC. A populated list
    # narrows the picker to its intersection with the operator universe.
    models: list[str] | None = None
    # §6.7: optional gitea_login naming the user whose registered API
    # credentials pay for AI calls on this RFC. None means absent —
    # operator credentials per §18 are used. The binding is inert until
    # the named user has a funder_consents row (the hybrid two-key rule).
    funder: str | None = None
    # §22.4c: an `active` entry that landed without a human review gate
    # carries unreviewed=True until an owner clears it. Orthogonal to
    # `state`; only meaningful for active entries. reviewed_at/reviewed_by
    # are the provenance of the clear, paralleling graduated_at/by.
    unreviewed: bool = False
    reviewed_at: str | None = None
    reviewed_by: str | None = None
    body: str = ""
    # §22.4a (configurable collection metadata, SLICE-1): frontmatter / sidecar
    # keys outside the known set above are preserved here verbatim so they ride
    # along untouched through a parse→serialize round-trip and the
    # frontmatter→sidecar migration (INV-7). Includes future collection-`fields:`
    # schema values, which the engine does not interpret.
    extra: dict[str, Any] = field(default_factory=dict)


# Frontmatter keys the Entry models explicitly; everything else is `extra`.
KNOWN_KEYS = {
    "slug", "title", "state", "id", "repo", "proposed_by", "proposed_at",
    "graduated_at", "graduated_by", "owners", "arbiters", "tags", "models",
    "funder", "unreviewed", "reviewed_at", "reviewed_by",
}


def parse(text: str) -> Entry:
    match = FRONTMATTER_RE.match(text)
    if not match:
        raise ValueError("Entry file missing frontmatter")
    fm = yaml.safe_load(match.group(1)) or {}
    body = match.group(2).lstrip("\n")
    return from_frontmatter(fm, body)


def from_frontmatter(fm: dict[str, Any], body: str = "") -> Entry:
    """Build an Entry from an already-parsed metadata mapping + body.

    Shared by `parse()` (legacy `.md` frontmatter) and the SLICE-1 dual-read
    sidecar path (`metadata.read_entry`), so both produce identical records
    (INV-6). `fm` keys outside `KNOWN_KEYS` are preserved on `Entry.extra`.
    """
    raw_models = fm.get("models", _ABSENT)
    if raw_models is _ABSENT or raw_models is None:
        models: list[str] | None = None
    else:
        models = [str(m) for m in raw_models]
    raw_funder = fm.get("funder")
    funder = str(raw_funder).strip() if raw_funder else None
    unreviewed = bool(fm.get("unreviewed") or False)
    extra = {k: v for k, v in fm.items() if k not in KNOWN_KEYS}
    return Entry(
        slug=str(fm.get("slug") or ""),
        title=str(fm.get("title") or ""),
        state=str(fm.get("state") or "super-draft"),
        id=fm.get("id") or None,
        repo=fm.get("repo") or None,
        proposed_by=str(fm.get("proposed_by") or ""),
        proposed_at=str(fm.get("proposed_at") or ""),
        graduated_at=fm.get("graduated_at"),
        graduated_by=fm.get("graduated_by"),
        owners=list(fm.get("owners") or []),
        arbiters=list(fm.get("arbiters") or []),
        tags=list(fm.get("tags") or []),
        models=models,
        funder=funder,
        unreviewed=unreviewed,
        reviewed_at=fm.get("reviewed_at") or None,
        reviewed_by=fm.get("reviewed_by") or None,
        body=body,
        extra=extra,
    )


def to_frontmatter_dict(entry: Entry) -> dict[str, Any]:
    """The canonical ordered metadata mapping for an entry.

    Shared by `serialize()` (which wraps it in `---` fences over the body) and
    the SLICE-1 sidecar writer (`metadata.sidecar_yaml`, which emits the same
    mapping as a standalone `<slug>.meta.yaml`). Known keys first in canonical
    order, then `extra` (INV-7).
    """
    fm: dict[str, Any] = {
        "slug": entry.slug,
        "title": entry.title,
        "state": entry.state,
        "id": entry.id,
        "repo": entry.repo,
        "proposed_by": entry.proposed_by,
        "proposed_at": entry.proposed_at,
        "graduated_at": entry.graduated_at,
        "graduated_by": entry.graduated_by,
        "owners": entry.owners,
        "arbiters": entry.arbiters,
        "tags": entry.tags,
    }
    # §6.6: emit `models:` only when set. Absent in the frontmatter
    # is meaningfully different from `models: []` per §6.6.
    if entry.models is not None:
        fm["models"] = entry.models
    # §6.7: emit `funder:` only when set. Empty / None means absent —
    # operator credentials are used. There is no "explicit opt-out"
    # second meaning here as with `models:`; one set of semantics.
    if entry.funder:
        fm["funder"] = entry.funder
    # §22.4c: emit unreviewed only when True (a super-draft / reviewed
    # active entry leaves the key absent → frontmatter stays minimal).
    if entry.unreviewed:
        fm["unreviewed"] = True
    if entry.reviewed_at:
        fm["reviewed_at"] = entry.reviewed_at
    if entry.reviewed_by:
        fm["reviewed_by"] = entry.reviewed_by
    # INV-7: forward-compat / unknown keys ride along after the known ones.
    for k, v in entry.extra.items():
        if k not in fm:
            fm[k] = v
    return fm


def serialize(entry: Entry) -> str:
    """Emit canonical entry file text — frontmatter then body."""
    fm = to_frontmatter_dict(entry)
    yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
    body = entry.body.lstrip("\n")
    if body:
        return f"---\n{yaml_text}\n---\n\n{body}\n"
    return f"---\n{yaml_text}\n---\n"


def slugify(title: str) -> str:
    """Deterministic kebab-case per §9.1."""
    s = title.lower().strip()
    s = re.sub(r"[^a-z0-9]+", "-", s)
    return s.strip("-")


def today() -> str:
    return date.today().isoformat()


def is_valid_slug(slug: str) -> bool:
    return bool(SLUG_RE.match(slug)) and len(slug) <= 80
