"""Research-project proposals (Thuyết minh đề tài) + lifecycle.

A PI fills the proposal form; the row goes draft → submitted; an admin approves it
(→ approved, the project's cockpit unlocks) or rejects it. The proposal row *is* the
project across its lifecycle; the cockpit's child entities (members/datasets/models/
assets/milestones) hang off it (added in phase 2).

Authz (v1): a project is readable / mutable by its owner OR a platform admin.
Approve / reject are admin-only. Every lifecycle transition writes an append-only audit row.

Mounted under ``/api/v1`` in main.py → routes live at ``/api/v1/research/*``.
"""
from __future__ import annotations

import uuid
from datetime import datetime, timezone
from typing import Any, Optional

from fastapi import APIRouter, Body, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, select

from src.auth_jwt import decode_access_token_user_id, decode_bearer_token
from src.initiative_db.engine import get_session, is_postgres_enabled
from src.initiative_db.models import (
    ResearchProject,
    ResearchProjectAsset,
    ResearchProjectAudit,
    ResearchProjectDataset,
    ResearchProjectMember,
    ResearchProjectMilestone,
    ResearchProjectModel,
    User,
)

router = APIRouter(prefix="/research", tags=["research"])

_STATUS_DRAFT = "draft"
_STATUS_SUBMITTED = "submitted"
_STATUS_APPROVED = "approved"
_STATUS_REJECTED = "rejected"

_ROLE_PI = "Chủ nhiệm (PI)"
_ROLE_ADMIN = "Quản trị viên"


# --------------------------------------------------------------------------- #
# Auth (mirrors template_routes / the extracted admin routers)
# --------------------------------------------------------------------------- #
def _jwt_roles(authorization: str | None) -> list[str]:
    p = decode_bearer_token(authorization)
    if not p:
        return []
    r = p.get("roles")
    return [str(x) for x in r] if isinstance(r, list) else []


def _require_authed_uid(authorization: str | None) -> uuid.UUID:
    uid = decode_access_token_user_id(authorization)
    if uid is None:
        raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.")
    return uid


def _is_admin(authorization: str | None) -> bool:
    return "admin" in _jwt_roles(authorization)


def _require_admin_uid(authorization: str | None) -> uuid.UUID:
    uid = _require_authed_uid(authorization)
    if not _is_admin(authorization):
        raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.")
    return uid


def _require_db() -> None:
    if not is_postgres_enabled():
        raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa sẵn sàng.")


# --------------------------------------------------------------------------- #
# Scalar extraction from the proposal content blob (for listing / filtering / overview)
# --------------------------------------------------------------------------- #
def _coerce_int(v: Any) -> Optional[int]:
    if v is None or v == "":
        return None
    try:
        return int(float(str(v).strip()))
    except (ValueError, TypeError):
        return None


def _coerce_float(v: Any) -> Optional[float]:
    if v is None or v == "":
        return None
    try:
        return float(str(v).strip())
    except (ValueError, TypeError):
        return None


def _extract_scalars(content: Any) -> dict[str, Any]:
    """Pull the queryable scalars out of the (flat, dotted-key) proposal form values."""
    c = content if isinstance(content, dict) else {}
    return {
        "title": str(c.get("tenDeTai") or "").strip(),
        "level": str(c.get("capDeTai") or "").strip(),
        "pi_name": str(c.get("chuNhiem.hoTen") or "").strip(),
        "period_months": _coerce_int(c.get("thoiGianThucHienThang")),
        "budget_total": _coerce_float(c.get("tongKinhPhi")),
    }


# --------------------------------------------------------------------------- #
# Schemas
# --------------------------------------------------------------------------- #
class ProjectOut(BaseModel):
    id: str
    ownerUserId: str
    status: str
    code: Optional[str] = None
    title: str = ""
    level: str = ""
    piName: str = ""
    periodMonths: Optional[int] = None
    budgetTotal: Optional[float] = None
    content: dict[str, Any] = Field(default_factory=dict)
    submittedAt: Optional[datetime] = None
    reviewedAt: Optional[datetime] = None
    reviewNote: Optional[str] = None
    createdAt: Optional[datetime] = None
    updatedAt: Optional[datetime] = None


class ProjectCreateIn(BaseModel):
    content: dict[str, Any] = Field(default_factory=dict)


class ProjectUpdateIn(BaseModel):
    content: dict[str, Any] = Field(default_factory=dict)


class ProjectDetailPatchIn(BaseModel):
    """Partial administrative-detail patch merged into an approved project's content."""
    patch: dict[str, Any] = Field(default_factory=dict)


class ApproveIn(BaseModel):
    code: Optional[str] = Field(default=None, max_length=100)
    note: Optional[str] = Field(default=None, max_length=2000)


class RejectIn(BaseModel):
    note: Optional[str] = Field(default=None, max_length=2000)


class AuditOut(BaseModel):
    id: int
    occurredAt: Optional[datetime] = None
    actorName: str = ""
    roleLabel: str = ""
    action: str
    subject: str = ""
    detail: str = ""


def _to_out(row: ResearchProject) -> ProjectOut:
    return ProjectOut(
        id=str(row.id),
        ownerUserId=str(row.owner_user_id),
        status=row.status,
        code=row.code,
        title=row.title or "",
        level=row.level or "",
        piName=row.pi_name or "",
        periodMonths=row.period_months,
        budgetTotal=float(row.budget_total) if row.budget_total is not None else None,
        content=row.content if isinstance(row.content, dict) else {},
        submittedAt=row.submitted_at,
        reviewedAt=row.reviewed_at,
        reviewNote=row.review_note,
        createdAt=row.created_at,
        updatedAt=row.updated_at,
    )


# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
async def _load_project(session, project_id: str, uid: uuid.UUID, is_admin: bool) -> ResearchProject:
    """Fetch a project enforcing owner-or-admin read access (404 hides others' rows)."""
    try:
        pid = uuid.UUID(project_id)
    except (ValueError, TypeError):
        raise HTTPException(status_code=404, detail="Không tìm thấy đề tài.")
    row = (
        await session.execute(select(ResearchProject).where(ResearchProject.id == pid))
    ).scalar_one_or_none()
    if row is None or (not is_admin and row.owner_user_id != uid):
        raise HTTPException(status_code=404, detail="Không tìm thấy đề tài.")
    return row


async def _actor_name(session, uid: uuid.UUID) -> str:
    u = await session.get(User, uid)
    if u is None:
        return ""
    return (u.full_name or u.email or "").strip()


async def _write_audit(
    session,
    project_id: uuid.UUID,
    actor_uid: Optional[uuid.UUID],
    actor_name: str,
    role_label: str,
    action: str,
    subject: str = "",
    detail: str = "",
) -> None:
    session.add(
        ResearchProjectAudit(
            project_id=project_id,
            actor_user_id=actor_uid,
            actor_name=actor_name or "",
            role_label=role_label or "",
            action=action,
            subject=subject or "",
            detail=detail or "",
        )
    )


# --------------------------------------------------------------------------- #
# Endpoints — proposals lifecycle
# --------------------------------------------------------------------------- #
@router.post("/projects", response_model=ProjectOut)
async def create_project(
    payload: Optional[ProjectCreateIn] = Body(None),
    authorization: Optional[str] = Header(None),
) -> ProjectOut:
    """Authed: create a draft proposal owned by the current user."""
    _require_db()
    uid = _require_authed_uid(authorization)
    content = (payload.content if payload and isinstance(payload.content, dict) else {})
    scalars = _extract_scalars(content)
    async with get_session() as session:
        row = ResearchProject(id=uuid.uuid4(), owner_user_id=uid, status=_STATUS_DRAFT, content=content, **scalars)
        session.add(row)
        await session.flush()
        await _write_audit(
            session, row.id, uid, await _actor_name(session, uid), _ROLE_PI,
            "Tạo bản thảo đề tài", scalars["title"] or "(chưa đặt tên)",
        )
        await session.commit()
        await session.refresh(row)
        return _to_out(row)


@router.get("/projects", response_model=list[ProjectOut])
async def list_projects(mine: bool = True, authorization: Optional[str] = Header(None)) -> list[ProjectOut]:
    """List projects. Non-admin always sees only their own; admin can pass ?mine=false to see all."""
    _require_db()
    uid = _require_authed_uid(authorization)
    is_admin = _is_admin(authorization)
    async with get_session() as session:
        stmt = select(ResearchProject).order_by(ResearchProject.created_at.desc())
        if mine or not is_admin:
            stmt = stmt.where(ResearchProject.owner_user_id == uid)
        rows = (await session.execute(stmt)).scalars().all()
        return [_to_out(r) for r in rows]


@router.get("/projects/{project_id}", response_model=ProjectOut)
async def get_project(project_id: str, authorization: Optional[str] = Header(None)) -> ProjectOut:
    _require_db()
    uid = _require_authed_uid(authorization)
    async with get_session() as session:
        return _to_out(await _load_project(session, project_id, uid, _is_admin(authorization)))


@router.put("/projects/{project_id}", response_model=ProjectOut)
async def update_project(
    project_id: str,
    payload: ProjectUpdateIn = Body(...),
    authorization: Optional[str] = Header(None),
) -> ProjectOut:
    """Owner: replace the draft proposal content. Allowed only while status=draft."""
    _require_db()
    uid = _require_authed_uid(authorization)
    async with get_session() as session:
        row = await _load_project(session, project_id, uid, _is_admin(authorization))
        if row.owner_user_id != uid:
            raise HTTPException(status_code=403, detail="Chỉ chủ nhiệm mới sửa được bản thảo.")
        if row.status != _STATUS_DRAFT:
            raise HTTPException(status_code=409, detail="Chỉ sửa được khi đề tài ở trạng thái bản thảo.")
        content = payload.content if isinstance(payload.content, dict) else {}
        row.content = content
        for k, v in _extract_scalars(content).items():
            setattr(row, k, v)
        row.updated_at = datetime.now(tz=timezone.utc)
        await session.commit()
        await session.refresh(row)
        return _to_out(row)


@router.put("/projects/{project_id}/detail", response_model=ProjectOut)
async def update_project_detail(
    project_id: str,
    payload: ProjectDetailPatchIn = Body(...),
    authorization: Optional[str] = Header(None),
) -> ProjectOut:
    """Owner-or-admin: shallow-merge administrative-detail fields into an approved
    project's content JSONB. Unlike ``update_project`` (draft-only, wholesale-replace),
    this serves the cockpit: it is allowed once the project is approved and MERGES the
    patch into existing content so the original proposal keys survive. Writes an audit row."""
    _require_db()
    uid = _require_authed_uid(authorization)
    async with get_session() as session:
        row = await _load_project(session, project_id, uid, _is_admin(authorization))
        _require_approved(row)
        patch = payload.patch if isinstance(payload.patch, dict) else {}
        merged = {**(row.content if isinstance(row.content, dict) else {}), **patch}
        row.content = merged
        for k, v in _extract_scalars(merged).items():
            setattr(row, k, v)
        row.updated_at = datetime.now(tz=timezone.utc)
        await _write_audit(
            session, row.id, uid, await _actor_name(session, uid),
            _role_label(authorization), "Cập nhật thông tin đề tài", "",
            f"{len(patch)} trường",
        )
        await session.commit()
        await session.refresh(row)
        return _to_out(row)


@router.post("/projects/{project_id}/submit", response_model=ProjectOut)
async def submit_project(project_id: str, authorization: Optional[str] = Header(None)) -> ProjectOut:
    """Owner: submit a draft for review (draft → submitted)."""
    _require_db()
    uid = _require_authed_uid(authorization)
    async with get_session() as session:
        row = await _load_project(session, project_id, uid, _is_admin(authorization))
        if row.owner_user_id != uid:
            raise HTTPException(status_code=403, detail="Chỉ chủ nhiệm mới nộp được đề tài.")
        if row.status != _STATUS_DRAFT:
            raise HTTPException(status_code=409, detail="Đề tài đã được nộp hoặc đã xử lý.")
        if not (row.title or "").strip():
            raise HTTPException(status_code=422, detail="Cần nhập tên đề tài trước khi nộp.")
        row.status = _STATUS_SUBMITTED
        row.submitted_at = datetime.now(tz=timezone.utc)
        row.updated_at = row.submitted_at
        await _write_audit(
            session, row.id, uid, await _actor_name(session, uid), _ROLE_PI, "Nộp đề tài", row.title,
        )
        await session.commit()
        await session.refresh(row)
        return _to_out(row)


@router.post("/projects/{project_id}/approve", response_model=ProjectOut)
async def approve_project(
    project_id: str,
    payload: Optional[ApproveIn] = Body(None),
    authorization: Optional[str] = Header(None),
) -> ProjectOut:
    """Admin: approve a submitted proposal (submitted → approved); optionally assign its code."""
    _require_db()
    admin_uid = _require_admin_uid(authorization)
    async with get_session() as session:
        row = await _load_project(session, project_id, admin_uid, True)
        if row.status != _STATUS_SUBMITTED:
            raise HTTPException(status_code=409, detail="Chỉ duyệt được đề tài đang chờ duyệt.")
        code = (payload.code if payload else None) or ""
        note = (payload.note if payload else None) or ""
        if code.strip():
            row.code = code.strip()
        row.status = _STATUS_APPROVED
        row.reviewed_by = admin_uid
        row.reviewed_at = datetime.now(tz=timezone.utc)
        row.review_note = note.strip() or None
        row.updated_at = row.reviewed_at
        await _seed_cockpit_from_proposal(session, row)
        await _write_audit(
            session, row.id, admin_uid, await _actor_name(session, admin_uid), _ROLE_ADMIN,
            "Phê duyệt đề tài", row.code or row.title, row.review_note or "",
        )
        await session.commit()
        await session.refresh(row)
        return _to_out(row)


@router.post("/projects/{project_id}/reject", response_model=ProjectOut)
async def reject_project(
    project_id: str,
    payload: Optional[RejectIn] = Body(None),
    authorization: Optional[str] = Header(None),
) -> ProjectOut:
    """Admin: reject a submitted proposal (submitted → rejected) with an optional note."""
    _require_db()
    admin_uid = _require_admin_uid(authorization)
    async with get_session() as session:
        row = await _load_project(session, project_id, admin_uid, True)
        if row.status != _STATUS_SUBMITTED:
            raise HTTPException(status_code=409, detail="Chỉ từ chối được đề tài đang chờ duyệt.")
        note = (payload.note if payload else None) or ""
        row.status = _STATUS_REJECTED
        row.reviewed_by = admin_uid
        row.reviewed_at = datetime.now(tz=timezone.utc)
        row.review_note = note.strip() or None
        row.updated_at = row.reviewed_at
        await _write_audit(
            session, row.id, admin_uid, await _actor_name(session, admin_uid), _ROLE_ADMIN,
            "Từ chối đề tài", row.title, row.review_note or "",
        )
        await session.commit()
        await session.refresh(row)
        return _to_out(row)


@router.delete("/projects/{project_id}")
async def delete_project(project_id: str, authorization: Optional[str] = Header(None)) -> dict[str, Any]:
    """Owner may delete a draft; admin may delete any. Children + audit cascade in the DB."""
    _require_db()
    uid = _require_authed_uid(authorization)
    is_admin = _is_admin(authorization)
    async with get_session() as session:
        row = await _load_project(session, project_id, uid, is_admin)
        if not is_admin:
            if row.owner_user_id != uid:
                raise HTTPException(status_code=404, detail="Không tìm thấy đề tài.")
            if row.status != _STATUS_DRAFT:
                raise HTTPException(status_code=409, detail="Chỉ xóa được bản thảo; đề tài đã nộp cần liên hệ quản trị.")
        await session.delete(row)
        await session.commit()
    return {"ok": True}


@router.get("/projects/{project_id}/audit", response_model=list[AuditOut])
async def list_audit(project_id: str, authorization: Optional[str] = Header(None)) -> list[AuditOut]:
    """Owner or admin: the append-only audit trail for a project, newest first."""
    _require_db()
    uid = _require_authed_uid(authorization)
    async with get_session() as session:
        row = await _load_project(session, project_id, uid, _is_admin(authorization))
        rows = (
            await session.execute(
                select(ResearchProjectAudit)
                .where(ResearchProjectAudit.project_id == row.id)
                .order_by(ResearchProjectAudit.occurred_at.desc(), ResearchProjectAudit.id.desc())
            )
        ).scalars().all()
        return [
            AuditOut(
                id=r.id,
                occurredAt=r.occurred_at,
                actorName=r.actor_name,
                roleLabel=r.role_label,
                action=r.action,
                subject=r.subject,
                detail=r.detail,
            )
            for r in rows
        ]


# --------------------------------------------------------------------------- #
# Cockpit entities — generic, config-driven CRUD (phase 2)
# The 5 child entity types share one CRUD surface keyed by an entity config.
# Mutations require the project to be approved (the cockpit "unlocks" on approval) and
# are allowed for the owner OR an admin; every mutation writes an audit row.
# --------------------------------------------------------------------------- #
_TEXT, _INT, _NUM = "text", "int", "num"

_ENTITY_CONFIG: dict[str, dict[str, Any]] = {
    "members": {
        "model": ResearchProjectMember,
        "singular": "thành viên",
        "primary": "name",
        "fields": [
            ("name", "name", _TEXT),
            ("role", "role", _TEXT),
            ("access", "access", _TEXT),
            ("org", "org", _TEXT),
            ("email", "email", _TEXT),
            ("months", "months", _INT),
            ("tasks", "tasks", _TEXT),
            ("status", "status", _TEXT),
        ],
    },
    "datasets": {
        "model": ResearchProjectDataset,
        "singular": "bộ dữ liệu",
        "primary": "name",
        "fields": [
            ("name", "name", _TEXT),
            ("type", "type", _TEXT),
            ("records", "records", _INT),
            ("source", "source", _TEXT),
            ("sensitivity", "sensitivity", _TEXT),
            ("ethics", "ethics", _TEXT),
            ("owner", "owner", _TEXT),
            ("status", "status", _TEXT),
        ],
    },
    "models": {
        "model": ResearchProjectModel,
        "singular": "mô hình",
        "primary": "name",
        "fields": [
            ("name", "name", _TEXT),
            ("task", "task", _TEXT),
            ("framework", "framework", _TEXT),
            ("version", "version", _TEXT),
            ("dataset", "dataset", _TEXT),
            ("auc", "auc", _NUM),
            ("sensitivity", "sensitivity", _NUM),
            ("specificity", "specificity", _NUM),
            ("accuracy", "accuracy", _NUM),
            ("owner", "owner", _TEXT),
            ("notes", "notes", _TEXT),
            ("status", "status", _TEXT),
        ],
    },
    "assets": {
        "model": ResearchProjectAsset,
        "singular": "tài sản",
        "primary": "name",
        "fields": [
            ("name", "name", _TEXT),
            ("category", "category", _TEXT),
            ("acquisition", "acquisition", _TEXT),
            ("value", "value", _NUM),
            ("owner", "owner", _TEXT),
            ("notes", "notes", _TEXT),
            ("status", "status", _TEXT),
        ],
    },
    "milestones": {
        "model": ResearchProjectMilestone,
        "singular": "mốc tiến độ",
        "primary": "title",
        "fields": [
            ("title", "title", _TEXT),
            ("deliverable", "deliverable", _TEXT),
            ("start", "start_period", _TEXT),
            ("end", "end_period", _TEXT),
            ("owner", "owner", _TEXT),
            ("budget", "budget", _NUM),
            ("progress", "progress", _INT),
            ("status", "status", _TEXT),
        ],
    },
}


def _coerce_value(kind: str, v: Any) -> Any:
    if kind == _INT:
        return _coerce_int(v)
    if kind == _NUM:
        return _coerce_float(v)
    return "" if v is None else str(v)


def _apply_fields(row: Any, cfg: dict[str, Any], data: dict[str, Any]) -> None:
    """Whitelist-copy known fields from a client dict into an ORM row (prevents column injection)."""
    for json_key, column, kind in cfg["fields"]:
        if json_key in data:
            setattr(row, column, _coerce_value(kind, data[json_key]))


def _entity_to_out(cfg: dict[str, Any], row: Any) -> dict[str, Any]:
    out: dict[str, Any] = {"id": str(row.id), "sortOrder": row.sort_order}
    for json_key, column, kind in cfg["fields"]:
        val = getattr(row, column)
        out[json_key] = float(val) if (kind == _NUM and val is not None) else val
    return out


def _entity_cfg_or_404(entity: str) -> dict[str, Any]:
    cfg = _ENTITY_CONFIG.get(entity)
    if cfg is None:
        raise HTTPException(status_code=404, detail="Không tìm thấy loại dữ liệu.")
    return cfg


def _role_label(authorization: str | None) -> str:
    return _ROLE_ADMIN if _is_admin(authorization) else _ROLE_PI


def _require_approved(project: ResearchProject) -> None:
    if project.status != _STATUS_APPROVED:
        raise HTTPException(status_code=409, detail="Chỉ quản lý được dữ liệu sau khi đề tài được phê duyệt.")


async def _entity_list(session, cfg: dict[str, Any], project_id: uuid.UUID) -> list[dict[str, Any]]:
    model = cfg["model"]
    rows = (
        await session.execute(
            select(model).where(model.project_id == project_id).order_by(model.sort_order, model.created_at)
        )
    ).scalars().all()
    return [_entity_to_out(cfg, r) for r in rows]


async def _load_entity_or_404(session, cfg: dict[str, Any], project_id: uuid.UUID, item_id: str):
    model = cfg["model"]
    try:
        iid = uuid.UUID(item_id)
    except (ValueError, TypeError):
        raise HTTPException(status_code=404, detail="Không tìm thấy mục.")
    row = (
        await session.execute(select(model).where(model.id == iid, model.project_id == project_id))
    ).scalar_one_or_none()
    if row is None:
        raise HTTPException(status_code=404, detail="Không tìm thấy mục.")
    return row


# --- seeding the cockpit from the proposal content (best-effort, on approval) ---
def _seed_members_from_content(content: Any) -> list[dict[str, Any]]:
    c = content if isinstance(content, dict) else {}
    out: list[dict[str, Any]] = []
    pi_name = str(c.get("chuNhiem.hoTen") or "").strip()
    if pi_name:
        out.append(
            {
                "name": pi_name,
                "role": "Chủ nhiệm đề tài",
                "access": _ROLE_PI,
                "org": str(c.get("chuNhiem.tenToChuc") or ""),
                "email": str(c.get("chuNhiem.email") or ""),
                "status": "Đang hoạt động",
            }
        )
    sec_name = str(c.get("thuKy.hoTen") or "").strip()
    if sec_name:
        out.append(
            {
                "name": sec_name,
                "role": "Thư ký khoa học",
                "access": "Điều phối / Thư ký",
                "org": str(c.get("thuKy.tenToChuc") or ""),
                "email": str(c.get("thuKy.email") or ""),
                "status": "Đang hoạt động",
            }
        )
    members_raw = c.get("thanhVienThucHien")
    for m in members_raw if isinstance(members_raw, list) else []:
        if isinstance(m, dict) and str(m.get("hoTenHocVi") or "").strip():
            out.append(
                {
                    "name": str(m.get("hoTenHocVi")),
                    "role": str(m.get("chucDanh") or "Thành viên chính"),
                    "org": str(m.get("toChucCongTac") or ""),
                    "status": "Đang hoạt động",
                }
            )
    return out


def _seed_milestones_from_content(content: Any) -> list[dict[str, Any]]:
    c = content if isinstance(content, dict) else {}
    out: list[dict[str, Any]] = []
    timeline_raw = c.get("tienDoThucHien")
    for t in timeline_raw if isinstance(timeline_raw, list) else []:
        if isinstance(t, dict) and str(t.get("noiDungCongViec") or "").strip():
            out.append(
                {
                    "title": str(t.get("noiDungCongViec")),
                    "deliverable": str(t.get("ketQua") or ""),
                    "start": str(t.get("thoiGian") or ""),
                    "owner": str(t.get("caNhanToChuc") or ""),
                    "budget": t.get("kinhPhi"),
                    "progress": 0,
                    "status": "Chưa bắt đầu",
                }
            )
    return out


async def _seed_cockpit_from_proposal(session, project: ResearchProject) -> None:
    """On approval, populate members + milestones from the proposal (only when none exist yet)."""
    existing = (
        await session.execute(
            select(func.count())
            .select_from(ResearchProjectMember)
            .where(ResearchProjectMember.project_id == project.id)
        )
    ).scalar_one()
    if existing:
        return
    for i, m in enumerate(_seed_members_from_content(project.content)):
        row = ResearchProjectMember(id=uuid.uuid4(), project_id=project.id, sort_order=i)
        _apply_fields(row, _ENTITY_CONFIG["members"], m)
        session.add(row)
    for i, t in enumerate(_seed_milestones_from_content(project.content)):
        row = ResearchProjectMilestone(id=uuid.uuid4(), project_id=project.id, sort_order=i)
        _apply_fields(row, _ENTITY_CONFIG["milestones"], t)
        session.add(row)


# --- entity endpoints ---
@router.get("/projects/{project_id}/entities/{entity}")
async def list_entity(
    project_id: str, entity: str, authorization: Optional[str] = Header(None)
) -> list[dict[str, Any]]:
    _require_db()
    uid = _require_authed_uid(authorization)
    cfg = _entity_cfg_or_404(entity)
    async with get_session() as session:
        project = await _load_project(session, project_id, uid, _is_admin(authorization))
        return await _entity_list(session, cfg, project.id)


@router.post("/projects/{project_id}/entities/{entity}")
async def create_entity(
    project_id: str,
    entity: str,
    payload: Optional[dict[str, Any]] = Body(None),
    authorization: Optional[str] = Header(None),
) -> dict[str, Any]:
    _require_db()
    uid = _require_authed_uid(authorization)
    cfg = _entity_cfg_or_404(entity)
    data = payload if isinstance(payload, dict) else {}
    async with get_session() as session:
        project = await _load_project(session, project_id, uid, _is_admin(authorization))
        _require_approved(project)
        count = (
            await session.execute(
                select(func.count()).select_from(cfg["model"]).where(cfg["model"].project_id == project.id)
            )
        ).scalar_one()
        row = cfg["model"](id=uuid.uuid4(), project_id=project.id, sort_order=int(count or 0))
        _apply_fields(row, cfg, data)
        session.add(row)
        await session.flush()
        subject = str(getattr(row, cfg["primary"]) or "")
        await _write_audit(
            session, project.id, uid, await _actor_name(session, uid),
            _role_label(authorization), f"Thêm {cfg['singular']}", subject,
        )
        await session.commit()
        await session.refresh(row)
        return _entity_to_out(cfg, row)


@router.put("/projects/{project_id}/entities/{entity}/{item_id}")
async def update_entity(
    project_id: str,
    entity: str,
    item_id: str,
    payload: dict[str, Any] = Body(...),
    authorization: Optional[str] = Header(None),
) -> dict[str, Any]:
    _require_db()
    uid = _require_authed_uid(authorization)
    cfg = _entity_cfg_or_404(entity)
    async with get_session() as session:
        project = await _load_project(session, project_id, uid, _is_admin(authorization))
        _require_approved(project)
        row = await _load_entity_or_404(session, cfg, project.id, item_id)
        _apply_fields(row, cfg, payload if isinstance(payload, dict) else {})
        row.updated_at = datetime.now(tz=timezone.utc)
        await session.flush()
        subject = str(getattr(row, cfg["primary"]) or "")
        await _write_audit(
            session, project.id, uid, await _actor_name(session, uid),
            _role_label(authorization), f"Cập nhật {cfg['singular']}", subject,
        )
        await session.commit()
        await session.refresh(row)
        return _entity_to_out(cfg, row)


@router.delete("/projects/{project_id}/entities/{entity}/{item_id}")
async def delete_entity(
    project_id: str,
    entity: str,
    item_id: str,
    authorization: Optional[str] = Header(None),
) -> dict[str, Any]:
    _require_db()
    uid = _require_authed_uid(authorization)
    cfg = _entity_cfg_or_404(entity)
    async with get_session() as session:
        project = await _load_project(session, project_id, uid, _is_admin(authorization))
        _require_approved(project)
        row = await _load_entity_or_404(session, cfg, project.id, item_id)
        subject = str(getattr(row, cfg["primary"]) or "")
        await session.delete(row)
        await _write_audit(
            session, project.id, uid, await _actor_name(session, uid),
            _role_label(authorization), f"Xóa {cfg['singular']}", subject,
        )
        await session.commit()
    return {"ok": True}


@router.get("/projects/{project_id}/cockpit")
async def get_cockpit(project_id: str, authorization: Optional[str] = Header(None)) -> dict[str, Any]:
    """Owner or admin: the whole cockpit in one shot — project + 5 entity lists + recent audit."""
    _require_db()
    uid = _require_authed_uid(authorization)
    async with get_session() as session:
        project = await _load_project(session, project_id, uid, _is_admin(authorization))
        bundle: dict[str, Any] = {"project": _to_out(project).model_dump(mode="json")}
        for name, cfg in _ENTITY_CONFIG.items():
            bundle[name] = await _entity_list(session, cfg, project.id)
        audit_rows = (
            await session.execute(
                select(ResearchProjectAudit)
                .where(ResearchProjectAudit.project_id == project.id)
                .order_by(ResearchProjectAudit.occurred_at.desc(), ResearchProjectAudit.id.desc())
                .limit(200)
            )
        ).scalars().all()
        bundle["audit"] = [
            AuditOut(
                id=r.id, occurredAt=r.occurred_at, actorName=r.actor_name, roleLabel=r.role_label,
                action=r.action, subject=r.subject, detail=r.detail,
            ).model_dump(mode="json")
            for r in audit_rows
        ]
        return bundle
