"""§22.4a SLICE-4/5 — entry metadata edit endpoints.

`POST .../rfcs/<slug>/meta` writes schema-defined metadata to an entry's sidecar
with a direct commit (D7: direct commit for authorized roles), validated against
the collection's field schema at the write boundary (INV-4), lazy-migrating a
legacy entry to a clean body-only `.md` on first edit. The Owner-gated
`metadata.migrate_collection` operator endpoint also lives here (SLICE-4 carried
work); SLICE-5's bulk endpoint will join it.
"""
from __future__ import annotations

from typing import Any

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

from . import (auth, cache, collections as collections_mod,
               metadata as metadata_mod, metadata_schema,
               projects as projects_mod)
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError


class MetaEditBody(BaseModel):
    values: dict[str, Any]


class BulkMetaBody(BaseModel):
    slugs: list[str]
    op: str
    field: str
    value: Any = None


def _apply_op(entry: Any, op: str, field: str, value: Any) -> Any:
    """Return the new value for `field` after applying `op` to `entry`.

    `set` → `value`; `add`/`remove` operate on the entry's current tags-list
    value for `field` (the route restricts add/remove to tags-type fields).
    """
    if op == "set":
        return value
    current = metadata_mod.metadata_dict(entry).get(field) or []
    if not isinstance(current, list):
        current = [current]
    if op == "add":
        return current if value in current else [*current, value]
    if op == "remove":
        return [x for x in current if x != value]
    return value  # unreachable; op validated by the route


def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
    router = APIRouter()

    def _content_repo(collection_id: str) -> tuple[str, str]:
        # §22/G-15: the COLLECTION's project content_repo, not the deployment
        # default — an entry in a non-default project writes its own repo.
        # Falls back to the default repo for an unknown collection.
        repo = (projects_mod.content_repo_for_collection(collection_id)
                or (projects_mod.default_content_repo(config) or ""))
        return config.gitea_org, repo

    def _md_path(collection_id: str, slug: str) -> str:
        sub = collections_mod.subfolder_of(collection_id) or ""
        rfcs_dir = f"{sub}/rfcs" if sub else "rfcs"
        return f"{rfcs_dir}/{slug}.md"

    @router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}/meta")
    async def edit_meta(
        project_id: str, collection_id: str, slug: str,
        body: MetaEditBody, request: Request,
    ) -> dict[str, Any]:
        viewer = auth.current_user(request)
        if collections_mod.project_of_collection(collection_id) != project_id:
            raise HTTPException(404, "Collection not in project")
        # INV-4: contributor+ on the collection (returns False for anonymous).
        if not auth.can_contribute_in_collection(viewer, collection_id):
            raise HTTPException(403, "Contributor access required to edit metadata")
        col = collections_mod.get_collection(collection_id)
        fields = (col or {}).get("fields") or {}
        if not fields:
            raise HTTPException(422, "Collection declares no editable fields")
        if not body.values:
            raise HTTPException(422, "Provide at least one field value")
        unknown = [k for k in body.values if k not in fields]
        if unknown:
            raise HTTPException(422, f"Unknown field(s): {', '.join(sorted(unknown))}")

        org, repo = _content_repo(collection_id)
        md_path = _md_path(collection_id, slug)
        st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
        if st is None:
            raise HTTPException(404, f"{md_path} not found")

        # Validate the *raw* submitted values (INV-4): catch a type mismatch
        # before `apply_values` coerces it — e.g. a scalar handed to a `tags`
        # field would otherwise char-split into a valid-looking list.
        problems = metadata_schema.validate(body.values, fields)
        if problems:
            raise HTTPException(422, {"problems": [p.as_dict() for p in problems]})
        new_entry = metadata_mod.apply_values(st.entry, body.values)

        files = metadata_mod.write_entry_files(md_path, new_entry, st)
        try:
            await bot.commit_entry_files(
                viewer.as_actor(), org=org, repo=repo, files=files,
                message=f"Edit metadata: {slug}", branch="main")
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")

        await cache.refresh_meta_repo(config, gitea)
        return {
            "ok": True, "slug": slug,
            "meta": metadata_mod.metadata_dict(new_entry),
        }

    @router.post("/api/projects/{project_id}/collections/{collection_id}/meta/bulk")
    async def bulk_meta(
        project_id: str, collection_id: str,
        body: BulkMetaBody, request: Request,
    ) -> dict[str, Any]:
        """§22.4a PUC-2 (SLICE-5): apply one field op to many entries at once.

        `set` works for any field; `add`/`remove` operate on a tags-type field.
        Each passing entry's metadata is validated at the write boundary
        (INV-4) and its sidecar staged; all stage into **one** commit (D7:
        bulk = 1 commit, reusing the SLICE-4 sidecar write-through). Entries
        that are missing or fail validation are reported in `rejected`; the
        rest in `applied`. A no-op (value unchanged) is applied without writing.
        """
        viewer = auth.current_user(request)
        if collections_mod.project_of_collection(collection_id) != project_id:
            raise HTTPException(404, "Collection not in project")
        # INV-4: contributor+ on the collection (returns False for anonymous).
        if not auth.can_contribute_in_collection(viewer, collection_id):
            raise HTTPException(403, "Contributor access required to edit metadata")
        col = collections_mod.get_collection(collection_id)
        fields = (col or {}).get("fields") or {}
        if not fields:
            raise HTTPException(422, "Collection declares no editable fields")
        if not body.slugs:
            raise HTTPException(422, "Provide at least one entry")
        if body.op not in ("set", "add", "remove"):
            raise HTTPException(422, f"Unknown op: {body.op}")
        if body.field not in fields:
            raise HTTPException(422, f"Unknown field: {body.field}")
        if body.op in ("add", "remove") and fields[body.field].get("type") != "tags":
            raise HTTPException(422, f"op {body.op} requires a tags field")

        org, repo = _content_repo(collection_id)
        applied: list[str] = []
        rejected: list[dict[str, str]] = []
        all_ops: list[dict[str, Any]] = []
        for slug in body.slugs:
            md_path = _md_path(collection_id, slug)
            st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
            if st is None:
                rejected.append({"slug": slug, "reason": "not found"})
                continue
            new_value = _apply_op(st.entry, body.op, body.field, body.value)
            # Validate the *raw* new value before coercion (see edit_meta) so a
            # scalar `set` onto a tags field is rejected, not char-split.
            problems = metadata_schema.validate({body.field: new_value}, fields)
            if problems:
                rejected.append({"slug": slug,
                                 "reason": "; ".join(p.message for p in problems)})
                continue
            applied.append(slug)
            new_entry = metadata_mod.apply_values(st.entry, {body.field: new_value})
            if metadata_mod.metadata_dict(new_entry) != metadata_mod.metadata_dict(st.entry):
                all_ops.extend(metadata_mod.write_entry_files(md_path, new_entry, st))

        committed = False
        if all_ops:
            n = len(applied)
            msg = f"Bulk {body.op} {body.field}: {n} entr{'y' if n == 1 else 'ies'}"
            try:
                await bot.commit_entry_files(
                    viewer.as_actor(), org=org, repo=repo, files=all_ops,
                    message=msg, branch="main")
            except GiteaError as e:
                raise HTTPException(502, f"Gitea: {e.detail}")
            committed = True
            await cache.refresh_meta_repo(config, gitea)
        return {"ok": True, "applied": applied,
                "rejected": rejected, "committed": committed}

    @router.post("/api/projects/{project_id}/collections/{collection_id}/migrate")
    async def migrate(
        project_id: str, collection_id: str, request: Request
    ) -> dict[str, Any]:
        """§22.4a PUC-5: migrate a collection's legacy-frontmatter entries to
        clean body-only `.md` + sidecars, one commit per collection. Owner-gated
        operator action. Safe to ship now that every entry write path is
        sidecar-aware (SLICE-4 carried work). Idempotent."""
        viewer = auth.current_user(request)
        if collections_mod.project_of_collection(collection_id) != project_id:
            raise HTTPException(404, "Collection not in project")
        if not auth.is_collection_superuser(viewer, collection_id):
            raise HTTPException(403, "Owner access required to migrate a collection")
        org, repo = _content_repo(collection_id)
        subfolder = collections_mod.subfolder_of(collection_id) or ""
        try:
            result = await metadata_mod.migrate_collection(
                gitea, org=org, repo=repo, subfolder=subfolder,
                actor=viewer.as_actor())
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")
        if result["committed"]:
            await cache.refresh_meta_repo(config, gitea)
        return result

    return router
