"""Publication policy: the authorization oracle.

A policy record authorizes one internal repository to project one ref to one
destination. No record, or any defect in a record, denies publication.
Rationale and the trust argument live in docs/DESIGN.md.
"""

import json
import os
import re

SCHEMA_VERSION = 1

PROJECTIONS = frozenset({
    "source-only",
    "source+release-assets",
    "source+rolling-archive",
})

KNOWN_EVENTS = frozenset({
    "push", "workflow_dispatch", "pull_request", "schedule", "release",
})

# A matrix gate expands one declaration into many jobs. The selector is the
# explicit list in `matrix_jobs`, never a prefix: prefix matching decides which
# jobs count, and that is the ambiguity the (workflow_path, job_name) key exists
# to remove. Adding a leg upstream therefore requires a policy commit.
SUPPORTED_CARDINALITIES = frozenset({"exactly-one", "matrix"})
DECLARED_CARDINALITIES = frozenset({"exactly-one", "at-least-one", "all-of", "matrix"})

RERUN_POLICIES = frozenset({
    "latest-attempt-must-succeed",
    "all-attempts-must-succeed",
})

REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
HOST_RE = re.compile(r"^[a-z0-9.-]+$")
REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
WORKFLOW_PATH_RE = re.compile(r"^\.(gitea|forgejo|github)/workflows/[A-Za-z0-9._-]+\.ya?ml$")

TOP_KEYS = {
    "schema_version", "internal_repository", "destination_host",
    "destination_repository", "permitted_refs", "projection",
    "publisher_identity", "allowed_events", "rerun_policy",
    "required_gates", "repo_assertions", "mechanism",
    "history_allowlist_sha256",
}
GATE_KEYS = {"workflow_path", "job_name", "cardinality"}
MATRIX_GATE_KEYS = GATE_KEYS | {"matrix_jobs"}
ASSERT_KEYS = {"require_files", "readme_install_pattern", "max_blob_bytes"}
MECH_KEYS = {"version", "manifest_sha256"}


class PolicyError(Exception):
    """A policy record is absent, malformed, or declares something unsupported."""

    def __init__(self, reasons):
        self.reasons = list(reasons)
        super().__init__("; ".join(self.reasons))


class Gate:
    __slots__ = ("workflow_path", "job_name", "cardinality", "matrix_jobs")

    def __init__(self, workflow_path, job_name, cardinality, matrix_jobs=()):
        self.workflow_path = workflow_path
        self.job_name = job_name
        self.cardinality = cardinality
        self.matrix_jobs = tuple(matrix_jobs)

    def __repr__(self):
        n = f", {len(self.matrix_jobs)} legs" if self.matrix_jobs else ""
        return f"Gate({self.workflow_path}::{self.job_name}, {self.cardinality}{n})"


class Policy:
    __slots__ = (
        "schema_version", "internal_repository", "destination_host",
        "destination_repository", "permitted_refs", "projection",
        "publisher_identity", "allowed_events", "rerun_policy",
        "required_gates", "repo_assertions", "mechanism_version",
        "mechanism_manifest_sha256", "source_path", "history_allowlist_sha256",
    )

    def __init__(self, raw, source_path=None):
        self.schema_version = raw["schema_version"]
        self.internal_repository = raw["internal_repository"]
        self.destination_host = raw["destination_host"]
        self.destination_repository = raw["destination_repository"]
        self.permitted_refs = tuple(raw["permitted_refs"])
        self.projection = raw["projection"]
        self.publisher_identity = raw["publisher_identity"]
        self.allowed_events = frozenset(raw["allowed_events"])
        self.rerun_policy = raw["rerun_policy"]
        self.required_gates = tuple(
            Gate(g["workflow_path"], g["job_name"], g["cardinality"],
                 g.get("matrix_jobs", ()))
            for g in raw["required_gates"]
        )
        self.repo_assertions = dict(raw["repo_assertions"])
        self.mechanism_version = raw["mechanism"]["version"]
        self.mechanism_manifest_sha256 = raw["mechanism"]["manifest_sha256"]
        self.history_allowlist_sha256 = raw["history_allowlist_sha256"]
        self.source_path = source_path


def _strict_keys(obj, expected, where, reasons):
    if not isinstance(obj, dict):
        reasons.append(f"{where}: expected object")
        return False
    got = set(obj)
    for k in sorted(expected - got):
        reasons.append(f"{where}: missing key '{k}'")
    for k in sorted(got - expected):
        reasons.append(f"{where}: unknown key '{k}'")
    return got == expected


def validate(raw):
    """Return a list of reasons the record is unusable. Empty list means valid."""
    reasons = []
    if not _strict_keys(raw, TOP_KEYS, "policy", reasons):
        return reasons

    if raw["schema_version"] != SCHEMA_VERSION:
        reasons.append(
            f"policy: schema_version {raw['schema_version']!r} is not {SCHEMA_VERSION}")

    for field, pattern in (
        ("internal_repository", REPO_RE),
        ("destination_repository", REPO_RE),
        ("destination_host", HOST_RE),
    ):
        v = raw[field]
        if not isinstance(v, str) or not pattern.match(v):
            reasons.append(f"policy: {field} {v!r} is malformed")

    refs = raw["permitted_refs"]
    if not isinstance(refs, list) or not refs:
        reasons.append("policy: permitted_refs must be a non-empty list")
    else:
        for r in refs:
            if not isinstance(r, str) or not REF_RE.match(r):
                reasons.append(f"policy: permitted_ref {r!r} is malformed")
        if len(set(refs)) != len(refs):
            reasons.append("policy: permitted_refs contains duplicates")

    if raw["projection"] not in PROJECTIONS:
        reasons.append(f"policy: projection {raw['projection']!r} is not one of "
                       + ", ".join(sorted(PROJECTIONS)))

    if not isinstance(raw["publisher_identity"], str) or not raw["publisher_identity"]:
        reasons.append("policy: publisher_identity must be a non-empty string")

    events = raw["allowed_events"]
    if not isinstance(events, list) or not events:
        reasons.append("policy: allowed_events must be a non-empty list")
    else:
        for e in events:
            if e not in KNOWN_EVENTS:
                reasons.append(f"policy: allowed_event {e!r} is unknown")

    if raw["rerun_policy"] not in RERUN_POLICIES:
        reasons.append(f"policy: rerun_policy {raw['rerun_policy']!r} is not one of "
                       + ", ".join(sorted(RERUN_POLICIES)))

    gates = raw["required_gates"]
    if not isinstance(gates, list) or not gates:
        reasons.append("policy: required_gates must be a non-empty list")
    else:
        seen = set()
        for i, g in enumerate(gates):
            where = f"required_gates[{i}]"
            expected = (MATRIX_GATE_KEYS
                        if isinstance(g, dict) and g.get("cardinality") == "matrix"
                        else GATE_KEYS)
            if not _strict_keys(g, expected, where, reasons):
                continue
            wp, jn, card = g["workflow_path"], g["job_name"], g["cardinality"]
            if not isinstance(wp, str) or not WORKFLOW_PATH_RE.match(wp):
                reasons.append(f"{where}: workflow_path {wp!r} is malformed")
            if not isinstance(jn, str) or not jn:
                reasons.append(f"{where}: job_name must be a non-empty string")
            if card not in DECLARED_CARDINALITIES:
                reasons.append(f"{where}: cardinality {card!r} is not recognised")
            elif card not in SUPPORTED_CARDINALITIES:
                reasons.append(
                    f"{where}: gate {wp}::{jn} declares cardinality {card!r}, which is "
                    f"UNSUPPORTED in schema v{SCHEMA_VERSION}; publication is denied "
                    f"rather than guessed")
            if card == "matrix":
                legs = g["matrix_jobs"]
                if not isinstance(legs, list) or not legs:
                    reasons.append(f"{where}: matrix_jobs must be a non-empty list "
                                   "naming every expanded job exactly")
                else:
                    if any(not isinstance(x, str) or not x for x in legs):
                        reasons.append(f"{where}: matrix_jobs must be non-empty strings")
                    if len(set(legs)) != len(legs):
                        reasons.append(f"{where}: matrix_jobs contains duplicates")
            key = (wp, jn)
            if key in seen:
                reasons.append(f"{where}: duplicate gate {wp}::{jn}")
            seen.add(key)

    a = raw["repo_assertions"]
    if _strict_keys(a, ASSERT_KEYS, "repo_assertions", reasons):
        if not isinstance(a["require_files"], list) or any(
                not isinstance(f, str) or not f for f in a["require_files"]):
            reasons.append("repo_assertions: require_files must be a list of paths")
        p = a["readme_install_pattern"]
        if p is not None:
            if not isinstance(p, str):
                reasons.append("repo_assertions: readme_install_pattern must be a string or null")
            else:
                try:
                    re.compile(p)
                except re.error as exc:
                    reasons.append(f"repo_assertions: readme_install_pattern is not a valid regex ({exc})")
        n = a["max_blob_bytes"]
        if not isinstance(n, int) or isinstance(n, bool) or n <= 0:
            reasons.append("repo_assertions: max_blob_bytes must be a positive integer")

    m = raw["mechanism"]
    if _strict_keys(m, MECH_KEYS, "mechanism", reasons):
        if not isinstance(m["version"], int) or isinstance(m["version"], bool) or m["version"] < 1:
            reasons.append("mechanism: version must be a positive integer")
        if not isinstance(m["manifest_sha256"], str) or not SHA256_RE.match(m["manifest_sha256"]):
            reasons.append("mechanism: manifest_sha256 must be 64 lowercase hex characters")

    h = raw["history_allowlist_sha256"]
    if h is not None and (not isinstance(h, str) or not SHA256_RE.match(h)):
        reasons.append(
            "policy: history_allowlist_sha256 must be 64 lowercase hex characters or "
            "null; pinning it here is what stops a repository blessing its own history")

    return reasons


def load_record(path):
    try:
        with open(path, "r", encoding="utf-8") as fh:
            raw = json.load(fh)
    except FileNotFoundError:
        raise PolicyError([f"policy: no record at {path}"])
    except json.JSONDecodeError as exc:
        raise PolicyError([f"policy: {path} is not valid JSON ({exc})"])
    reasons = validate(raw)
    if reasons:
        raise PolicyError(reasons)
    return Policy(raw, source_path=os.path.abspath(path))


class PolicySet:
    """Every record in a directory, indexed by internal repository.

    A record whose filename does not match its internal_repository is rejected:
    the filename is how a human finds it, and a mismatch hides one record behind
    another's name.
    """

    def __init__(self, records):
        self._by_repo = records

    @classmethod
    def from_directory(cls, directory):
        by_repo = {}
        reasons = []
        if not os.path.isdir(directory):
            raise PolicyError([f"policy: no policy directory at {directory}"])
        for name in sorted(os.listdir(directory)):
            if not name.endswith(".json"):
                continue
            path = os.path.join(directory, name)
            try:
                pol = load_record(path)
            except PolicyError as exc:
                reasons.extend(f"{name}: {r}" for r in exc.reasons)
                continue
            expected = pol.internal_repository.replace("/", "-") + ".json"
            if name != expected:
                reasons.append(
                    f"{name}: record for {pol.internal_repository} must be named {expected}")
                continue
            if pol.internal_repository in by_repo:
                reasons.append(f"{name}: duplicate record for {pol.internal_repository}")
                continue
            by_repo[pol.internal_repository] = pol
        if reasons:
            raise PolicyError(reasons)
        return cls(by_repo)

    def authorize(self, internal_repository):
        pol = self._by_repo.get(internal_repository)
        if pol is None:
            raise PolicyError(
                [f"policy: no record authorizes {internal_repository}; publication denied"])
        return pol

    def __len__(self):
        return len(self._by_repo)

    def repositories(self):
        return tuple(sorted(self._by_repo))


def assert_policy_is_external(policy, consuming_repo_root):
    """The publishing repo must not be able to edit its own authorization.

    Filesystem containment is the check available here; the durable control is
    that the policy repository denies write to every publishing identity.
    """
    if policy.source_path is None:
        raise PolicyError(["policy: record has no source path to verify"])
    root = os.path.realpath(consuming_repo_root)
    src = os.path.realpath(policy.source_path)
    if src == root or src.startswith(root + os.sep):
        raise PolicyError([
            f"policy: record {src} lives inside the repository it authorizes ({root}); "
            "a repository must not be able to write its own policy"])
