"""Donor portal: scoping (fail-closed), review workflow, summary endpoints,
and donor-side team management."""
from datetime import date, timedelta
from decimal import Decimal

import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from rest_framework.test import APIClient

from apps.companies.models import Company, Organization, OrganizationMembership
from apps.core.models import Invitation, User
from apps.meetings.models import Meeting
from apps.projects.models import (
    Deliverable,
    FundReport,
    IndicatorRecord,
    Project,
    ProjectFund,
    ProjectIndicator,
    ProjectRisk,
    ProjectSchedule,
    SubmissionVersion,
)


def api(user):
    client = APIClient()
    client.force_authenticate(user)
    return client


def unwrap(data):
    """Paginated or plain-list response -> list of rows."""
    return data["results"] if isinstance(data, dict) and "results" in data else data


@pytest.fixture
def org(db):
    return Organization.objects.create(name="CSIS")


@pytest.fixture
def company_a(org):
    return Company.objects.create(name="Donor A", organization=org, company_type="donor")


@pytest.fixture
def company_b(org):
    return Company.objects.create(name="Donor B", organization=org, company_type="donor")


@pytest.fixture
def donor_admin_a(org, company_a):
    u = User.objects.create_user(
        email="admin-a@donor.test", password="p",
        organization_role="donor", donor_company=company_a, donor_role="admin",
    )
    OrganizationMembership.objects.create(organization=org, user=u)
    return u


@pytest.fixture
def donor_viewer_a(org, company_a):
    u = User.objects.create_user(
        email="viewer-a@donor.test", password="p",
        organization_role="donor", donor_company=company_a, donor_role="viewer",
    )
    OrganizationMembership.objects.create(organization=org, user=u)
    return u


@pytest.fixture
def donor_b(org, company_b):
    u = User.objects.create_user(
        email="admin-b@donor.test", password="p",
        organization_role="donor", donor_company=company_b, donor_role="admin",
    )
    OrganizationMembership.objects.create(organization=org, user=u)
    return u


@pytest.fixture
def staff(org):
    u = User.objects.create_user(email="staff@csis.test", password="p", organization_role="director")
    OrganizationMembership.objects.create(organization=org, user=u)
    return u


@pytest.fixture
def project_a(org):
    return Project.objects.create(organization=org, name="Project A")


@pytest.fixture
def project_b(org):
    return Project.objects.create(organization=org, name="Project B")


@pytest.fixture
def fund_a(org, company_a, project_a):
    return ProjectFund.objects.create(
        organization=org, project=project_a, source="Donor A", donor=company_a,
        amount=Decimal("100000"), currency="USD", grant_number="GA-001",
    )


@pytest.fixture
def fund_b(org, company_b, project_b):
    return ProjectFund.objects.create(
        organization=org, project=project_b, source="Donor B", donor=company_b,
        amount=Decimal("50000"), currency="USD",
    )


@pytest.mark.django_db
class TestDonorScoping:
    """Every donor list endpoint must return only the requesting donor's rows
    and must be closed to staff accounts."""

    def test_funds_scoped_to_own_company(self, donor_admin_a, fund_a, fund_b):
        rows = unwrap(api(donor_admin_a).get("/api/donor/funds/").json())
        assert [r["grant_number"] for r in rows] == ["GA-001"]

    def test_staff_rejected(self, staff, fund_a):
        # Directors are not donors; the donor surface stays closed to them.
        assert api(staff).get("/api/donor/funds/").status_code == 403

    def test_anonymous_rejected(self, db):
        assert APIClient().get("/api/donor/funds/").status_code in (401, 403)

    def test_deliverables_scoped(self, donor_admin_a, fund_a, fund_b):
        Deliverable.objects.create(fund=fund_a, title="Policy Brief A")
        Deliverable.objects.create(fund=fund_b, title="Policy Brief B")
        Deliverable.objects.create(fund=fund_a, title="Hidden", donor_visible=False)
        rows = unwrap(api(donor_admin_a).get("/api/donor/deliverables/").json())
        assert [r["title"] for r in rows] == ["Policy Brief A"]

    def test_indicators_scoped_and_visible_only(self, donor_admin_a, fund_a, project_a, project_b, fund_b):
        ProjectIndicator.objects.create(project=project_a, name="Publications", target=10)
        ProjectIndicator.objects.create(project=project_a, name="Secret", target=1, donor_visible=False)
        ProjectIndicator.objects.create(project=project_b, name="Other donor", target=5)
        rows = unwrap(api(donor_admin_a).get("/api/donor/indicators/").json())
        assert [r["name"] for r in rows] == ["Publications"]

    def test_risks_fail_closed(self, donor_admin_a, fund_a, project_a):
        ProjectRisk.objects.create(project=project_a, title="Internal only")  # default hidden
        ProjectRisk.objects.create(project=project_a, title="Shared", donor_visible=True)
        rows = unwrap(api(donor_admin_a).get("/api/donor/risks/").json())
        assert [r["title"] for r in rows] == ["Shared"]

    def test_milestones_scoped(self, donor_admin_a, fund_a, project_a):
        ProjectSchedule.objects.create(project=project_a, title="Phase 1")
        ProjectSchedule.objects.create(project=project_a, title="Internal", donor_visible=False)
        rows = unwrap(api(donor_admin_a).get("/api/donor/milestones/").json())
        assert [r["title"] for r in rows] == ["Phase 1"]

    def test_meetings_scoped(self, donor_admin_a, org, fund_a, fund_b, staff):
        from django.utils import timezone
        now = timezone.now()
        Meeting.objects.create(
            organization=org, title="Grant review A", fund=fund_a, donor_visible=True,
            start_time=now, end_time=now + timedelta(hours=1), organizer=staff,
        )
        Meeting.objects.create(
            organization=org, title="Internal", fund=fund_a, donor_visible=False,
            start_time=now, end_time=now + timedelta(hours=1), organizer=staff,
        )
        Meeting.objects.create(
            organization=org, title="Grant review B", fund=fund_b, donor_visible=True,
            start_time=now, end_time=now + timedelta(hours=1), organizer=staff,
        )
        rows = unwrap(api(donor_admin_a).get("/api/donor/meetings/").json())
        assert [r["title"] for r in rows] == ["Grant review A"]


@pytest.mark.django_db
class TestIndicators:
    def test_current_value_sum_and_latest(self, project_a):
        cumulative = ProjectIndicator.objects.create(project=project_a, name="Participants", aggregation="sum", target=100)
        IndicatorRecord.objects.create(indicator=cumulative, period_date=date(2026, 3, 31), value=30)
        IndicatorRecord.objects.create(indicator=cumulative, period_date=date(2026, 6, 30), value=20)
        assert cumulative.current_value() == 50

        latest = ProjectIndicator.objects.create(project=project_a, name="Score", aggregation="latest", target=10)
        IndicatorRecord.objects.create(indicator=latest, period_date=date(2026, 3, 31), value=4)
        IndicatorRecord.objects.create(indicator=latest, period_date=date(2026, 6, 30), value=7)
        assert latest.current_value() == 7

    def test_indicator_serializer_achievement(self, donor_admin_a, fund_a, project_a):
        ind = ProjectIndicator.objects.create(project=project_a, name="Publications", target=10, aggregation="sum")
        IndicatorRecord.objects.create(indicator=ind, period_date=date(2026, 6, 30), value=4)
        rows = unwrap(api(donor_admin_a).get("/api/donor/indicators/").json())
        assert rows[0]["current_value"] == "4.00"
        assert rows[0]["achievement_pct"] == 40.0


@pytest.mark.django_db
class TestReviewWorkflow:
    def _submit(self, staff, report):
        return api(staff).post(
            f"/api/fund-reports/{report.id}/submit/",
            {"file": SimpleUploadedFile("r.pdf", b"pdf"), "note": "first draft"},
            format="multipart",
        )

    def test_staff_submit_creates_version_and_status(self, staff, fund_a):
        report = FundReport.objects.create(fund=fund_a, title="Q1", status="upcoming")
        resp = self._submit(staff, report)
        assert resp.status_code == 200, resp.content
        report.refresh_from_db()
        assert report.status == "submitted"
        assert report.submitted_date == date.today()
        assert SubmissionVersion.objects.filter(object_id=str(report.id)).count() == 1

    def test_donor_admin_approves(self, staff, donor_admin_a, fund_a):
        report = FundReport.objects.create(fund=fund_a, title="Q1", status="upcoming")
        self._submit(staff, report)
        resp = api(donor_admin_a).post(
            f"/api/donor/fund-reports/{report.id}/review/",
            {"action": "approve", "note": "Looks good"}, format="json",
        )
        assert resp.status_code == 200, resp.content
        report.refresh_from_db()
        assert report.status == "approved"
        assert report.reviewed_by == donor_admin_a

    def test_viewer_cannot_review(self, staff, donor_viewer_a, fund_a):
        report = FundReport.objects.create(fund=fund_a, title="Q1", status="upcoming")
        self._submit(staff, report)
        resp = api(donor_viewer_a).post(
            f"/api/donor/fund-reports/{report.id}/review/", {"action": "approve"}, format="json",
        )
        assert resp.status_code == 403

    def test_other_donor_cannot_review(self, staff, donor_b, fund_a):
        report = FundReport.objects.create(fund=fund_a, title="Q1", status="upcoming")
        self._submit(staff, report)
        resp = api(donor_b).post(
            f"/api/donor/fund-reports/{report.id}/review/", {"action": "approve"}, format="json",
        )
        assert resp.status_code == 404

    def test_illegal_transition_rejected(self, donor_admin_a, fund_a):
        report = FundReport.objects.create(fund=fund_a, title="Q1", status="upcoming")
        resp = api(donor_admin_a).post(
            f"/api/donor/fund-reports/{report.id}/review/", {"action": "approve"}, format="json",
        )
        assert resp.status_code == 400

    def test_revision_then_resubmit_bumps_version(self, staff, donor_admin_a, fund_a):
        report = FundReport.objects.create(fund=fund_a, title="Q1", status="upcoming")
        self._submit(staff, report)
        api(donor_admin_a).post(
            f"/api/donor/fund-reports/{report.id}/review/",
            {"action": "request_revision", "note": "fix annex"}, format="json",
        )
        report.refresh_from_db()
        assert report.status == "revision_requested"
        resp = api(staff).post(
            f"/api/fund-reports/{report.id}/submit/",
            {"file": SimpleUploadedFile("r2.pdf", b"pdf2"), "note": "annex fixed"},
            format="multipart",
        )
        assert resp.status_code == 200
        report.refresh_from_db()
        assert report.status == "resubmitted"
        versions = SubmissionVersion.objects.filter(object_id=str(report.id)).order_by("version_number")
        assert [v.version_number for v in versions] == [1, 2]

    def test_deliverable_approval_sets_approved_date(self, staff, donor_admin_a, fund_a):
        d = Deliverable.objects.create(fund=fund_a, title="Policy Brief")
        resp = api(staff).post(
            f"/api/deliverables/{d.id}/submit/",
            {"file": SimpleUploadedFile("d.pdf", b"pdf"), "note": "v1"},
            format="multipart",
        )
        assert resp.status_code == 200, resp.content
        resp = api(donor_admin_a).post(
            f"/api/donor/deliverables/{d.id}/review/", {"action": "approve"}, format="json",
        )
        assert resp.status_code == 200, resp.content
        d.refresh_from_db()
        assert d.status == "approved"
        assert d.approved_date == date.today()

    def test_donor_sees_versions(self, staff, donor_admin_a, fund_a):
        report = FundReport.objects.create(fund=fund_a, title="Q1", status="upcoming")
        self._submit(staff, report)
        detail = api(donor_admin_a).get(f"/api/donor/fund-reports/{report.id}/").json()
        assert len(detail["versions"]) == 1
        assert detail["versions"][0]["version_number"] == 1


@pytest.mark.django_db
class TestDeliverableDonorStatus:
    def test_internal_statuses_collapsed(self, donor_admin_a, fund_a):
        Deliverable.objects.create(fund=fund_a, title="WIP", status="internal_review")
        rows = unwrap(api(donor_admin_a).get("/api/donor/deliverables/").json())
        assert rows[0]["donor_status"] == "in_preparation"


@pytest.mark.django_db
class TestDonorSummary:
    def test_summary_totals(self, donor_admin_a, fund_a, fund_b):
        from apps.projects.models import ProjectFundTranche
        ProjectFundTranche.objects.create(fund=fund_a, label="T1", amount=Decimal("40000"), status="received")
        FundReport.objects.create(fund=fund_a, title="Due soon", status="upcoming", due_date=date.today() + timedelta(days=10))
        resp = api(donor_admin_a).get("/api/donor/summary/")
        assert resp.status_code == 200
        data = resp.json()
        assert data["funding"]["committed"] == "100000.00"
        assert data["funding"]["disbursed"] == "40000.00"
        assert data["obligations"]["reports_due_soon"] == 1
        assert data["projects"]["total"] == 1

    def test_finance_summary_scoped(self, donor_b, fund_a):
        resp = api(donor_b).get(f"/api/donor/finance-summary/?fund={fund_a.id}")
        assert resp.status_code == 404


@pytest.mark.django_db
class TestDonorTeam:
    def test_admin_lists_own_company_users(self, donor_admin_a, donor_viewer_a, donor_b):
        rows = unwrap(api(donor_admin_a).get("/api/donor/team/").json())
        emails = {r["email"] for r in rows}
        assert emails == {"admin-a@donor.test", "viewer-a@donor.test"}

    def test_admin_invites_viewer(self, donor_admin_a, fund_a, company_a):
        resp = api(donor_admin_a).post(
            "/api/donor/team/invite/",
            {"email": "new@donor.test", "donor_role": "viewer"}, format="json",
        )
        assert resp.status_code == 201, resp.content
        invite = Invitation.objects.get(email="new@donor.test")
        assert str(invite.donor_company_id).replace("-", "") == str(company_a.id).replace("-", "")
        assert invite.donor_role == "viewer"

    def test_viewer_cannot_invite(self, donor_viewer_a, fund_a):
        resp = api(donor_viewer_a).post(
            "/api/donor/team/invite/",
            {"email": "x@donor.test", "donor_role": "viewer"}, format="json",
        )
        assert resp.status_code == 403


@pytest.mark.django_db
class TestCoFundedSpendAttribution:
    """A project funded by two donors must not leak one donor's spend into the
    other's actuals.

    Donor spend used to be filtered by project + budget_item. BudgetItem is a
    shared catalog code (B.1 = "Travel"), so two donors allocating to the same
    line each saw the other's expenses summed into their own actuals — both a
    wrong figure and a cross-donor disclosure. Attribution now runs off
    ProjectFinance.fund.
    """

    @pytest.fixture
    def shared_project(self, org):
        return Project.objects.create(organization=org, name="Co-funded Project")

    @pytest.fixture
    def budget_item(self, org):
        from apps.finance.models import BudgetCategory, BudgetItem
        category = BudgetCategory.objects.create(organization=org, name="Travel", code="B")
        return BudgetItem.objects.create(category=category, name="Airfare", code="B.1")

    @pytest.fixture
    def cofund_a(self, org, company_a, shared_project):
        return ProjectFund.objects.create(
            organization=org, project=shared_project, source="Donor A", donor=company_a,
            amount=Decimal("100000"), currency="USD",
        )

    @pytest.fixture
    def cofund_b(self, org, company_b, shared_project):
        return ProjectFund.objects.create(
            organization=org, project=shared_project, source="Donor B", donor=company_b,
            amount=Decimal("100000"), currency="USD",
        )

    def _allocate(self, fund, budget_item, amount):
        from apps.projects.models import ProjectFundAllocation
        return ProjectFundAllocation.objects.create(
            fund=fund, budget_item=budget_item, amount=Decimal(amount),
        )

    def _spend(self, project, budget_item, amount, fund=None):
        from apps.projects.models import ProjectFinance
        return ProjectFinance.objects.create(
            project=project, fund=fund, budget_item=budget_item,
            description="Airfare", amount=Decimal(amount), currency="USD",
            type="expense", approval_status="approved", date=date.today(),
        )

    def test_donor_sees_only_own_spend_on_shared_budget_line(
        self, donor_admin_a, shared_project, budget_item, cofund_a, cofund_b,
    ):
        self._allocate(cofund_a, budget_item, "10000")
        self._allocate(cofund_b, budget_item, "10000")
        self._spend(shared_project, budget_item, "3000", fund=cofund_a)
        self._spend(shared_project, budget_item, "7000", fund=cofund_b)

        resp = api(donor_admin_a).get(f"/api/donor/finance-summary/?fund={cofund_a.id}")
        assert resp.status_code == 200, resp.content
        lines = resp.json()["lines"]
        assert len(lines) == 1
        # Donor A funded 3000 of the 10000 spent on this line. Before the fix
        # this returned 10000 — Donor B's 7000 included.
        assert lines[0]["actual"] == "3000.00"

    def test_unattributed_expense_is_hidden_from_donors(
        self, donor_admin_a, shared_project, budget_item, cofund_a,
    ):
        self._allocate(cofund_a, budget_item, "10000")
        self._spend(shared_project, budget_item, "2000", fund=cofund_a)
        self._spend(shared_project, budget_item, "5000", fund=None)

        resp = api(donor_admin_a).get(f"/api/donor/finance-summary/?fund={cofund_a.id}")
        assert resp.json()["lines"][0]["actual"] == "2000.00"

    def test_internal_fund_is_unreachable_from_the_portal(
        self, donor_admin_a, org, shared_project, cofund_a,
    ):
        # Self-funded work is a fund with no donor company, so no donor account
        # can ever resolve it.
        internal = ProjectFund.objects.create(
            organization=org, project=shared_project, source="Internal budget",
            donor=None, amount=Decimal("5000"), currency="USD",
        )
        resp = api(donor_admin_a).get(f"/api/donor/finance-summary/?fund={internal.id}")
        assert resp.status_code == 404

        fund_ids = {r["id"] for r in unwrap(api(donor_admin_a).get("/api/donor/funds/").json())}
        assert str(internal.id) not in fund_ids

    def test_co_funders_both_see_the_shared_project(
        self, donor_admin_a, donor_b, shared_project, cofund_a, cofund_b,
    ):
        for user in (donor_admin_a, donor_b):
            rows = unwrap(api(user).get("/api/donor/projects/").json())
            assert {r["name"] for r in rows} == {"Co-funded Project"}
