from decimal import Decimal

import pytest
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from datetime import date, timedelta
from rest_framework.test import APIClient

from apps.projects.models import Project, ProjectDocument, ProjectTeamMember, ProjectFund, CashAdvance, ProjectFundAllocation
from apps.hr.models import Employee
from apps.accounting.models import Account, JournalLine
from apps.companies.models import Organization, OrganizationMembership


@pytest.fixture
def organization(db):
    return Organization.objects.create(name="Test Org")


@pytest.fixture
def user(db, organization):
    from apps.hr.signals import ensure_employee_stub
    User = get_user_model()
    u = User.objects.create_user(email="project@example.com", password="pass123")
    OrganizationMembership.objects.create(organization=organization, user=u)
    ensure_employee_stub(u)
    return u


@pytest.fixture
def project(user, organization):
    return Project.objects.create(
        organization=organization,
        name="Test Project",
        description="A test project",
    )


@pytest.mark.django_db
class TestProjectModel:
    def test_create_project(self, user, organization):
        proj = Project.objects.create(
            organization=organization,
            name="New Project",
            description="Description",
            )
        assert proj.name == "New Project"
        assert proj.status == "planning"

    def test_project_str(self, project):
        assert str(project) == "Test Project"


@pytest.mark.django_db
class TestProjectDocumentModel:
    def test_create_document(self, project, user):
        doc = ProjectDocument.objects.create(
            project=project,
            name="README.md",
            uploaded_by=user
        )
        assert doc.name == "README.md"


@pytest.mark.django_db
class TestProjectTeamMemberModel:
    def test_add_team_member(self, project, user):
        member = ProjectTeamMember.objects.create(
            project=project,
            user=user,
            role="member"
        )
        assert member.role == "member"

    def test_unique_team_member(self, project, user):
        ProjectTeamMember.objects.create(project=project, user=user, role="member")
        with pytest.raises(Exception):
            ProjectTeamMember.objects.create(project=project, user=user, role="owner")


@pytest.fixture
def api_client(db, organization):
    User = get_user_model()
    admin = User.objects.create_superuser(email="advance-admin@test.local", password="x")
    auto_org = admin.organizations.first()
    OrganizationMembership.objects.filter(organization=auto_org, user=admin).update(organization=organization)
    auto_org.delete()
    c = APIClient()
    c.force_authenticate(admin)
    return c


@pytest.fixture
def fund(project, organization):
    return ProjectFund.objects.create(
        organization=organization,
        project=project, source="Test Donor", amount=Decimal("10000000"),
        currency="IDR", status="received",
    )


@pytest.fixture
def coa(organization):
    return {
        "advance": Account.objects.create(organization=organization, code="1200", name="Employee Advance", type="Asset", normal_balance="Debit"),
        "cash": Account.objects.create(organization=organization, code="1000", name="Cash", type="Asset", normal_balance="Debit", is_cash=True),
        "expense": Account.objects.create(organization=organization, code="5000", name="Travel", type="Expense", normal_balance="Debit"),
    }


@pytest.mark.django_db
class TestCashAdvance:
    def test_issue_does_not_count_as_spent(self, api_client, project, fund, coa, user):
        recipient = Employee.objects.get(user=user)
        resp = api_client.post("/api/cash-advances/", {
            "project": str(project.id), "fund": str(fund.id), "recipient": str(recipient.id),
            "purpose": "Field visit transport", "amount": "1000000", "currency": "IDR",
            "date_issued": "2026-07-01",
        }, format="multipart")
        assert resp.status_code == 201, resp.content
        adv = CashAdvance.objects.get(id=resp.data["id"])
        assert adv.status == "issued"
        assert adv.issue_journal_entry_id is not None

        # Issue journal hits the Advance asset account, not Expense — so this
        # is not yet a recognized expense.
        lines = JournalLine.objects.filter(journal=adv.issue_journal_entry)
        assert {l.account.type for l in lines} == {"Asset"}
        spent = JournalLine.objects.filter(
            journal__status="Posted", account__type="Expense",
        ).count()
        assert spent == 0

    def test_settle_recognizes_spent_and_returns_unspent(self, api_client, project, fund, coa, user):
        recipient = Employee.objects.get(user=user)
        resp = api_client.post("/api/cash-advances/", {
            "project": str(project.id), "fund": str(fund.id), "recipient": str(recipient.id),
            "purpose": "Workshop supplies", "amount": "1000000", "currency": "IDR",
            "date_issued": "2026-07-01",
        }, format="multipart")
        adv_id = resp.data["id"]

        resp2 = api_client.post(f"/api/cash-advances/{adv_id}/settle/", {
            "spent_amount": "700000", "expense_account": str(coa["expense"].id),
            "settlement_notes": "receipts attached",
            "return_receipt": SimpleUploadedFile("return.jpg", b"fake-image", content_type="image/jpeg"),
        }, format="multipart")
        assert resp2.status_code == 200, resp2.content

        adv = CashAdvance.objects.get(id=adv_id)
        assert adv.status == "settled"
        assert adv.spent_amount == Decimal("700000")
        assert adv.returned_amount == Decimal("300000")
        assert adv.project_finance_entry_id is not None
        assert adv.project_finance_entry.amount == Decimal("700000")
        assert adv.project_finance_entry.approval_status == "approved"

        # Now the settled portion (and only it) shows up as recognized spend.
        expense_debits = JournalLine.objects.filter(
            journal__status="Posted", account__type="Expense",
        ).count()
        assert expense_debits == 1
        assert JournalLine.objects.get(journal__status="Posted", account__type="Expense").debit_idr == Decimal("700000")

    def test_cannot_settle_twice(self, api_client, project, fund, coa, user):
        recipient = Employee.objects.get(user=user)
        resp = api_client.post("/api/cash-advances/", {
            "project": str(project.id), "fund": str(fund.id), "recipient": str(recipient.id),
            "purpose": "Test", "amount": "100000", "currency": "IDR", "date_issued": "2026-07-01",
        }, format="multipart")
        adv_id = resp.data["id"]
        api_client.post(f"/api/cash-advances/{adv_id}/settle/", {
            "spent_amount": "100000", "expense_account": str(coa["expense"].id),
        }, format="multipart")
        resp2 = api_client.post(f"/api/cash-advances/{adv_id}/settle/", {
            "spent_amount": "50000", "expense_account": str(coa["expense"].id),
        }, format="multipart")
        assert resp2.status_code == 409

    def test_settle_rejects_amount_over_advance(self, api_client, project, fund, coa, user):
        recipient = Employee.objects.get(user=user)
        resp = api_client.post("/api/cash-advances/", {
            "project": str(project.id), "fund": str(fund.id), "recipient": str(recipient.id),
            "purpose": "Test", "amount": "100000", "currency": "IDR", "date_issued": "2026-07-01",
        }, format="multipart")
        adv_id = resp.data["id"]
        resp2 = api_client.post(f"/api/cash-advances/{adv_id}/settle/", {
            "spent_amount": "999999999", "expense_account": str(coa["expense"].id),
        }, format="multipart")
        assert resp2.status_code == 400

    def test_cancel_issued_advance(self, api_client, project, fund, coa, user):
        recipient = Employee.objects.get(user=user)
        resp = api_client.post("/api/cash-advances/", {
            "project": str(project.id), "fund": str(fund.id), "recipient": str(recipient.id),
            "purpose": "Test", "amount": "100000", "currency": "IDR", "date_issued": "2026-07-01",
        }, format="multipart")
        adv_id = resp.data["id"]
        resp2 = api_client.post(f"/api/cash-advances/{adv_id}/cancel/", {}, format="multipart")
        assert resp2.status_code == 200
        adv = CashAdvance.objects.get(id=adv_id)
        assert adv.status == "cancelled"
        assert adv.issue_journal_entry.status == "Rejected"

    def test_settle_requires_return_receipt_when_unspent(self, api_client, project, fund, coa, user):
        recipient = Employee.objects.get(user=user)
        resp = api_client.post("/api/cash-advances/", {
            "project": str(project.id), "fund": str(fund.id), "recipient": str(recipient.id),
            "purpose": "Test", "amount": "100000", "currency": "IDR", "date_issued": "2026-07-01",
        }, format="multipart")
        adv_id = resp.data["id"]
        resp2 = api_client.post(f"/api/cash-advances/{adv_id}/settle/", {
            "spent_amount": "70000", "expense_account": str(coa["expense"].id),
        }, format="multipart")
        assert resp2.status_code == 400
        assert CashAdvance.objects.get(id=adv_id).status == "issued"

    def test_settle_notifies_creator(self, api_client, approver_client, project, fund, coa, user):
        from apps.notifications.models import Notification
        recipient = Employee.objects.get(user=user)
        resp = api_client.post("/api/cash-advances/", {
            "project": str(project.id), "fund": str(fund.id), "recipient": str(recipient.id),
            "purpose": "Notify test", "amount": "100000", "currency": "IDR", "date_issued": "2026-07-01",
        }, format="multipart")
        adv_id = resp.data["id"]
        creator = CashAdvance.objects.get(id=adv_id).created_by
        resp2 = approver_client.post(f"/api/cash-advances/{adv_id}/settle/", {
            "spent_amount": "100000", "expense_account": str(coa["expense"].id),
        }, format="multipart")
        assert resp2.status_code == 200, resp2.content
        assert Notification.objects.filter(user=creator, notification_type="project").exists()

    def test_cancel_notifies_creator(self, api_client, approver_client, project, fund, coa, user):
        from apps.notifications.models import Notification
        recipient = Employee.objects.get(user=user)
        resp = api_client.post("/api/cash-advances/", {
            "project": str(project.id), "fund": str(fund.id), "recipient": str(recipient.id),
            "purpose": "Notify test", "amount": "100000", "currency": "IDR", "date_issued": "2026-07-01",
        }, format="multipart")
        adv_id = resp.data["id"]
        creator = CashAdvance.objects.get(id=adv_id).created_by
        resp2 = approver_client.post(f"/api/cash-advances/{adv_id}/cancel/", {}, format="multipart")
        assert resp2.status_code == 200, resp2.content
        assert Notification.objects.filter(user=creator, notification_type="project").exists()


@pytest.fixture
def budget_item(organization):
    from apps.finance.models import BudgetCategory, BudgetItem
    cat = BudgetCategory.objects.create(organization=organization, name="Test Category RV")
    return BudgetItem.objects.create(code="RV.1", name="Test Item", category=cat)


@pytest.fixture
def approver_client(db, organization):
    """A second superuser, distinct from api_client's — approve/reject checks
    that the approver isn't the proposer, so tests exercising the full
    propose→approve cycle need two different actors."""
    User = get_user_model()
    admin2 = User.objects.create_superuser(email="advance-admin-2@test.local", password="x")
    auto_org = admin2.organizations.first()
    OrganizationMembership.objects.filter(organization=auto_org, user=admin2).update(organization=organization)
    auto_org.delete()
    c = APIClient()
    c.force_authenticate(admin2)
    return c


@pytest.mark.django_db
class TestFundAllocationRevisions:
    def test_direct_write_is_blocked(self, api_client, fund, budget_item):
        resp = api_client.post("/api/project-fund-allocations/", {
            "fund": str(fund.id), "budget_item": str(budget_item.id), "quantity": "1", "unit_cost": "100",
        }, format="multipart")
        assert resp.status_code == 405

    def test_propose_create_does_not_touch_live_table(self, api_client, fund, budget_item):
        from apps.projects.models import ProjectFundAllocation
        resp = api_client.post("/api/project-fund-allocation-revisions/", {
            "fund": str(fund.id), "action": "create",
            "proposed_data": {"budget_item": str(budget_item.id), "quantity": "2", "unit_cost": "100", "frequency": "1", "time_allocated_pct": "100"},
        }, format="json")
        assert resp.status_code == 201, resp.content
        assert resp.data["status"] == "pending"
        assert ProjectFundAllocation.objects.filter(fund=fund).count() == 0

    def test_approve_applies_create_and_versions_update(self, api_client, approver_client, fund, budget_item):
        from apps.projects.models import ProjectFundAllocation

        create_resp = api_client.post("/api/project-fund-allocation-revisions/", {
            "fund": str(fund.id), "action": "create",
            "proposed_data": {"budget_item": str(budget_item.id), "quantity": "2", "unit_cost": "100", "frequency": "1", "time_allocated_pct": "100"},
        }, format="json")
        rev_id = create_resp.data["id"]

        approve_resp = approver_client.post(f"/api/project-fund-allocation-revisions/{rev_id}/approve/", {}, format="json")
        assert approve_resp.status_code == 200, approve_resp.content
        alloc = ProjectFundAllocation.objects.get(fund=fund, budget_item=budget_item)
        assert alloc.version == 1
        assert alloc.amount == Decimal("200.00")

        update_resp = api_client.post("/api/project-fund-allocation-revisions/", {
            "fund": str(fund.id), "action": "update", "allocation": str(alloc.id),
            "proposed_data": {"quantity": "5"},
        }, format="json")
        assert update_resp.status_code == 201, update_resp.content
        approver_client.post(f"/api/project-fund-allocation-revisions/{update_resp.data['id']}/approve/", {}, format="json")

        alloc.refresh_from_db()
        assert alloc.version == 2
        assert alloc.amount == Decimal("500.00")

    def test_cannot_approve_own_proposal(self, api_client, fund, budget_item):
        resp = api_client.post("/api/project-fund-allocation-revisions/", {
            "fund": str(fund.id), "action": "create",
            "proposed_data": {"budget_item": str(budget_item.id), "quantity": "1", "unit_cost": "50", "frequency": "1", "time_allocated_pct": "100"},
        }, format="json")
        rev_id = resp.data["id"]

        approve_resp = api_client.post(f"/api/project-fund-allocation-revisions/{rev_id}/approve/", {}, format="json")
        assert approve_resp.status_code == 403

    def test_reject_leaves_live_table_untouched(self, api_client, approver_client, fund, budget_item):
        from apps.projects.models import ProjectFundAllocation
        resp = api_client.post("/api/project-fund-allocation-revisions/", {
            "fund": str(fund.id), "action": "create",
            "proposed_data": {"budget_item": str(budget_item.id), "quantity": "1", "unit_cost": "50", "frequency": "1", "time_allocated_pct": "100"},
        }, format="json")
        rev_id = resp.data["id"]
        reject_resp = approver_client.post(f"/api/project-fund-allocation-revisions/{rev_id}/reject/", {}, format="json")
        assert reject_resp.status_code == 200
        assert reject_resp.data["status"] == "rejected"
        assert ProjectFundAllocation.objects.filter(fund=fund).count() == 0

    def test_create_proposal_blocked_when_budget_item_already_allocated(self, api_client, approver_client, fund, budget_item):
        first = api_client.post("/api/project-fund-allocation-revisions/", {
            "fund": str(fund.id), "action": "create",
            "proposed_data": {"budget_item": str(budget_item.id), "quantity": "1", "unit_cost": "50", "frequency": "1", "time_allocated_pct": "100"},
        }, format="json")
        approver_client.post(f"/api/project-fund-allocation-revisions/{first.data['id']}/approve/", {}, format="json")

        dup = api_client.post("/api/project-fund-allocation-revisions/", {
            "fund": str(fund.id), "action": "create",
            "proposed_data": {"budget_item": str(budget_item.id), "quantity": "1", "unit_cost": "50", "frequency": "1", "time_allocated_pct": "100"},
        }, format="json")
        assert dup.status_code == 409


@pytest.mark.django_db
class TestPaymentRequestFormNotify:
    def _create_prf(self, api_client, project):
        resp = api_client.post("/api/payment-request-forms/", {
            "project": str(project.id), "recipient_name": "Vendor XYZ", "amount": "500000",
        }, format="json")
        assert resp.status_code == 201, resp.content
        return resp.data["id"]

    def test_review_notifies_creator(self, api_client, approver_client, project):
        from apps.notifications.models import Notification
        from apps.projects.models import PaymentRequestForm
        prf_id = self._create_prf(api_client, project)
        creator = PaymentRequestForm.objects.get(id=prf_id).created_by
        resp = approver_client.post(f"/api/payment-request-forms/{prf_id}/review/", {"decision": "approve"}, format="json")
        assert resp.status_code == 200, resp.content
        assert Notification.objects.filter(user=creator, notification_type="project").exists()

    def test_reject_at_review_notifies_creator(self, api_client, approver_client, project):
        from apps.notifications.models import Notification
        from apps.projects.models import PaymentRequestForm
        prf_id = self._create_prf(api_client, project)
        creator = PaymentRequestForm.objects.get(id=prf_id).created_by
        resp = approver_client.post(f"/api/payment-request-forms/{prf_id}/review/", {"decision": "reject"}, format="json")
        assert resp.status_code == 200, resp.content
        assert Notification.objects.filter(user=creator, notification_type="project").exists()

    def test_verify_budget_notifies_creator(self, api_client, approver_client, project):
        from apps.notifications.models import Notification
        from apps.projects.models import PaymentRequestForm
        prf_id = self._create_prf(api_client, project)
        creator = PaymentRequestForm.objects.get(id=prf_id).created_by
        approver_client.post(f"/api/payment-request-forms/{prf_id}/review/", {"decision": "approve"}, format="json")
        Notification.objects.filter(user=creator).delete()
        resp = approver_client.post(f"/api/payment-request-forms/{prf_id}/verify-budget/", {"decision": "approve"}, format="json")
        assert resp.status_code == 200, resp.content
        assert Notification.objects.filter(user=creator, notification_type="project").exists()

    def test_approve_notifies_creator(self, api_client, approver_client, project):
        from apps.notifications.models import Notification
        from apps.projects.models import PaymentRequestForm
        prf_id = self._create_prf(api_client, project)
        creator = PaymentRequestForm.objects.get(id=prf_id).created_by
        approver_client.post(f"/api/payment-request-forms/{prf_id}/review/", {"decision": "approve"}, format="json")
        approver_client.post(f"/api/payment-request-forms/{prf_id}/verify-budget/", {"decision": "approve"}, format="json")
        Notification.objects.filter(user=creator).delete()
        resp = approver_client.post(f"/api/payment-request-forms/{prf_id}/approve/", {"decision": "approve"}, format="json")
        assert resp.status_code == 200, resp.content
        assert Notification.objects.filter(user=creator, notification_type="project").exists()

    def test_cannot_review_own_prf(self, api_client, project):
        prf_id = self._create_prf(api_client, project)
        resp = api_client.post(f"/api/payment-request-forms/{prf_id}/review/", {"decision": "approve"}, format="json")
        assert resp.status_code == 403


@pytest.mark.django_db
class TestFinanceDecisionNotify:
    def test_finance_approve_notifies_creator(self, api_client, approver_client, project):
        from apps.notifications.models import Notification
        from apps.projects.models import ProjectFinance
        entry = ProjectFinance.objects.create(
            project=project, description="Travel expense", amount=Decimal("100000"),
            type="expense", ledger="expense", created_by=get_user_model().objects.get(email="advance-admin@test.local"),
        )
        resp = approver_client.post(f"/api/projects/{project.id}/finance/{entry.id}/approve/", {}, format="json")
        assert resp.status_code == 200, resp.content
        assert Notification.objects.filter(user=entry.created_by, notification_type="project").exists()

    def test_finance_reject_notifies_creator(self, api_client, approver_client, project):
        from apps.notifications.models import Notification
        from apps.projects.models import ProjectFinance
        entry = ProjectFinance.objects.create(
            project=project, description="Travel expense", amount=Decimal("100000"),
            type="expense", ledger="expense", created_by=get_user_model().objects.get(email="advance-admin@test.local"),
        )
        resp = approver_client.post(f"/api/projects/{project.id}/finance/{entry.id}/reject/", {}, format="json")
        assert resp.status_code == 200, resp.content
        assert Notification.objects.filter(user=entry.created_by, notification_type="project").exists()

    def test_cannot_approve_own_expense(self, api_client, project):
        from apps.projects.models import ProjectFinance
        entry = ProjectFinance.objects.create(
            project=project, description="Travel expense", amount=Decimal("100000"),
            type="expense", ledger="expense", created_by=get_user_model().objects.get(email="advance-admin@test.local"),
        )
        resp = api_client.post(f"/api/projects/{project.id}/finance/{entry.id}/approve/", {}, format="json")
        assert resp.status_code == 403


@pytest.fixture
def grant_coa(organization):
    return {
        "cash": Account.objects.create(organization=organization, code="1000", name="Cash and Bank", type="Asset", normal_balance="Debit", is_cash=True),
        "deferred": Account.objects.create(organization=organization, code="2100", name="Deferred Grant Income", type="Liability", normal_balance="Credit"),
    }


@pytest.mark.django_db
class TestProjectFundTrancheReceipt:
    def test_scheduled_tranche_does_not_post(self, api_client, fund, grant_coa):
        resp = api_client.post("/api/project-fund-tranches/", {
            "fund": str(fund.id), "label": "First tranche", "amount": "5000000",
            "expected_date": "2026-08-01", "status": "scheduled",
        }, format="json")
        assert resp.status_code == 201, resp.content
        from apps.accounting.models import JournalEntry
        assert resp.data["journal_entry"] is None
        assert JournalEntry.objects.count() == 0

    def test_marking_received_posts_draft_journal(self, api_client, fund, grant_coa):
        from apps.accounting.models import JournalEntry, JournalLine, Fund as AccountingFund
        from apps.projects.models import ProjectFundTranche

        resp = api_client.post("/api/project-fund-tranches/", {
            "fund": str(fund.id), "label": "First tranche", "amount": "5000000",
            "expected_date": "2026-08-01", "status": "scheduled",
        }, format="json")
        tranche_id = resp.data["id"]

        resp2 = api_client.patch(f"/api/project-fund-tranches/{tranche_id}/", {
            "status": "received", "received_date": "2026-08-05",
        }, format="json")
        assert resp2.status_code == 200, resp2.content

        tranche = ProjectFundTranche.objects.get(id=tranche_id)
        assert tranche.journal_entry_id is not None
        journal = tranche.journal_entry
        assert journal.status == "Draft"
        assert str(journal.source_project_fund_id).replace("-", "") == str(fund.id).replace("-", "")

        lines = {l.account.code: l for l in JournalLine.objects.filter(journal=journal)}
        assert lines["1000"].debit_idr == Decimal("5000000")
        assert lines["2100"].credit_idr == Decimal("5000000")

        assert AccountingFund.objects.filter(project_fund=fund).exists()

    def test_resaving_received_tranche_does_not_duplicate_journal(self, api_client, fund, grant_coa):
        from apps.accounting.models import JournalEntry
        from apps.projects.models import ProjectFundTranche

        resp = api_client.post("/api/project-fund-tranches/", {
            "fund": str(fund.id), "label": "First tranche", "amount": "5000000",
            "expected_date": "2026-08-01", "status": "received", "received_date": "2026-08-05",
        }, format="json")
        assert resp.status_code == 201, resp.content
        assert JournalEntry.objects.count() == 1

        tranche_id = resp.data["id"]
        resp2 = api_client.patch(f"/api/project-fund-tranches/{tranche_id}/", {
            "notes": "confirmed by bank statement",
        }, format="json")
        assert resp2.status_code == 200, resp2.content
        assert JournalEntry.objects.count() == 1

        tranche = ProjectFundTranche.objects.get(id=tranche_id)
        assert tranche.journal_entry_id is not None

    def test_uses_fund_exchange_rate_for_amount_idr(self, api_client, project, grant_coa):
        from apps.projects.models import ProjectFund, ProjectFundTranche
        from apps.accounting.models import JournalLine

        usd_fund = ProjectFund.objects.create(
            organization=project.organization,
            project=project, source="USD Donor", amount=Decimal("10000"),
            currency="USD", exchange_rate=Decimal("15000"), status="pledged",
        )
        resp = api_client.post("/api/project-fund-tranches/", {
            "fund": str(usd_fund.id), "label": "Tranche 1", "amount": "1000",
            "status": "received", "received_date": "2026-08-05",
        }, format="json")
        assert resp.status_code == 201, resp.content

        tranche = ProjectFundTranche.objects.get(id=resp.data["id"])
        lines = JournalLine.objects.filter(journal=tranche.journal_entry)
        assert lines.filter(account__code="1000").first().debit_idr == Decimal("15000000")


@pytest.mark.django_db
class TestProjectFinanceAiTools:
    """Scope gating for the AI assistant's financial tools (ai_tools.py)."""

    @pytest.fixture
    def outsider(self, project):
        user = get_user_model().objects.create_user(email="outsider@example.com", password="pass123")
        OrganizationMembership.objects.create(organization=project.organization, user=user)
        return user

    @pytest.fixture
    def lead_employee(self, project):
        from apps.hr.signals import ensure_employee_stub
        user = get_user_model().objects.create_user(email="lead@example.com", password="pass123")
        OrganizationMembership.objects.create(organization=project.organization, user=user)
        ensure_employee_stub(user)
        employee = Employee.objects.get(user=user)
        project.lead = employee
        project.save(update_fields=["lead"])
        return user

    @pytest.fixture
    def team_member(self, project):
        user = get_user_model().objects.create_user(email="member@example.com", password="pass123")
        OrganizationMembership.objects.create(organization=project.organization, user=user)
        ProjectTeamMember.objects.create(project=project, user=user, role="member")
        return user

    @pytest.fixture
    def finance_staff(self, project):
        from apps.core.models import Permission, Role, UserRole

        user = get_user_model().objects.create_user(email="finance@example.com", password="pass123")
        OrganizationMembership.objects.create(organization=project.organization, user=user)
        perm, _ = Permission.objects.get_or_create(domain="finance", action="approve", defaults={"label": "Finance: Approve"})
        role = Role.objects.create(name="Finance Staff Test", slug="finance-staff-ai-test")
        role.permissions.add(perm)
        UserRole.objects.create(user=user, role=role, organization=project.organization)
        return user

    @pytest.fixture
    def fund(self, project):
        return ProjectFund.objects.create(
            organization=project.organization,
            project=project, source="Test Donor", amount=Decimal("10000000"),
            currency="IDR", status="received",
        )

    def test_outsider_project_not_visible(self, outsider, project):
        from apps.projects.ai_tools import project_finance_summary

        result = project_finance_summary(outsider, project.organization, str(project.id))
        assert result == {"error": "Project not found or not visible to you."}

    def test_finance_staff_in_other_org_cannot_resolve_project_by_name(self, project):
        """Regression: _resolve_project fetched `org` but never filtered the
        queryset by it — a full-scope/finance-authority user in ANOTHER org
        could look up this project by name or UUID. Confirm isolation now."""
        from apps.core.models import Permission, Role, UserRole
        from apps.projects.ai_tools import _resolve_project

        other_org = Organization.objects.create(name="Other Org")
        other_user = get_user_model().objects.create_user(email="other-finance@example.com", password="pass123")
        OrganizationMembership.objects.create(organization=other_org, user=other_user)
        perm, _ = Permission.objects.get_or_create(domain="finance", action="approve", defaults={"label": "Finance: Approve"})
        role = Role.objects.create(name="Other Org Finance Staff", slug="other-org-finance-ai-test")
        role.permissions.add(perm)
        UserRole.objects.create(user=other_user, role=role, organization=other_org)

        assert _resolve_project(other_user, other_org, str(project.id)) is None
        assert _resolve_project(other_user, other_org, project.name) is None

    def test_team_member_without_finance_access_is_restricted(self, team_member, project):
        from apps.projects.ai_tools import project_finance_summary

        result = project_finance_summary(team_member, project.organization, str(project.id))
        assert "requires finance access" in result["error"]

    def test_project_lead_gets_full_access(self, lead_employee, project):
        from apps.projects.ai_tools import project_finance_summary

        result = project_finance_summary(lead_employee, project.organization, str(project.id))
        assert "error" not in result
        assert result["project"] == project.name

    def test_finance_staff_gets_full_access_to_any_project(self, finance_staff, project):
        from apps.projects.ai_tools import project_finance_summary

        result = project_finance_summary(finance_staff, project.organization, str(project.id))
        assert "error" not in result

    def test_fund_allocations_happy_path(self, finance_staff, project, fund):
        from apps.finance.models import BudgetCategory, BudgetItem
        from apps.projects.ai_tools import project_fund_allocations

        category = BudgetCategory.objects.create(organization=project.organization, name="Personnel")
        budget_item = BudgetItem.objects.create(category=category, name="Consultant Fee")
        ProjectFundAllocation.objects.create(
            fund=fund, budget_item=budget_item, unit="month",
            quantity=Decimal("2"), unit_cost=Decimal("5000000"),
        )

        result = project_fund_allocations(finance_staff, project.organization, str(project.id))
        assert len(result) == 1
        assert result[0]["budget_item"] == "Consultant Fee"

    def test_expenses_happy_path(self, finance_staff, project, fund):
        from apps.hr.signals import ensure_employee_stub
        recipient_user = get_user_model().objects.create_user(email="recipient@example.com", password="x")
        OrganizationMembership.objects.create(organization=project.organization, user=recipient_user)
        ensure_employee_stub(recipient_user)
        employee = Employee.objects.get(user=recipient_user)
        CashAdvance.objects.create(
            project=project, fund=fund, recipient=employee, purpose="Travel",
            amount=Decimal("1000000"), date_issued=date(2026, 1, 1),
        )

        from apps.projects.ai_tools import project_expenses

        result = project_expenses(finance_staff, project.organization, str(project.id))
        assert len(result["cash_advances"]) == 1
        assert result["cash_advances"][0]["purpose"] == "Travel"

    def test_grants_happy_path(self, finance_staff, project, fund):
        from apps.projects.ai_tools import project_grants

        result = project_grants(finance_staff, project.organization, str(project.id))
        assert len(result["funds"]) == 1
        assert result["funds"][0]["source"] == "Test Donor"

    def test_grants_never_label_idr_amount_with_native_currency(self, finance_staff, project):
        """Regression: `amount` was `amount_idr or amount` while `currency` stayed
        the fund's native code, so the model reported EUR 100,000 as
        "EUR 2,036,116,000" — the IDR value wearing a EUR label (~20,000x off)."""
        from apps.projects.ai_tools import project_grants

        ProjectFund.objects.create(
            organization=project.organization,
            project=project, source="EU Delegation", amount=Decimal("100000"),
            currency="EUR", exchange_rate=Decimal("20361.16"),
            amount_idr=Decimal("2036116000"), status="partially_received",
        )

        eur = next(
            f for f in project_grants(
                finance_staff, project.organization, str(project.id)
            )["funds"] if f["currency"] == "EUR"
        )
        assert eur["amount"] == "100000.00"
        assert eur["amount_idr"] == "2036116000.00"

    def test_finance_summary_totals_are_named_idr(self, finance_staff, project):
        """Mixed-currency totals are summed in IDR, so the keys must say so —
        an unlabeled `total_expense` invites the model to guess a currency."""
        from apps.projects.ai_tools import project_finance_summary

        result = project_finance_summary(finance_staff, project.organization, str(project.id))
        assert "total_income_idr" in result and "total_expense_idr" in result
        assert "total_income" not in result and "total_expense" not in result

    def test_call_tool_dispatches_and_drops_unknown_args(self, finance_staff, project):
        from apps.projects.ai_tools import call_project_finance_tool

        result = call_project_finance_tool(
            finance_staff, project.organization, "project_finance_summary",
            {"project": str(project.id), "unexpected": "x"},
        )
        assert "error" not in result

    def test_call_tool_unknown_name(self, finance_staff, project):
        from apps.projects.ai_tools import call_project_finance_tool

        result = call_project_finance_tool(
            finance_staff, project.organization, "not_a_real_tool", {}
        )
        assert result == {"error": "Unknown tool: not_a_real_tool"}

@pytest.mark.django_db
class TestScheduleItemDetailRoute:
    """PATCH/DELETE /projects/{id}/schedule/{item_id}/ — milestone updates."""

    def _superuser(self, organization):
        from apps.companies.models import OrganizationMembership
        User = get_user_model()
        u = User.objects.create_user(email="sched-admin@example.com", password="p", is_staff=True, is_superuser=True)
        OrganizationMembership.objects.create(organization=organization, user=u)
        return u

    def test_patch_status_and_revised_date(self, project, organization):
        from apps.projects.models import ProjectSchedule
        user = self._superuser(organization)
        item = ProjectSchedule.objects.create(project=project, title="Phase 1", end_date=date(2026, 6, 30))
        client = APIClient()
        client.force_authenticate(user)
        resp = client.patch(
            f"/api/projects/{project.id}/schedule/{item.id}/",
            {"status": "delayed", "revised_end_date": "2026-09-30"}, format="json",
        )
        assert resp.status_code == 200, resp.content
        item.refresh_from_db()
        assert item.status == "delayed"
        assert str(item.revised_end_date) == "2026-09-30"

    def test_delete(self, project, organization):
        from apps.projects.models import ProjectSchedule
        user = self._superuser(organization)
        item = ProjectSchedule.objects.create(project=project, title="Phase X")
        client = APIClient()
        client.force_authenticate(user)
        resp = client.delete(f"/api/projects/{project.id}/schedule/{item.id}/")
        assert resp.status_code == 204
        assert not ProjectSchedule.objects.filter(pk=item.id).exists()


@pytest.mark.django_db
class TestProjectNumberAndDepartments:
    """Project number format is <DEPT CODE>.<YY>.<NNN>, e.g. ECO.26.001."""

    @pytest.fixture
    def research_dept(self, organization):
        from apps.companies.models import Department
        return Department.objects.create(
            organization=organization, name="Dept. Ekonomi", code="ECO", is_research=True,
        )

    @pytest.fixture
    def support_dept(self, organization):
        from apps.companies.models import Department
        return Department.objects.create(
            organization=organization, name="Finance", code="FIN", is_research=False,
        )

    def _create(self, api_client, name, departments=()):
        return api_client.post("/api/projects/", {
            "name": name, "departments": [str(d.id) for d in departments],
        }, format="json")

    def test_number_uses_department_code_and_two_digit_year(self, api_client, research_dept):
        from django.utils import timezone
        resp = self._create(api_client, "Trade Study", [research_dept])
        assert resp.status_code == 201, resp.content
        yy = f"{timezone.now().year % 100:02d}"
        assert resp.data["project_number"] == f"ECO.{yy}.001"
        assert resp.data["department_names"] == ["Dept. Ekonomi"]

    def test_sequence_increments_per_department(self, api_client, research_dept):
        from django.utils import timezone
        yy = f"{timezone.now().year % 100:02d}"
        first = self._create(api_client, "One", [research_dept])
        second = self._create(api_client, "Two", [research_dept])
        assert first.data["project_number"] == f"ECO.{yy}.001"
        assert second.data["project_number"] == f"ECO.{yy}.002"

    def test_falls_back_to_prj_without_department(self, api_client):
        from django.utils import timezone
        resp = self._create(api_client, "Unassigned")
        assert resp.status_code == 201, resp.content
        assert resp.data["project_number"] == f"PRJ.{timezone.now().year % 100:02d}.001"

    def test_non_research_department_rejected(self, api_client, support_dept):
        resp = self._create(api_client, "Bad", [support_dept])
        assert resp.status_code == 400, resp.content
        assert "departments" in resp.data

    def test_multiple_departments_allowed_number_from_first_by_name(
        self, api_client, organization, research_dept,
    ):
        from apps.companies.models import Department
        from django.utils import timezone
        other = Department.objects.create(
            organization=organization, name="Aa Politik", code="POL", is_research=True,
        )
        resp = self._create(api_client, "Joint", [research_dept, other])
        assert resp.status_code == 201, resp.content
        # "Aa Politik" sorts before "Dept. Ekonomi", so POL prefixes the number.
        assert resp.data["project_number"] == f"POL.{timezone.now().year % 100:02d}.001"
        assert sorted(resp.data["department_names"]) == ["Aa Politik", "Dept. Ekonomi"]

    def test_number_frozen_when_departments_change(self, api_client, organization, research_dept):
        resp = self._create(api_client, "Shifting", [research_dept])
        original = resp.data["project_number"]
        from apps.companies.models import Department
        other = Department.objects.create(
            organization=organization, name="Politik", code="POL", is_research=True,
        )
        patched = api_client.patch(
            f"/api/projects/{resp.data['id']}/",
            {"departments": [str(other.id)]}, format="json",
        )
        assert patched.status_code == 200, patched.content
        assert patched.data["project_number"] == original
        assert patched.data["department_names"] == ["Politik"]

    def test_project_number_is_read_only(self, api_client, research_dept):
        resp = self._create(api_client, "Fixed", [research_dept])
        assigned = resp.data["project_number"]
        patched = api_client.patch(
            f"/api/projects/{resp.data['id']}/",
            {"project_number": "HACK.99.999"}, format="json",
        )
        assert patched.status_code == 200, patched.content
        assert patched.data["project_number"] == assigned


@pytest.mark.django_db
class TestFxRateCaching:
    """FX rates persist in ExchangeRate so repeat lookups skip the upstream API."""

    @pytest.fixture(autouse=True)
    def _clear_memo(self):
        from apps.projects import fx
        fx.clear_rate_memo()
        yield
        fx.clear_rate_memo()

    def test_resolved_rate_is_stored_and_reused(self, monkeypatch):
        from apps.projects import fx
        from apps.projects.models import ExchangeRate
        from decimal import Decimal

        calls = []
        monkeypatch.setattr(
            fx, "_fetch_remote",
            lambda c, i: (calls.append((c, i)), Decimal("16000"))[1],
        )
        d = date(2026, 6, 16)
        assert fx.fetch_idr_rate("USD", d) == Decimal("16000")
        assert len(calls) == 1
        assert ExchangeRate.objects.filter(currency="USD", date=d).exists()

        # Second call in the same process hits the memo.
        assert fx.fetch_idr_rate("USD", d) == Decimal("16000")
        assert len(calls) == 1

        # A fresh process (memo cleared) reads the stored row, not the API.
        fx.clear_rate_memo()
        assert fx.fetch_idr_rate("USD", d) == Decimal("16000")
        assert len(calls) == 1

    def test_failed_lookup_is_retried_not_cached_as_permanent(self, monkeypatch):
        from apps.projects import fx
        from decimal import Decimal

        calls = []
        monkeypatch.setattr(fx, "_fetch_remote", lambda c, i: (calls.append(1), None)[1])
        d = date(2026, 6, 16)
        assert fx.fetch_idr_rate("USD", d) is None
        assert fx.fetch_idr_rate("USD", d) is None
        # Retried rather than memoized as unavailable.
        assert len(calls) == 2

        # Once upstream recovers, the real rate is picked up and stored.
        monkeypatch.setattr(fx, "_fetch_remote", lambda c, i: Decimal("16500"))
        assert fx.fetch_idr_rate("USD", d) == Decimal("16500")

    def test_idr_needs_no_lookup(self, monkeypatch):
        from apps.projects import fx
        monkeypatch.setattr(
            fx, "_fetch_remote",
            lambda c, i: pytest.fail("IDR must not hit the API"),
        )
        assert fx.fetch_idr_rate("IDR", date(2026, 6, 16)) == Decimal("1")

    def test_prefetch_resolves_each_pair_once(self, monkeypatch):
        from apps.projects import fx
        from decimal import Decimal

        calls = []
        monkeypatch.setattr(
            fx, "_fetch_remote",
            lambda c, i: (calls.append((c, i)), Decimal("100"))[1],
        )
        d1, d2 = date(2026, 6, 16), date(2026, 7, 3)
        # Duplicates and IDR entries must not produce extra calls.
        fx.prefetch_idr_rates([
            ("USD", d1), ("USD", d1), ("EUR", d1), ("EUR", d2), ("IDR", d1), ("USD", None),
        ])
        assert len(calls) == 3

        # Every pair now resolves without further upstream traffic.
        calls.clear()
        for ccy, d in (("USD", d1), ("EUR", d1), ("EUR", d2)):
            assert fx.fetch_idr_rate(ccy, d) == Decimal("100")
        assert calls == []


@pytest.mark.django_db
class TestFinanceTotalsEndpoint:
    def test_totals_converted_without_serializing_rows(self, api_client, project, monkeypatch):
        from apps.projects import fx
        from apps.projects.models import ProjectFinance
        from decimal import Decimal

        fx.clear_rate_memo()
        # 1 USD = 16000 IDR, 1 EUR = 20000 IDR  ->  1 USD = 0.8 EUR
        rates = {"USD": Decimal("16000"), "EUR": Decimal("20000")}
        monkeypatch.setattr(fx, "_fetch_remote", lambda c, i: rates.get(c))

        ProjectFinance.objects.create(
            project=project, description="local", amount=Decimal("500"),
            currency="EUR", type="expense", date=date(2026, 6, 16),
        )
        ProjectFinance.objects.create(
            project=project, description="foreign", amount=Decimal("1000"),
            currency="USD", type="expense", date=date(2026, 6, 16),
        )
        ProjectFinance.objects.create(
            project=project, description="grant", amount=Decimal("100"),
            currency="EUR", type="income", date=date(2026, 6, 16),
        )

        resp = api_client.get(f"/api/projects/{project.id}/finance-totals/?to=EUR")
        assert resp.status_code == 200, resp.content
        assert resp.data["currency"] == "EUR"
        assert resp.data["entry_count"] == 3
        assert resp.data["unconverted_count"] == 0
        # 500 EUR + (1000 USD -> 800 EUR) = 1300
        assert Decimal(resp.data["spent"]) == Decimal("1300")
        assert Decimal(resp.data["income"]) == Decimal("100")
        fx.clear_rate_memo()

    def test_unconverted_entries_are_reported(self, api_client, project, monkeypatch):
        from apps.projects import fx
        from apps.projects.models import ProjectFinance
        from decimal import Decimal

        fx.clear_rate_memo()
        monkeypatch.setattr(fx, "_fetch_remote", lambda c, i: None)  # upstream down
        ProjectFinance.objects.create(
            project=project, description="foreign", amount=Decimal("1000"),
            currency="USD", type="expense", date=date(2026, 6, 16),
        )
        resp = api_client.get(f"/api/projects/{project.id}/finance-totals/?to=EUR")
        assert resp.status_code == 200, resp.content
        # Falls back to face value, and says so.
        assert Decimal(resp.data["spent"]) == Decimal("1000")
        assert resp.data["unconverted_count"] == 1
        fx.clear_rate_memo()


@pytest.mark.django_db
class TestCrossTenantProjectChildWrites:
    """Writable parent FKs on project sub-resources must reject foreign-org ids.

    Same class of bug as BE-002 (attendance): `organization_lookup` scopes reads,
    but DRF resolves a writable FK against the related model's unrestricted
    default queryset, so a POST carrying another org's parent id creates a valid
    row attached to the wrong tenant. Reads stay filtered, which is exactly what
    makes it easy to miss.
    """

    @pytest.fixture
    def other_org(self, db):
        return Organization.objects.create(name="Cross-Tenant Other Org")

    @pytest.fixture
    def client_in_org(self, db, organization):
        User = get_user_model()
        admin = User.objects.create_superuser(email="xtenant-admin@test.local", password="x")
        auto_org = admin.organizations.first()
        OrganizationMembership.objects.filter(organization=auto_org, user=admin).update(
            organization=organization
        )
        auto_org.delete()
        c = APIClient()
        c.force_authenticate(admin)
        return c

    @pytest.fixture
    def foreign_project(self, other_org):
        return Project.objects.create(organization=other_org, name="Foreign Project")

    @pytest.fixture
    def foreign_indicator(self, foreign_project):
        from apps.projects.models import ProjectIndicator
        return ProjectIndicator.objects.create(
            project=foreign_project, name="Foreign indicator", result_level="output"
        )

    def test_indicator_create_rejects_foreign_project(self, client_in_org, foreign_project):
        from apps.projects.models import ProjectIndicator
        resp = client_in_org.post("/api/project-indicators/", {
            "project": str(foreign_project.id), "name": "probe", "result_level": "output",
        }, format="json")
        assert resp.status_code == 400, resp.content
        assert not ProjectIndicator.objects.filter(project=foreign_project, name="probe").exists()

    def test_indicator_record_create_rejects_foreign_indicator(
        self, client_in_org, foreign_indicator,
    ):
        from apps.projects.models import IndicatorRecord
        resp = client_in_org.post("/api/indicator-records/", {
            "indicator": str(foreign_indicator.id), "period_date": "2026-01-01", "value": "5",
        }, format="json")
        assert resp.status_code == 400, resp.content
        assert not IndicatorRecord.objects.filter(indicator=foreign_indicator).exists()

    def test_risk_create_rejects_foreign_project(self, client_in_org, foreign_project):
        from apps.projects.models import ProjectRisk
        resp = client_in_org.post("/api/project-risks/", {
            "project": str(foreign_project.id), "title": "probe", "category": "financial",
        }, format="json")
        assert resp.status_code == 400, resp.content
        assert not ProjectRisk.objects.filter(project=foreign_project).exists()

    def test_indicator_update_cannot_reassign_to_foreign_project(
        self, client_in_org, organization, foreign_project,
    ):
        from apps.projects.models import ProjectIndicator
        own_project = Project.objects.create(organization=organization, name="Mine")
        ind = ProjectIndicator.objects.create(
            project=own_project, name="mine", result_level="output"
        )
        resp = client_in_org.patch(f"/api/project-indicators/{ind.id}/", {
            "project": str(foreign_project.id),
        }, format="json")
        assert resp.status_code == 400, resp.content
        ind.refresh_from_db()
        # project_id comes back as a UUID while Project.id is undashed hex
        # (uuid7 default) — compare on the hex form.
        assert str(ind.project_id).replace("-", "") == str(own_project.id).replace("-", "")

    def test_same_organization_indicator_create_still_succeeds(
        self, client_in_org, organization,
    ):
        from apps.projects.models import ProjectIndicator
        own_project = Project.objects.create(organization=organization, name="Mine 2")
        resp = client_in_org.post("/api/project-indicators/", {
            "project": str(own_project.id), "name": "ok", "result_level": "output",
        }, format="json")
        assert resp.status_code == 201, resp.content
        assert ProjectIndicator.objects.filter(project=own_project, name="ok").exists()

    def test_foreign_and_nonexistent_ids_are_indistinguishable(
        self, client_in_org, foreign_project,
    ):
        """A caller must not be able to probe which ids exist in other orgs."""
        import uuid
        foreign = client_in_org.post("/api/project-indicators/", {
            "project": str(foreign_project.id), "name": "p", "result_level": "output",
        }, format="json")
        missing = client_in_org.post("/api/project-indicators/", {
            "project": str(uuid.uuid4()), "name": "p", "result_level": "output",
        }, format="json")
        assert foreign.status_code == missing.status_code == 400
        assert foreign.data["project"][0].code == missing.data["project"][0].code
