"""Tests for the campaign manager-approval workflow."""

import uuid

import pytest

from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest
from app.models.technique import Technique
from app.models.test import Test
from app.models.enums import TestState
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError, PermissionViolation
from app.services.campaign_crud_service import (
    create_campaign,
    submit_campaign_for_approval,
    approve_campaign,
    reject_campaign,
)


@pytest.fixture
def draft_campaign_with_test(db, admin_user):
    """A draft campaign with exactly one test, created by admin_user."""
    tech = Technique(mitre_id="T1059", name="Command Line", tactic="execution", platforms=["windows"])
    db.add(tech)
    db.flush()

    campaign = Campaign(name="Approval Test Campaign", type="custom", status="draft", created_by=admin_user.id)
    db.add(campaign)
    db.flush()

    test = Test(technique_id=tech.id, name="T1059 test", state=TestState.draft, created_by=admin_user.id)
    db.add(test)
    db.flush()

    ct = CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0)
    db.add(ct)
    db.commit()
    db.refresh(campaign)
    return campaign


class TestSubmitCampaignForApproval:
    def test_submit_moves_draft_to_pending_approval(self, db, draft_campaign_with_test, admin_user):
        campaign = submit_campaign_for_approval(
            db, str(draft_campaign_with_test.id),
            submitter_id=admin_user.id, submitter_role="admin",
        )
        assert campaign.status == "pending_approval"

    def test_submit_without_tests_raises(self, db, admin_user):
        campaign = Campaign(name="Empty", type="custom", status="draft", created_by=admin_user.id)
        db.add(campaign)
        db.commit()
        with pytest.raises(BusinessRuleViolation, match="at least one test"):
            submit_campaign_for_approval(
                db, str(campaign.id), submitter_id=admin_user.id, submitter_role="admin",
            )

    def test_submit_by_non_creator_non_admin_raises(self, db, draft_campaign_with_test, red_lead_user):
        with pytest.raises(PermissionViolation):
            submit_campaign_for_approval(
                db, str(draft_campaign_with_test.id),
                submitter_id=red_lead_user.id, submitter_role="red_lead",
            )

    def test_submit_non_draft_raises(self, db, draft_campaign_with_test, admin_user):
        draft_campaign_with_test.status = "active"
        db.commit()
        with pytest.raises(BusinessRuleViolation, match="Only draft campaigns"):
            submit_campaign_for_approval(
                db, str(draft_campaign_with_test.id),
                submitter_id=admin_user.id, submitter_role="admin",
            )

    def test_submit_nonexistent_campaign_raises(self, db, admin_user):
        with pytest.raises(EntityNotFoundError):
            submit_campaign_for_approval(
                db, str(uuid.uuid4()), submitter_id=admin_user.id, submitter_role="admin",
            )


class TestApproveCampaign:
    def test_approve_sets_start_date_and_activates(self, db, draft_campaign_with_test, admin_user):
        submit_campaign_for_approval(
            db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
        )
        db.commit()
        campaign = approve_campaign(
            db, str(draft_campaign_with_test.id),
            approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
        )
        assert campaign.status == "active"
        assert campaign.start_date is not None
        assert campaign.approved_by == admin_user.id
        assert campaign.approved_at is not None

    def test_approve_without_start_date_raises(self, db, draft_campaign_with_test, admin_user):
        submit_campaign_for_approval(
            db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
        )
        db.commit()
        with pytest.raises(BusinessRuleViolation, match="start_date is required"):
            approve_campaign(db, str(draft_campaign_with_test.id), approver_id=admin_user.id, start_date="")

    def test_approve_non_pending_raises(self, db, draft_campaign_with_test, admin_user):
        with pytest.raises(BusinessRuleViolation, match="pending approval"):
            approve_campaign(
                db, str(draft_campaign_with_test.id),
                approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
            )

    def test_approve_nonexistent_campaign_raises(self, db, admin_user):
        with pytest.raises(EntityNotFoundError):
            approve_campaign(
                db, str(uuid.uuid4()), approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
            )


class TestRejectCampaign:
    def test_reject_returns_to_draft_with_reason(self, db, draft_campaign_with_test, admin_user):
        submit_campaign_for_approval(
            db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
        )
        db.commit()
        campaign = reject_campaign(
            db, str(draft_campaign_with_test.id),
            rejecter_id=admin_user.id, reason="Missing scope details",
        )
        assert campaign.status == "draft"
        assert campaign.rejection_reason == "Missing scope details"

    def test_reject_without_reason_raises(self, db, draft_campaign_with_test, admin_user):
        submit_campaign_for_approval(
            db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
        )
        db.commit()
        with pytest.raises(BusinessRuleViolation, match="reason is required"):
            reject_campaign(db, str(draft_campaign_with_test.id), rejecter_id=admin_user.id, reason="")

    def test_reject_non_pending_raises(self, db, draft_campaign_with_test, admin_user):
        with pytest.raises(BusinessRuleViolation, match="pending approval"):
            reject_campaign(db, str(draft_campaign_with_test.id), rejecter_id=admin_user.id, reason="No")

    def test_reject_clears_prior_approval_fields(self, db, draft_campaign_with_test, admin_user):
        submit_campaign_for_approval(
            db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
        )
        db.commit()
        approve_campaign(
            db, str(draft_campaign_with_test.id),
            approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
        )
        db.commit()
        # Simulate the campaign being sent back into the approval queue
        # (e.g. after a modification request) so reject_campaign has
        # approved_by/approved_at to clear.
        draft_campaign_with_test.status = "pending_approval"
        db.commit()

        campaign = reject_campaign(
            db, str(draft_campaign_with_test.id),
            rejecter_id=admin_user.id, reason="Needs rework",
        )
        assert campaign.status == "draft"
        assert campaign.approved_by is None
        assert campaign.approved_at is None

    def test_reject_nonexistent_campaign_raises(self, db, admin_user):
        with pytest.raises(EntityNotFoundError):
            reject_campaign(db, str(uuid.uuid4()), rejecter_id=admin_user.id, reason="No")


from app.services.campaign_crud_service import (
    add_test_to_campaign,
    remove_test_from_campaign,
    create_modification_request,
    approve_modification_request,
    reject_modification_request,
    list_modification_requests,
    serialize_modification_request,
)


@pytest.fixture
def active_campaign_with_test(db, draft_campaign_with_test, admin_user):
    """An active campaign (bypasses the approval flow directly for test setup)."""
    draft_campaign_with_test.status = "active"
    db.commit()
    db.refresh(draft_campaign_with_test)
    return draft_campaign_with_test


class TestDirectTestEditingGate:
    def test_add_test_blocked_on_active_campaign(self, db, active_campaign_with_test, admin_user):
        tech = Technique(mitre_id="T1078", name="Valid Accounts", tactic="persistence", platforms=["windows"])
        db.add(tech)
        db.flush()
        new_test = Test(technique_id=tech.id, name="New test", state=TestState.draft, created_by=admin_user.id)
        db.add(new_test)
        db.commit()

        with pytest.raises(BusinessRuleViolation, match="draft"):
            add_test_to_campaign(db, str(active_campaign_with_test.id), test_id=str(new_test.id))

    def test_remove_test_blocked_on_active_campaign(self, db, active_campaign_with_test):
        ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
        with pytest.raises(BusinessRuleViolation, match="draft"):
            remove_test_from_campaign(db, str(active_campaign_with_test.id), str(ct.id))


class TestModificationRequests:
    def test_create_add_test_request(self, db, active_campaign_with_test, admin_user):
        tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
        db.add(tech)
        db.flush()
        new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=admin_user.id)
        db.add(new_test)
        db.commit()

        request = create_modification_request(
            db, str(active_campaign_with_test.id),
            requester_id=admin_user.id, action="add_test",
            test_id=str(new_test.id), justification="Coverage gap found in review",
        )
        assert request.status == "pending"

        # Underlying campaign is untouched until approval
        cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
        assert len(cts) == 1

    def test_create_request_without_justification_raises(self, db, active_campaign_with_test, admin_user):
        ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
        with pytest.raises(BusinessRuleViolation, match="justification is required"):
            create_modification_request(
                db, str(active_campaign_with_test.id),
                requester_id=admin_user.id, action="remove_test",
                test_id=str(ct.test_id), justification="",
            )

    def test_create_request_on_non_active_campaign_raises(self, db, draft_campaign_with_test, admin_user):
        ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == draft_campaign_with_test.id).first()
        with pytest.raises(BusinessRuleViolation, match="active campaigns"):
            create_modification_request(
                db, str(draft_campaign_with_test.id),
                requester_id=admin_user.id, action="remove_test",
                test_id=str(ct.test_id), justification="Not needed",
            )

    def test_approve_add_test_request_applies_change(self, db, active_campaign_with_test, admin_user):
        tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
        db.add(tech)
        db.flush()
        new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=admin_user.id)
        db.add(new_test)
        db.commit()

        request = create_modification_request(
            db, str(active_campaign_with_test.id),
            requester_id=admin_user.id, action="add_test",
            test_id=str(new_test.id), justification="Coverage gap found in review",
        )
        db.commit()

        approved = approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
        assert approved.status == "approved"

        cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
        assert len(cts) == 2
        assert any(ct.test_id == new_test.id for ct in cts)

    def test_approve_remove_test_request_applies_change(self, db, active_campaign_with_test, admin_user):
        ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
        request = create_modification_request(
            db, str(active_campaign_with_test.id),
            requester_id=admin_user.id, action="remove_test",
            test_id=str(ct.test_id), justification="Test superseded",
        )
        db.commit()
        request_id = request.id

        approve_modification_request(db, str(request_id), reviewer_id=admin_user.id)
        db.commit()

        cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
        assert len(cts) == 0

        # The request row must survive deletion of the underlying Test — this
        # locks in test_id's ondelete="SET NULL" (not CASCADE), which is what
        # keeps this audit record intact after the test it referenced is gone.
        # (Commit above first: ON DELETE SET NULL is applied DB-side by the
        # cascade, and the ORM only picks that up on already-loaded objects
        # after expire_on_commit forces a fresh read — exactly what happens
        # for real in the router, which commits before serializing.)
        reloaded = (
            db.query(CampaignModificationRequest)
            .filter(CampaignModificationRequest.id == request_id)
            .first()
        )
        assert reloaded is not None
        assert reloaded.status == "approved"
        assert reloaded.reviewed_by == admin_user.id
        assert reloaded.reviewed_at is not None

        # Serialization must not crash and must emit a real null, not the
        # string "None", for a request whose test_id has been nulled out.
        assert reloaded.test_id is None
        serialized = serialize_modification_request(db, reloaded)
        assert serialized["test_id"] is None
        assert serialized["test_name"] is None

    def test_approve_already_decided_request_raises(self, db, active_campaign_with_test, admin_user):
        ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
        request = create_modification_request(
            db, str(active_campaign_with_test.id),
            requester_id=admin_user.id, action="remove_test",
            test_id=str(ct.test_id), justification="Test superseded",
        )
        db.commit()
        approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
        db.commit()

        with pytest.raises(BusinessRuleViolation, match="Only pending"):
            approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)

    def test_reject_request_leaves_campaign_untouched(self, db, active_campaign_with_test, admin_user):
        ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
        request = create_modification_request(
            db, str(active_campaign_with_test.id),
            requester_id=admin_user.id, action="remove_test",
            test_id=str(ct.test_id), justification="Test superseded",
        )
        db.commit()

        rejected = reject_modification_request(
            db, str(request.id), reviewer_id=admin_user.id, review_notes="Still needed for coverage",
        )
        assert rejected.status == "rejected"
        assert rejected.review_notes == "Still needed for coverage"

        cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
        assert len(cts) == 1

    def test_reject_without_notes_raises(self, db, active_campaign_with_test, admin_user):
        ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
        request = create_modification_request(
            db, str(active_campaign_with_test.id),
            requester_id=admin_user.id, action="remove_test",
            test_id=str(ct.test_id), justification="Test superseded",
        )
        db.commit()
        with pytest.raises(BusinessRuleViolation, match="Review notes are required"):
            reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="")

    def test_reject_already_decided_request_raises(self, db, active_campaign_with_test, admin_user):
        ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
        request = create_modification_request(
            db, str(active_campaign_with_test.id),
            requester_id=admin_user.id, action="remove_test",
            test_id=str(ct.test_id), justification="Test superseded",
        )
        db.commit()
        reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="Not needed")
        db.commit()

        with pytest.raises(BusinessRuleViolation, match="Only pending"):
            reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="Again")

    def test_list_modification_requests_filters_by_status(self, db, active_campaign_with_test, admin_user):
        ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
        create_modification_request(
            db, str(active_campaign_with_test.id),
            requester_id=admin_user.id, action="remove_test",
            test_id=str(ct.test_id), justification="Test superseded",
        )
        db.commit()

        pending = list_modification_requests(db, status="pending")
        assert len(pending) == 1
        assert pending[0]["action"] == "remove_test"


from app.services.audit_service import log_action
from app.services.campaign_crud_service import get_campaign_timeline


class TestCampaignTimeline:
    def test_timeline_returns_entries_in_chronological_order(self, db, draft_campaign_with_test, admin_user):
        log_action(
            db, user_id=admin_user.id, action="create_campaign",
            entity_type="campaign", entity_id=str(draft_campaign_with_test.id), details={},
        )
        log_action(
            db, user_id=admin_user.id, action="submit_campaign_for_approval",
            entity_type="campaign", entity_id=str(draft_campaign_with_test.id), details={},
        )
        db.commit()

        timeline = get_campaign_timeline(db, str(draft_campaign_with_test.id))
        assert len(timeline) == 2
        assert timeline[0]["action"] == "create_campaign"
        assert timeline[1]["action"] == "submit_campaign_for_approval"

    def test_timeline_missing_campaign_raises(self, db):
        import uuid as uuid_mod
        with pytest.raises(EntityNotFoundError):
            get_campaign_timeline(db, str(uuid_mod.uuid4()))
