"""Required-gate evaluator against the Gitea 1.25.5 Actions API shape.

Gate identity is (workflow_path, job_name). A gate passes only when exactly one
completed, successful job matches it at the exact candidate SHA, on the
permitted ref, under an allowed event, in the correct workflow.

Every other outcome denies. See docs/DESIGN.md for the attack list this shape
answers and the one it cannot (continue-on-error).
"""

RUN_FIELDS = ("id", "path", "head_sha", "head_branch", "event",
              "status", "conclusion", "run_number", "run_attempt")
JOB_FIELDS = ("id", "run_id", "name", "head_sha", "head_branch",
              "status", "conclusion", "run_attempt")

CONTINUE_ON_ERROR_CAVEAT = (
    "a job failing under continue-on-error reports conclusion=success and is "
    "indistinguishable from a genuine pass when the API returns no steps")


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


class PublicationRequest:
    __slots__ = ("internal_repository", "destination_host",
                 "destination_repository", "sha", "ref")

    def __init__(self, internal_repository, destination_host,
                 destination_repository, sha, ref):
        self.internal_repository = internal_repository
        self.destination_host = destination_host
        self.destination_repository = destination_repository
        self.sha = sha
        self.ref = ref


class Report:
    def __init__(self):
        self.denials = []
        self.passed = []
        self.unproven = []

    @property
    def allowed(self):
        return not self.denials

    def deny(self, code, detail):
        self.denials.append(f"{code}: {detail}")

    def note_unproven(self, detail):
        if detail not in self.unproven:
            self.unproven.append(detail)

    def summary(self):
        verdict = "ALLOW" if self.allowed else "DENY"
        lines = [f"{verdict} ({len(self.passed)} gate(s) proven, "
                 f"{len(self.denials)} denial(s))"]
        lines.extend("  proven: " + g for g in self.passed)
        lines.extend("  DENY   " + d for d in self.denials)
        lines.extend("  unproven: " + u for u in self.unproven)
        return "\n".join(lines)


def _require_fields(obj, fields, kind):
    if not isinstance(obj, dict):
        return [f"{kind} is not an object"]
    return [f"{kind} is missing field '{f}'" for f in fields if obj.get(f) is None]


def _check_request(policy, request, report):
    if request.internal_repository != policy.internal_repository:
        report.deny("wrong-repository",
                    f"request names {request.internal_repository}, policy authorizes "
                    f"{policy.internal_repository}")
    if request.destination_repository != policy.destination_repository:
        report.deny("wrong-destination",
                    f"request targets {request.destination_repository}, policy permits "
                    f"{policy.destination_repository}")
    if request.destination_host != policy.destination_host:
        report.deny("wrong-destination-host",
                    f"request targets {request.destination_host}, policy permits "
                    f"{policy.destination_host}")
    if request.ref not in policy.permitted_refs:
        report.deny("wrong-ref",
                    f"ref {request.ref!r} is not in permitted_refs "
                    f"{list(policy.permitted_refs)}")
    sha = request.sha
    if not isinstance(sha, str) or len(sha) != 40 or any(
            c not in "0123456789abcdef" for c in sha):
        report.deny("malformed-sha", f"{sha!r} is not a full lowercase 40-hex SHA")


def _candidate_runs(policy, request, client, report):
    try:
        raw_runs = client.list_runs(policy.internal_repository, head_sha=request.sha)
    except Exception as exc:
        report.deny("api-unreachable", f"could not list runs ({exc})")
        return []
    runs = []
    for r in raw_runs:
        missing = _require_fields(r, RUN_FIELDS, "run")
        if missing:
            report.deny("malformed-run", "; ".join(missing))
            continue
        if r["head_sha"] != request.sha:
            continue
        if r["head_branch"] != request.ref:
            continue
        if r["event"] not in policy.allowed_events:
            continue
        runs.append(r)
    return runs


def _select_runs(policy, gate, runs, report):
    matching = [r for r in runs if r["path"] == gate.workflow_path]
    if not matching:
        report.deny("gate-missing",
                    f"{gate.workflow_path}::{gate.job_name} has no run at this "
                    f"SHA/ref/event")
        return []
    if policy.rerun_policy == "all-attempts-must-succeed":
        return matching
    top = max(r["run_number"] for r in matching)
    latest = [r for r in matching if r["run_number"] == top]
    if len(latest) > 1:
        report.deny("ambiguous-run",
                    f"{gate.workflow_path} has {len(latest)} runs sharing run_number "
                    f"{top}; cannot choose")
        return []
    return latest


def _jobs_for(client, policy, run, report):
    try:
        raw = client.list_jobs(policy.internal_repository, run["id"])
    except Exception as exc:
        report.deny("api-unreachable", f"could not list jobs for run {run['id']} ({exc})")
        return None
    jobs = []
    for j in raw:
        missing = _require_fields(j, JOB_FIELDS, "job")
        if missing:
            report.deny("malformed-job", "; ".join(missing))
            return None
        jobs.append(j)
    return jobs


def _judge_job(gate, job, report, label):
    ident = f"{gate.workflow_path}::{gate.job_name} ({label})"
    if job["status"] != "completed":
        report.deny("gate-incomplete", f"{ident} status={job['status']}")
        return False
    if job["conclusion"] != "success":
        report.deny(f"gate-{job['conclusion']}", f"{ident} conclusion={job['conclusion']}")
        return False
    steps = job.get("steps")
    if not steps:
        report.note_unproven(f"{ident}: no steps returned; {CONTINUE_ON_ERROR_CAVEAT}")
        return True
    failed = [s.get("name") for s in steps if s.get("conclusion") == "failure"]
    if failed:
        report.deny("gate-step-failed-under-success",
                    f"{ident} reports success but step(s) failed: {failed}")
        return False
    return True


def _matrix_drift(gate, jobs, report):
    """Tripwire, not a selector.

    Selection is the exact list in matrix_jobs. This counts how many jobs in
    the run look like legs of the same declaration and denies on a mismatch, so
    a leg added upstream and never approved cannot pass unnoticed. It decides
    nothing about which jobs are trusted -- only that the policy is stale.
    """
    shaped = [j for j in jobs if j["name"].startswith(gate.job_name + " (")]
    if len(shaped) != len(gate.matrix_jobs):
        report.deny("matrix-drift",
                    f"{gate.workflow_path}::{gate.job_name} declares "
                    f"{len(gate.matrix_jobs)} legs, the run carries {len(shaped)}; "
                    "approve the change in policy rather than around it")
        return False
    return True


def _evaluate_matrix(policy, request, gate, runs, client, report):
    selected = _select_runs(policy, gate, runs, report)
    if not selected:
        return
    proven = True
    for run in selected:
        jobs = _jobs_for(client, policy, run, report)
        if jobs is None:
            return
        at_sha = [j for j in jobs
                  if j["head_sha"] == request.sha and j["head_branch"] == request.ref]
        if not _matrix_drift(gate, at_sha, report):
            return
        for leg in gate.matrix_jobs:
            named = [j for j in at_sha if j["name"] == leg]
            if not named:
                report.deny("gate-missing",
                            f"{gate.workflow_path}::{leg} absent from run {run['id']}")
                return
            if policy.rerun_policy == "latest-attempt-must-succeed":
                top = max(j["run_attempt"] for j in named)
                candidates = [j for j in named if j["run_attempt"] == top]
                label = f"run {run['id']} attempt {top}"
                if len(candidates) != 1:
                    report.deny("gate-ambiguous",
                                f"{gate.workflow_path}::{leg} matched "
                                f"{len(candidates)} jobs at {label}; exactly one required")
                    return
            else:
                candidates = named
                label = f"run {run['id']} all attempts"
            for job in candidates:
                if not _judge_job(gate, job, report, f"{leg}, {label}"):
                    proven = False
    if proven:
        report.passed.append(f"{gate.workflow_path}::{gate.job_name} "
                             f"({len(gate.matrix_jobs)} legs)")


def _evaluate_gate(policy, request, gate, runs, client, report):
    if gate.cardinality == "matrix":
        return _evaluate_matrix(policy, request, gate, runs, client, report)
    if gate.cardinality != "exactly-one":
        report.deny("unsupported-cardinality",
                    f"{gate.workflow_path}::{gate.job_name} declares "
                    f"{gate.cardinality!r}; not supported in this iteration")
        return

    selected = _select_runs(policy, gate, runs, report)
    if not selected:
        return

    proven = True
    for run in selected:
        jobs = _jobs_for(client, policy, run, report)
        if jobs is None:
            return
        named = [j for j in jobs
                 if j["name"] == gate.job_name
                 and j["head_sha"] == request.sha
                 and j["head_branch"] == request.ref]
        if not named:
            report.deny("gate-missing",
                        f"{gate.workflow_path}::{gate.job_name} absent from run "
                        f"{run['id']} (attempt {run['run_attempt']})")
            return
        if policy.rerun_policy == "latest-attempt-must-succeed":
            top_attempt = max(j["run_attempt"] for j in named)
            candidates = [j for j in named if j["run_attempt"] == top_attempt]
            label = f"run {run['id']} attempt {top_attempt}"
        else:
            candidates = named
            label = f"run {run['id']} all attempts"

        if policy.rerun_policy == "latest-attempt-must-succeed" and len(candidates) != 1:
            report.deny("gate-ambiguous",
                        f"{gate.workflow_path}::{gate.job_name} matched "
                        f"{len(candidates)} jobs at {label}; exactly one required")
            return
        for job in candidates:
            if not _judge_job(gate, job, report, label):
                proven = False
    if proven:
        report.passed.append(f"{gate.workflow_path}::{gate.job_name}")


def evaluate(policy, request, client):
    """Return a Report. Report.allowed is True only if every gate is proven."""
    report = Report()
    _check_request(policy, request, report)
    if report.denials:
        return report

    runs = _candidate_runs(policy, request, client, report)
    if report.denials:
        return report
    if not runs:
        report.deny("no-runs",
                    f"no run at {request.sha} on {request.ref} under events "
                    f"{sorted(policy.allowed_events)}")
        return report

    for gate in policy.required_gates:
        _evaluate_gate(policy, request, gate, runs, client, report)

    report.note_unproven(
        "gate proof shows a green run exists at this SHA on this ref; it does not "
        "prove which push produced it")
    return report
