#!/usr/bin/env python3
"""Approve a strict kb_stage proposal for guarded canonical apply.

Stage 1 of the KB apply pipeline: REVIEW -> approve -> apply -> render.

This tool does not create canonical rows. It only moves exactly one strict,
pending, applyable proposal from ``pending_review`` to ``approved`` after a
human/operator supplies a reviewer handle and verbatim review note. The apply
step remains separate and is performed by ``scripts/apply_proposal.py``.

The guardrails intentionally mirror the apply worker:

* proposal must be ``pending_review``;
* proposal_type must be one of the worker-supported applyable types;
* payload must contain a strict ``apply_payload`` contract;
* the strict payload must build valid apply SQL before approval is recorded;
* the UPDATE must affect exactly one row.

``--dry-run`` validates the proposal and prints the SQL without writing.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))

import apply_proposal as ap  # noqa: E402


def load_review_note(args: argparse.Namespace) -> str:
    if args.review_note and args.review_note_file:
        raise SystemExit("use either --review-note or --review-note-file, not both")
    note = Path(args.review_note_file).read_text(encoding="utf-8") if args.review_note_file else args.review_note or ""
    note = note.strip()
    if not note:
        raise SystemExit("review note is required and must be non-empty")
    return note


def assert_approvable(proposal: dict[str, Any], reviewed_by: str, review_note: str) -> None:
    if not reviewed_by.strip():
        raise SystemExit("--reviewed-by is required and must be non-empty")
    if not review_note.strip():
        raise SystemExit("--review-note is required and must be non-empty")

    status = proposal.get("status")
    proposal_id = proposal.get("id")
    if status == "approved":
        raise SystemExit(f"proposal {proposal_id} is already approved")
    if status == "applied":
        raise SystemExit(f"proposal {proposal_id} is already applied")
    if status != "pending_review":
        raise SystemExit(
            f"proposal {proposal_id} has status {status!r}; only 'pending_review' proposals can be approved"
        )

    ptype = proposal.get("proposal_type")
    if ptype not in ap.APPLYABLE_TYPES:
        raise SystemExit(
            f"proposal {proposal_id} has proposal_type {ptype!r}; only {ap.APPLYABLE_TYPES!r} are applyable"
        )

    payload = proposal.get("payload") or {}
    if not isinstance(payload, dict) or payload.get("apply_payload") is None:
        raise SystemExit(
            f"proposal {proposal_id} has no payload.apply_payload; run/repair normalization before approval"
        )

    # Validate the strict contract before recording approval. This is pure SQL
    # building: it does not execute canonical writes.
    ap.build_apply_sql(proposal, ap.SERVICE_AGENT_HANDLE)


def build_approve_sql(proposal_id: str, reviewed_by: str, review_note: str) -> str:
    allowed_types_sql = ", ".join(ap.sql_literal(t) for t in ap.APPLYABLE_TYPES)
    return f"""
begin;
with reviewed as (
  update kb_stage.kb_proposals
     set status = 'approved',
         reviewed_by_handle = {ap.sql_literal(reviewed_by)},
         reviewed_at = now(),
         review_note = {ap.sql_literal(review_note)},
         updated_at = now()
   where id = {ap.sql_literal(proposal_id)}::uuid
     and status = 'pending_review'
     and proposal_type in ({allowed_types_sql})
     and payload ? 'apply_payload'
  returning id, proposal_type, status, reviewed_by_handle, reviewed_at, review_note
)
select coalesce(
  jsonb_agg(jsonb_build_object(
    'id', id::text,
    'proposal_type', proposal_type,
    'status', status,
    'reviewed_by_handle', reviewed_by_handle,
    'reviewed_at', reviewed_at::text,
    'review_note', review_note
  )),
  '[]'::jsonb
)::text
from reviewed;
commit;
""".lstrip()


def parse_reviewed_rows(output: str, proposal_id: str) -> list[dict[str, Any]]:
    rows = json.loads(output.strip() or "[]")
    if not isinstance(rows, list):
        raise SystemExit("approval update returned non-list JSON")
    if len(rows) != 1:
        raise SystemExit(
            f"approval update affected {len(rows)} rows for proposal {proposal_id}; expected exactly 1"
        )
    return rows


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("proposal_id", help="UUID of the pending strict proposal to approve")
    parser.add_argument("--reviewed-by", required=True, help="reviewer handle to record")
    note = parser.add_mutually_exclusive_group(required=True)
    note.add_argument("--review-note", help="verbatim review note to record")
    note.add_argument("--review-note-file", help="path containing the verbatim review note")
    parser.add_argument("--dry-run", action="store_true", help="print the SQL, do not execute the approval")
    parser.add_argument("--secrets-file", default=ap.DEFAULT_SECRETS_FILE)
    parser.add_argument("--container", default=ap.DEFAULT_CONTAINER)
    parser.add_argument("--db", default=ap.DEFAULT_DB)
    parser.add_argument("--host", default=ap.DEFAULT_HOST)
    parser.add_argument("--role", default=ap.DEFAULT_ROLE)
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(sys.argv[1:] if argv is None else argv)
    review_note = load_review_note(args)

    password = ap.load_password(args.secrets_file)
    proposal = ap.load_proposal(args, password)
    assert_approvable(proposal, args.reviewed_by, review_note)
    sql = build_approve_sql(args.proposal_id, args.reviewed_by.strip(), review_note)

    if args.dry_run:
        print(sql)
        return 0

    out = ap.run_psql(args, sql, password)
    rows = parse_reviewed_rows(out, args.proposal_id)
    print(json.dumps(rows[0], sort_keys=True))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
