"""Tests for the research-project routes (proposals lifecycle).

The pure-helper unit tests always run. The full lifecycle integration test runs only when
INITIATIVE_DATABASE_URL points at PostgreSQL (asyncpg), e.g.:

  export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives"
  cd be0 && python -m unittest tests.test_research_routes -v

Prereq for the DB test: migration 016_research_projects.sql applied (compose init mount or
scripts/apply_initiative_migrations.py).
"""
from __future__ import annotations

import os
import unittest
import uuid

_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql")


class ExtractScalarsTests(unittest.TestCase):
    """Pure unit tests for the proposal-content scalar extraction (no DB)."""

    def test_reads_dotted_keys_and_coerces(self) -> None:
        from src.research_routes import _extract_scalars

        s = _extract_scalars(
            {
                "tenDeTai": "  AI dự đoán di căn  ",
                "capDeTai": "Thành phố",
                "chuNhiem.hoTen": "TS. Nguyễn Văn A",
                "thoiGianThucHienThang": "24",
                "tongKinhPhi": "1800.5",
            }
        )
        self.assertEqual(s["title"], "AI dự đoán di căn")
        self.assertEqual(s["level"], "Thành phố")
        self.assertEqual(s["pi_name"], "TS. Nguyễn Văn A")
        self.assertEqual(s["period_months"], 24)
        self.assertAlmostEqual(s["budget_total"], 1800.5)

    def test_missing_and_garbage_values(self) -> None:
        from src.research_routes import _extract_scalars

        empty = _extract_scalars({})
        self.assertEqual(empty["title"], "")
        self.assertIsNone(empty["period_months"])
        self.assertIsNone(empty["budget_total"])

        garbage = _extract_scalars({"thoiGianThucHienThang": "abc", "tongKinhPhi": ""})
        self.assertIsNone(garbage["period_months"])
        self.assertIsNone(garbage["budget_total"])

    def test_non_dict_content(self) -> None:
        from src.research_routes import _extract_scalars

        self.assertEqual(_extract_scalars(None)["title"], "")
        self.assertEqual(_extract_scalars("nope")["pi_name"], "")


def _bearer(uid: uuid.UUID, roles: list[str]) -> str:
    import jwt

    from src.auth_jwt import jwt_secret

    return "Bearer " + jwt.encode({"sub": str(uid), "roles": roles, "cv": 0}, jwt_secret(), algorithm="HS256")


@unittest.skipUnless(
    _RUN_DB,
    "Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests",
)
class ResearchLifecycleDbTests(unittest.IsolatedAsyncioTestCase):
    """End-to-end: draft → submit → approve, owner/admin authz, and the audit trail."""

    async def asyncSetUp(self) -> None:
        from src.initiative_db import engine as eng

        await eng.dispose_engine()
        await eng.init_engine()
        self._user_ids: list[uuid.UUID] = []
        self._project_ids: list[uuid.UUID] = []

    async def asyncTearDown(self) -> None:
        from sqlalchemy import delete

        from src.initiative_db import engine as eng
        from src.initiative_db.engine import get_session
        from src.initiative_db.models import ResearchProject, User

        async with get_session() as session:
            for pid in self._project_ids:
                await session.execute(delete(ResearchProject).where(ResearchProject.id == pid))
            for uid in self._user_ids:
                await session.execute(delete(User).where(User.id == uid))
            await session.commit()
        await eng.dispose_engine()

    async def _seed_user(self, *, admin: bool = False) -> uuid.UUID:
        from src.initiative_db.engine import get_session
        from src.initiative_db.models import User

        uid = uuid.uuid4()
        async with get_session() as session:
            session.add(
                User(
                    id=uid,
                    email=f"rp-{uid.hex[:10]}@ump.edu.vn",
                    password_hash="x",
                    full_name=("Quản trị" if admin else "Chủ nhiệm") + " Test",
                )
            )
            await session.commit()
        self._user_ids.append(uid)
        return uid

    async def test_full_lifecycle_and_authz(self) -> None:
        from fastapi import HTTPException

        from src.research_routes import (
            ApproveIn,
            ProjectCreateIn,
            approve_project,
            create_project,
            get_project,
            list_audit,
            submit_project,
        )

        owner = await self._seed_user()
        admin = await self._seed_user(admin=True)
        other = await self._seed_user()
        owner_tok = _bearer(owner, ["viewer"])
        admin_tok = _bearer(admin, ["admin"])
        other_tok = _bearer(other, ["viewer"])

        # create draft
        created = await create_project(
            ProjectCreateIn(
                content={
                    "tenDeTai": "Đề tài thử nghiệm",
                    "capDeTai": "Cơ sở",
                    "thoiGianThucHienThang": "12",
                    "tongKinhPhi": "500",
                }
            ),
            owner_tok,
        )
        self._project_ids.append(uuid.UUID(created.id))
        self.assertEqual(created.status, "draft")
        self.assertEqual(created.title, "Đề tài thử nghiệm")
        self.assertEqual(created.periodMonths, 12)

        # another user cannot read it (404 hides existence)
        with self.assertRaises(HTTPException) as ctx_read:
            await get_project(created.id, other_tok)
        self.assertEqual(ctx_read.exception.status_code, 404)

        # submit (owner)
        submitted = await submit_project(created.id, owner_tok)
        self.assertEqual(submitted.status, "submitted")
        self.assertIsNotNone(submitted.submittedAt)

        # owner cannot approve (admin-only) → 403
        with self.assertRaises(HTTPException) as ctx_appr:
            await approve_project(created.id, ApproveIn(), owner_tok)
        self.assertEqual(ctx_appr.exception.status_code, 403)

        # admin approves with a code
        approved = await approve_project(created.id, ApproveIn(code="ĐTUD-TEST", note="Đạt"), admin_tok)
        self.assertEqual(approved.status, "approved")
        self.assertEqual(approved.code, "ĐTUD-TEST")
        self.assertIsNotNone(approved.reviewedAt)

        # cannot re-approve (not submitted anymore) → 409
        with self.assertRaises(HTTPException) as ctx_reappr:
            await approve_project(created.id, ApproveIn(), admin_tok)
        self.assertEqual(ctx_reappr.exception.status_code, 409)

        # audit trail recorded each transition (owner can read)
        audit = await list_audit(created.id, owner_tok)
        actions = [a.action for a in audit]
        self.assertIn("Tạo bản thảo đề tài", actions)
        self.assertIn("Nộp đề tài", actions)
        self.assertIn("Phê duyệt đề tài", actions)

    async def test_reject_path_and_admin_cannot_edit_others_draft(self) -> None:
        from fastapi import HTTPException

        from src.research_routes import (
            ProjectCreateIn,
            ProjectUpdateIn,
            RejectIn,
            create_project,
            reject_project,
            submit_project,
            update_project,
        )

        owner = await self._seed_user()
        admin = await self._seed_user(admin=True)
        owner_tok = _bearer(owner, ["viewer"])
        admin_tok = _bearer(admin, ["admin"])

        created = await create_project(ProjectCreateIn(content={"tenDeTai": "Đề tài bị từ chối"}), owner_tok)
        self._project_ids.append(uuid.UUID(created.id))

        # admin can load it but cannot update/submit someone else's draft (owner-only) → 403
        with self.assertRaises(HTTPException) as ctx_upd:
            await update_project(created.id, ProjectUpdateIn(content={"tenDeTai": "x"}), admin_tok)
        self.assertEqual(ctx_upd.exception.status_code, 403)
        with self.assertRaises(HTTPException) as ctx_sub:
            await submit_project(created.id, admin_tok)
        self.assertEqual(ctx_sub.exception.status_code, 403)

        # owner submits, admin rejects with a note
        await submit_project(created.id, owner_tok)
        rejected = await reject_project(created.id, RejectIn(note="Chưa đạt yêu cầu"), admin_tok)
        self.assertEqual(rejected.status, "rejected")
        self.assertEqual(rejected.reviewNote, "Chưa đạt yêu cầu")

        # cannot submit a rejected proposal (not draft) → 409
        with self.assertRaises(HTTPException) as ctx_resub:
            await submit_project(created.id, owner_tok)
        self.assertEqual(ctx_resub.exception.status_code, 409)

    async def test_cockpit_entities_crud_seeding_and_gate(self) -> None:
        from fastapi import HTTPException

        from src.research_routes import (
            ApproveIn,
            ProjectCreateIn,
            approve_project,
            create_entity,
            create_project,
            delete_entity,
            get_cockpit,
            list_entity,
            submit_project,
            update_entity,
        )

        owner = await self._seed_user()
        admin = await self._seed_user(admin=True)
        owner_tok = _bearer(owner, ["viewer"])
        admin_tok = _bearer(admin, ["admin"])

        created = await create_project(
            ProjectCreateIn(
                content={
                    "tenDeTai": "Đề tài cockpit",
                    "chuNhiem.hoTen": "TS. PI A",
                    "thanhVienThucHien": [{"hoTenHocVi": "KS. Lê C", "chucDanh": "Thành viên chính"}],
                    "tienDoThucHien": [{"noiDungCongViec": "ND1", "ketQua": "Báo cáo", "thoiGian": "T1-T3"}],
                }
            ),
            owner_tok,
        )
        pid = created.id
        self._project_ids.append(uuid.UUID(pid))

        # entity mutation before approval is locked → 409
        with self.assertRaises(HTTPException) as ctx_gate:
            await create_entity(pid, "datasets", {"name": "X"}, owner_tok)
        self.assertEqual(ctx_gate.exception.status_code, 409)

        # unknown entity type → 404
        with self.assertRaises(HTTPException) as ctx_unknown:
            await list_entity(pid, "nope", owner_tok)
        self.assertEqual(ctx_unknown.exception.status_code, 404)

        await submit_project(pid, owner_tok)
        await approve_project(pid, ApproveIn(code="C1"), admin_tok)

        # approval seeded members (PI + 1 member) + milestones (1) from the proposal content
        members = await list_entity(pid, "members", owner_tok)
        self.assertGreaterEqual(len(members), 2)
        self.assertTrue(any(m["name"] == "TS. PI A" for m in members))
        milestones = await list_entity(pid, "milestones", owner_tok)
        self.assertGreaterEqual(len(milestones), 1)
        self.assertEqual(milestones[0]["start"], "T1-T3")

        # create + coercion (records "620" → int 620)
        ds = await create_entity(pid, "datasets", {"name": "Ảnh CLVT", "records": "620", "status": "Sẵn sàng"}, owner_tok)
        self.assertEqual(ds["name"], "Ảnh CLVT")
        self.assertEqual(ds["records"], 620)

        # update
        upd = await update_entity(pid, "datasets", ds["id"], {"status": "Khóa"}, owner_tok)
        self.assertEqual(upd["status"], "Khóa")

        # cockpit bundle reflects entities + audit captured the actions
        bundle = await get_cockpit(pid, admin_tok)
        self.assertEqual(bundle["project"]["status"], "approved")
        self.assertEqual(len(bundle["datasets"]), 1)
        audit_actions = [a["action"] for a in bundle["audit"]]
        self.assertIn("Thêm bộ dữ liệu", audit_actions)
        self.assertIn("Cập nhật bộ dữ liệu", audit_actions)

        # delete
        await delete_entity(pid, "datasets", ds["id"], owner_tok)
        self.assertEqual(len(await list_entity(pid, "datasets", owner_tok)), 0)

    async def test_seeding_survives_malformed_content(self) -> None:
        """A PI can put arbitrary JSON in content; approve-time seeding must never crash (best-effort)."""
        from src.research_routes import (
            ApproveIn,
            ProjectCreateIn,
            approve_project,
            create_project,
            list_entity,
            submit_project,
        )

        owner = await self._seed_user()
        admin = await self._seed_user(admin=True)
        owner_tok = _bearer(owner, ["viewer"])
        admin_tok = _bearer(admin, ["admin"])

        created = await create_project(
            ProjectCreateIn(
                content={
                    "tenDeTai": "Đề tài lỗi định dạng",
                    "chuNhiem.hoTen": "TS. PI A",
                    "thanhVienThucHien": 5,  # truthy non-list — must be ignored, not crash
                    "tienDoThucHien": "oops",  # truthy non-list
                }
            ),
            owner_tok,
        )
        pid = created.id
        self._project_ids.append(uuid.UUID(pid))
        await submit_project(pid, owner_tok)

        approved = await approve_project(pid, ApproveIn(), admin_tok)  # must not raise
        self.assertEqual(approved.status, "approved")
        # malformed repeatables seeded nothing; only the PI member came from chuNhiem.hoTen
        self.assertEqual(len(await list_entity(pid, "milestones", owner_tok)), 0)
        members = await list_entity(pid, "members", owner_tok)
        self.assertTrue(any(m["name"] == "TS. PI A" for m in members))

    async def test_update_detail_merges_after_approval(self) -> None:
        """The cockpit detail endpoint: approved-only, owner-or-admin, shallow-merge + audit."""
        from fastapi import HTTPException

        from src.research_routes import (
            ApproveIn,
            ProjectCreateIn,
            ProjectDetailPatchIn,
            approve_project,
            create_project,
            get_cockpit,
            submit_project,
            update_project_detail,
        )

        owner = await self._seed_user()
        admin = await self._seed_user(admin=True)
        other = await self._seed_user()
        owner_tok = _bearer(owner, ["viewer"])
        admin_tok = _bearer(admin, ["admin"])
        other_tok = _bearer(other, ["viewer"])

        created = await create_project(
            ProjectCreateIn(content={"tenDeTai": "Đề tài chi tiết", "tongKinhPhi": "300"}),
            owner_tok,
        )
        pid = created.id
        self._project_ids.append(uuid.UUID(pid))

        # detail patch is rejected before approval (cockpit-only) → 409
        with self.assertRaises(HTTPException) as ctx_draft:
            await update_project_detail(pid, ProjectDetailPatchIn(patch={"soHopDong": "x"}), owner_tok)
        self.assertEqual(ctx_draft.exception.status_code, 409)

        await submit_project(pid, owner_tok)
        await approve_project(pid, ApproveIn(code="C-DET"), admin_tok)

        # owner patches admin-detail fields; merge preserves the original proposal key + re-derives scalars
        patched = await update_project_detail(
            pid,
            ProjectDetailPatchIn(patch={"soHopDong": "205/2024/HĐ", "tongKinhPhi": "29900000"}),
            owner_tok,
        )
        self.assertEqual(patched.content["soHopDong"], "205/2024/HĐ")
        self.assertEqual(patched.content["tenDeTai"], "Đề tài chi tiết")  # untouched proposal key
        self.assertAlmostEqual(patched.budgetTotal, 29900000.0)

        # admin may also patch (owner-or-admin — unlike draft update_project which is owner-only)
        patched2 = await update_project_detail(
            pid, ProjectDetailPatchIn(patch={"khoaDonVi": "Dược"}), admin_tok
        )
        self.assertEqual(patched2.content["khoaDonVi"], "Dược")
        self.assertEqual(patched2.content["soHopDong"], "205/2024/HĐ")  # earlier patch survives

        # a stranger cannot patch (404 hides the row)
        with self.assertRaises(HTTPException) as ctx_other:
            await update_project_detail(pid, ProjectDetailPatchIn(patch={"x": "y"}), other_tok)
        self.assertEqual(ctx_other.exception.status_code, 404)

        # audit captured the update
        bundle = await get_cockpit(pid, owner_tok)
        self.assertIn("Cập nhật thông tin đề tài", [a["action"] for a in bundle["audit"]])
