"""Owner approval for security-sensitive capabilities the coding agent declares.

The agent writes ``p2hs/feature-packs.json`` as *intent* (requirements_spec.py: what is needed,
never how it is enforced) and Core repairs, validates and provisions it. Most declarations are
plain data: a new admin-only or member-only entity needs no one's sign-off. A few grant access
that nobody may be given silently, so they are held for a workspace admin:

- any access for the ``anonymous`` actor (a public form, a public read),
- any ``delete``,
- the ``email.send`` and ``form.public_create`` primitives,
- a feature pack that is not in the project's plan (only judged when a plan exists).

The verdict is decided here, on Core's canonical form of the entry (after safe repair), and is
keyed by a fingerprint of exactly what is granted, so an approval carries over to retries and later
runs and is asked again only when the grant itself changes. It is evaluated on every gate call, not
inside the cached provisioning job: the job's result is cached by manifest, and a cached "waiting for
approval" would keep failing after the owner approved.
"""

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from typing import Any, Iterable, Optional, Protocol
from uuid import UUID

SENSITIVE_PRIMITIVES = frozenset({"email.send", "form.public_create"})

PENDING, APPROVED, REJECTED = "pending", "approved", "rejected"
KIND_COMPOSE, KIND_PACK = "compose", "pack_install"

_ACTOR_LABEL = {
    "anonymous": "Anyone on the internet",
    "member": "Signed-in members",
    "admin": "Admins",
    "super_admin": "Super admins",
}
_ACTION_PHRASE = {
    "create": "can create",
    "read": "can read",
    "update": "can change",
    "delete": "can permanently delete",
}
_PRIMITIVE_PHRASE = {
    "email.send": "Sends email on behalf of the project",
    "form.public_create": "Accepts public form submissions",
}
_MAX_FIELDS_SHOWN = 12
_MAX_SUMMARY = 900


@dataclass(frozen=True)
class Sensitive:
    """One thing that needs an owner's decision, described in plain words."""

    kind: str
    name: str
    fingerprint: str
    grants: list[dict[str, str]]
    summary: str


def sensitive_grants(req: Any) -> list[dict[str, str]]:
    """The security-sensitive part of a validated CompositionRequirement (empty = needs nothing)."""
    grants: dict[str, dict[str, str]] = {}
    for item in req.requirements:
        if item.actor == "anonymous" or item.action == "delete":
            grant = {"actor": item.actor, "action": item.action, "resource": item.resource}
            grants[json.dumps(grant, sort_keys=True)] = grant
    for primitive in req.requires:
        if primitive in SENSITIVE_PRIMITIVES:
            grant = {"primitive": primitive}
            grants[json.dumps(grant, sort_keys=True)] = grant
    return [grants[key] for key in sorted(grants)]


def _fingerprint(kind: str, name: str, grants: list[dict[str, str]]) -> str:
    canonical = json.dumps({"kind": kind, "name": name, "grants": grants}, sort_keys=True)
    return hashlib.sha256(canonical.encode()).hexdigest()


def _describe(req: Any, grants: list[dict[str, str]]) -> str:
    lines = []
    for grant in grants:
        if "primitive" in grant:
            lines.append(_PRIMITIVE_PHRASE.get(grant["primitive"], grant["primitive"]))
        else:
            lines.append(
                f"{_ACTOR_LABEL.get(grant['actor'], grant['actor'])} "
                f"{_ACTION_PHRASE.get(grant['action'], grant['action'])} {grant['resource']} records"
            )
    keys = [f.key for f in req.fields][:_MAX_FIELDS_SHOWN]
    if keys:
        more = len(req.fields) - len(keys)
        lines.append("Stores: " + ", ".join(keys) + (f" and {more} more" if more > 0 else ""))
    return ". ".join(lines)[:_MAX_SUMMARY]


def classify(
    compositions: Iterable[Any],
    pack_installs: Iterable[Any],
    plan_pack_ids: Optional[Iterable[str]],
) -> list[Sensitive]:
    """Everything in the agent's manifest that needs an owner's decision.

    Entries are judged on Core's repaired canonical form, so a rename or a normalized actor cannot
    change the outcome. An entry that is still invalid after repair is skipped here: it is reported
    as invalid by the provisioning job and never provisioned, so it grants nothing."""
    from src.domain.feature_packs.manifest_repair import repair_composition
    from src.domain.feature_packs.requirements_spec import CompositionRequirement

    found: dict[str, Sensitive] = {}
    for entry in compositions or []:
        if isinstance(entry, CompositionRequirement):
            req = entry
        else:
            req = repair_composition(entry).requirement
        if req is None:
            continue
        grants = sensitive_grants(req)
        if not grants:
            continue
        fingerprint = _fingerprint(KIND_COMPOSE, req.name, grants)
        found[fingerprint] = Sensitive(
            KIND_COMPOSE, req.name, fingerprint, grants, _describe(req, grants)
        )
    if plan_pack_ids is not None:
        planned = {str(p) for p in plan_pack_ids}
        for row in pack_installs or []:
            slug = str(row.get("pack_slug") or "") if isinstance(row, dict) else ""
            if not slug or slug in planned:
                continue
            grants = [{"pack": slug}]
            fingerprint = _fingerprint(KIND_PACK, slug, grants)
            found[fingerprint] = Sensitive(
                KIND_PACK,
                slug,
                fingerprint,
                grants,
                f"Installs the '{slug}' feature pack, which is not part of this project's plan.",
            )
    return list(found.values())


@dataclass
class Decision:
    approved: list[Any] = field(default_factory=list)
    pending: list[Any] = field(default_factory=list)
    rejected: list[Any] = field(default_factory=list)
    created: bool = False

    @property
    def clear(self) -> bool:
        return not self.pending and not self.rejected


class ApprovalStore(Protocol):
    async def get_many(self, project_id: UUID, fingerprints: list[str]) -> list[Any]: ...
    async def create_pending(
        self, project_id: UUID, run_id: Optional[UUID], items: list[Sensitive]
    ) -> None: ...
    async def get(self, project_id: UUID, approval_id: UUID) -> Optional[Any]: ...
    async def set_status(self, row: Any, status: str, user_id: UUID) -> None: ...
    async def list(self, project_id: UUID) -> list[Any]: ...


async def evaluate(
    store: ApprovalStore, project_id: UUID, run_id: Optional[UUID], items: list[Sensitive]
) -> Decision:
    """Look up each item's decision; anything never seen before is recorded as pending."""
    decision = Decision()
    if not items:
        return decision
    known = {row.fingerprint: row for row in await store.get_many(project_id, [i.fingerprint for i in items])}
    unseen = [item for item in items if item.fingerprint not in known]
    if unseen:
        await store.create_pending(project_id, run_id, unseen)
        decision.created = True
        known = {
            row.fingerprint: row
            for row in await store.get_many(project_id, [i.fingerprint for i in items])
        }
    for item in items:
        row = known.get(item.fingerprint)
        status = getattr(row, "status", PENDING)
        {APPROVED: decision.approved, REJECTED: decision.rejected}.get(status, decision.pending).append(
            row if row is not None else item
        )
    return decision


def waiting_message(pending: list[Any]) -> str:
    shown = "; ".join(f"{row.name}: {row.summary}" for row in pending[:5])
    more = f" (and {len(pending) - 5} more)" if len(pending) > 5 else ""
    return (
        f"Waiting for approval: {len(pending)} security-sensitive "
        f"capabilit{'y' if len(pending) == 1 else 'ies'} the project owner must approve before this "
        f"build can finish. {shown}{more}. Approve or reject them in the P2HS dashboard "
        "(Project > Capability approvals), then retry the build."
    )[:1800]


def rejection_correction(rejected: list[Any]) -> dict:
    """Send the agent back through the correction path: these grants were refused."""
    return {
        "entries": [
            {
                "name": str(row.name)[:100],
                "errors": [
                    {
                        "path": "requirements",
                        "code": "not_approved",
                        "message": (
                            "The project owner did not approve this access: "
                            + str(row.summary)[:240]
                            + " Remove it, or redesign this capability without it."
                        ),
                    }
                ],
            }
            for row in rejected[:20]
        ]
    }


def rejection_message(rejected: list[Any]) -> str:
    names = ", ".join(str(row.name) for row in rejected[:5])
    return f"The project owner did not approve: {names}. The build must be changed to work without that access."


async def verdict(
    repository: Any, run: Any, pack_installs: list, pack_compositions: list
) -> Optional[dict]:
    """The gate response when the agent's manifest needs (or was refused) an owner's decision,
    or None when nothing sensitive is undecided. Called before the cached provisioning job."""
    plan = (getattr(run, "plan", None) or {}).get("context") or {}
    composition = (plan.get("generation_plan") or {}).get("composition")
    plan_pack_ids = (
        composition.get("feature_pack_ids") or [] if isinstance(composition, dict) else None
    )
    items = classify(pack_compositions, pack_installs, plan_pack_ids)
    if not items:
        return None
    store = SqlApprovalStore(repository.db)
    decision = await evaluate(store, run.project_id, run.id, items)
    if decision.created:
        await repository.commit()
    if decision.rejected:
        return {
            "status": "blocked",
            "message": rejection_message(decision.rejected),
            "failure_class": "invalid_requirement",
            "retryable": True,
            "correction": rejection_correction(decision.rejected),
        }
    if decision.pending:
        return {
            "status": "blocked",
            "message": waiting_message(decision.pending),
            "failure_class": "approval_required",
            "retryable": True,
        }
    return None


class SqlApprovalStore:
    """Postgres-backed store; the decision logic above is tested against an in-memory one."""

    def __init__(self, db: Any):
        self._db = db

    async def get_many(self, project_id: UUID, fingerprints: list[str]) -> list[Any]:
        from sqlalchemy import select

        from src.domain.codegen.models import ProjectCapabilityApproval as Row

        result = await self._db.execute(
            select(Row).where(Row.project_id == project_id, Row.fingerprint.in_(fingerprints))
        )
        return list(result.scalars().all())

    async def create_pending(
        self, project_id: UUID, run_id: Optional[UUID], items: list[Sensitive]
    ) -> None:
        from sqlalchemy.dialects.postgresql import insert

        from src.domain.codegen.models import ProjectCapabilityApproval as Row

        for item in items:
            # Concurrent gate calls for the same run can race; the unique key makes the loser a no-op.
            await self._db.execute(
                insert(Row)
                .values(
                    project_id=project_id,
                    fingerprint=item.fingerprint,
                    kind=item.kind,
                    name=item.name[:100],
                    summary=item.summary,
                    grants=item.grants,
                    status=PENDING,
                    requested_run_id=run_id,
                )
                .on_conflict_do_nothing(constraint="uq_capability_approval_fingerprint")
            )
        await self._db.flush()

    async def get(self, project_id: UUID, approval_id: UUID) -> Optional[Any]:
        from sqlalchemy import select

        from src.domain.codegen.models import ProjectCapabilityApproval as Row

        result = await self._db.execute(
            select(Row).where(Row.project_id == project_id, Row.id == approval_id)
        )
        return result.scalar_one_or_none()

    async def set_status(self, row: Any, status: str, user_id: UUID) -> None:
        from datetime import UTC, datetime

        row.status = status
        row.decided_by = user_id
        row.decided_at = datetime.now(UTC)
        await self._db.flush()

    async def list(self, project_id: UUID) -> list[Any]:
        from sqlalchemy import select

        from src.domain.codegen.models import ProjectCapabilityApproval as Row

        result = await self._db.execute(
            select(Row).where(Row.project_id == project_id).order_by(Row.created_at.desc())
        )
        return list(result.scalars().all())


async def decide(store: ApprovalStore, project_id: UUID, approval_id: UUID, approve: bool, user_id: UUID):
    """Record an admin's decision. Deciding again later is allowed (approve a rejected item, or the
    reverse): the fingerprint is the grant itself, so a changed grant is a new question anyway."""
    from src.core.exceptions import NotFoundError

    row = await store.get(project_id, approval_id)
    if row is None:
        raise NotFoundError("Approval not found")
    await store.set_status(row, APPROVED if approve else REJECTED, user_id)
    return row
