"""Campaign CRUD service — list, create, update, and business logic.

Framework-agnostic; uses domain exceptions from app.domain.errors.
The router is responsible for HTTP concerns, auth, audit logging, and commit.
"""

# Import uuid
import uuid

# Import datetime from datetime
from datetime import datetime

# Import Optional from typing
from typing import Optional

# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session

# Import  from app.domain.errors
from app.domain.errors import (
    BusinessRuleViolation,
    EntityNotFoundError,
    PermissionViolation,
)

# Import Campaign, CampaignModificationRequest, CampaignTest from app.models.campaign
from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest

# Import AuditLog from app.models.audit
from app.models.audit import AuditLog

# Import Technique from app.models.technique
from app.models.technique import Technique

# Import Test from app.models.test
from app.models.test import Test

# Import calculate_next_run from app.services.campaign_scheduler_service
from app.services.campaign_scheduler_service import calculate_next_run
from app.services.status_service import recalculate_technique_status

# Import  from app.services.campaign_service
from app.services.campaign_service import (
    TACTIC_TO_PHASE,
    get_campaign_progress,
    validate_no_circular_dependency,
)

# Import escape_like from app.utils
from app.utils import escape_like

# ── Serialization helpers ────────────────────────────────────────────────


def serialize_campaign(db: Session, campaign: Campaign) -> dict:
    """Serialize a campaign with its tests and progress."""
    # Assign progress = get_campaign_progress(db, campaign.id)
    progress = get_campaign_progress(db, campaign.id)

    # Assign campaign_tests = (
    campaign_tests = (
        db.query(CampaignTest)
        # Chain .filter() call
        .filter(CampaignTest.campaign_id == campaign.id)
        # Chain .order_by() call
        .order_by(CampaignTest.order_index)
        # Chain .all() call
        .all()
    )

    # Assign tests = []
    tests = []
    # Iterate over campaign_tests
    for ct in campaign_tests:
        # Assign test = ct.test
        test = ct.test
        # Assign technique = db.query(Technique).filter(Technique.id == test.technique_id).first...
        technique = db.query(Technique).filter(Technique.id == test.technique_id).first() if test else None

        # Call tests.append()
        tests.append({
            # Literal argument value
            "id": str(ct.id),
            # Literal argument value
            "test_id": str(ct.test_id),
            # Literal argument value
            "order_index": ct.order_index,
            # Literal argument value
            "depends_on": str(ct.depends_on) if ct.depends_on else None,
            # Literal argument value
            "phase": ct.phase,
            # Literal argument value
            "test_name": test.name if test else None,
            # Literal argument value
            "test_state": test.state.value if test and test.state else None,
            # Literal argument value
            "test_result": test.result.value if test and test.result else None,
            # Literal argument value
            "technique_mitre_id": technique.mitre_id if technique else None,
            # Literal argument value
            "technique_name": technique.name if technique else None,
            # Literal argument value
            "platform": test.platform if test else None,
        })

    # Assign actor = campaign.threat_actor
    actor = campaign.threat_actor

    # Return {
    return {
        # Literal argument value
        "id": str(campaign.id),
        # Literal argument value
        "name": campaign.name,
        # Literal argument value
        "description": campaign.description,
        # Literal argument value
        "type": campaign.type,
        # Literal argument value
        "status": campaign.status,
        # Literal argument value
        "threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None,
        # Literal argument value
        "threat_actor_name": actor.name if actor else None,
        # Literal argument value
        "created_by": str(campaign.created_by) if campaign.created_by else None,
        "start_date": campaign.start_date.isoformat() if campaign.start_date else None,
        "scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
        # Literal argument value
        "completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None,
        "approved_by": str(campaign.approved_by) if campaign.approved_by else None,
        "approved_at": campaign.approved_at.isoformat() if campaign.approved_at else None,
        "rejection_reason": campaign.rejection_reason,
        # Literal argument value
        "target_platform": campaign.target_platform,
        # Literal argument value
        "tags": campaign.tags or [],
        # Literal argument value
        "created_at": campaign.created_at.isoformat() if campaign.created_at else None,
        # Literal argument value
        "is_recurring": campaign.is_recurring or False,
        # Literal argument value
        "recurrence_pattern": campaign.recurrence_pattern,
        # Literal argument value
        "next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
        # Literal argument value
        "last_run_at": campaign.last_run_at.isoformat() if campaign.last_run_at else None,
        # Literal argument value
        "parent_campaign_id": str(campaign.parent_campaign_id) if campaign.parent_campaign_id else None,
        # Literal argument value
        "tests": tests,
        # Literal argument value
        "progress": progress,
    }


# Define function serialize_campaign_summary
def serialize_campaign_summary(db: Session, campaign: Campaign) -> dict:
    """Lightweight campaign serialization for list views."""
    # Assign progress = get_campaign_progress(db, campaign.id)
    progress = get_campaign_progress(db, campaign.id)
    # Assign actor = campaign.threat_actor
    actor = campaign.threat_actor

    # Return {
    return {
        # Literal argument value
        "id": str(campaign.id),
        # Literal argument value
        "name": campaign.name,
        # Literal argument value
        "description": campaign.description,
        # Literal argument value
        "type": campaign.type,
        # Literal argument value
        "status": campaign.status,
        # Literal argument value
        "threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None,
        # Literal argument value
        "threat_actor_name": actor.name if actor else None,
        "start_date": campaign.start_date.isoformat() if campaign.start_date else None,
        "target_platform": campaign.target_platform,
        # Literal argument value
        "tags": campaign.tags or [],
        # Literal argument value
        "created_at": campaign.created_at.isoformat() if campaign.created_at else None,
        # Literal argument value
        "is_recurring": campaign.is_recurring or False,
        # Literal argument value
        "recurrence_pattern": campaign.recurrence_pattern,
        # Literal argument value
        "next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
        # Literal argument value
        "last_run_at": campaign.last_run_at.isoformat() if campaign.last_run_at else None,
        # Literal argument value
        "test_count": progress["total"],
        # Literal argument value
        "completion_pct": progress["completion_pct"],
    }


# ── CRUD operations ───────────────────────────────────────────────────────


def list_campaigns(
    # Entry: db
    db: Session,
    *,
    # Entry: type
    type: Optional[str] = None,
    # Entry: status
    status: Optional[str] = None,
    # Entry: threat_actor_id
    threat_actor_id: Optional[str] = None,
    # Entry: search
    search: Optional[str] = None,
    # Entry: offset
    offset: int = 0,
    # Entry: limit
    limit: int = 50,
) -> dict:
    """Return a paginated list of campaigns with optional filters."""
    # Assign query = db.query(Campaign)
    query = db.query(Campaign)

    # Check: type
    if type:
        # Assign query = query.filter(Campaign.type == type)
        query = query.filter(Campaign.type == type)
    # Check: status
    if status:
        # Assign query = query.filter(Campaign.status == status)
        query = query.filter(Campaign.status == status)
    # Check: threat_actor_id
    if threat_actor_id:
        # Assign query = query.filter(Campaign.threat_actor_id == threat_actor_id)
        query = query.filter(Campaign.threat_actor_id == threat_actor_id)
    # Check: search
    if search:
        # Assign pattern = f"%{escape_like(search)}%"
        pattern = f"%{escape_like(search)}%"
        # Assign query = query.filter(Campaign.name.ilike(pattern) | Campaign.description.il...
        query = query.filter(Campaign.name.ilike(pattern) | Campaign.description.ilike(pattern))

    # Assign total = query.count()
    total = query.count()
    # Assign campaigns = query.order_by(Campaign.created_at.desc()).offset(offset).limit(lim...
    campaigns = query.order_by(Campaign.created_at.desc()).offset(offset).limit(limit).all()

    # Return {
    return {
        # Literal argument value
        "total": total,
        # Literal argument value
        "offset": offset,
        # Literal argument value
        "limit": limit,
        # Literal argument value
        "items": [serialize_campaign_summary(db, c) for c in campaigns],
    }


# Define function create_campaign
def create_campaign(
    # Entry: db
    db: Session,
    *,
    # Entry: creator_id
    creator_id: uuid.UUID,
    # Entry: name
    name: str,
    # Entry: description
    description: Optional[str] = None,
    # Entry: type
    type: str = "custom",
    # Entry: threat_actor_id
    threat_actor_id: Optional[str] = None,
    # Entry: target_platform
    target_platform: Optional[str] = None,
    # Entry: tags
    tags: Optional[list[str]] = None,
    # Entry: scheduled_at
    scheduled_at: Optional[str] = None,
) -> dict:
    """Create a new campaign. Does not commit; caller commits."""
    # Assign campaign = Campaign(
    campaign = Campaign(
        # Keyword argument: name
        name=name,
        # Keyword argument: description
        description=description,
        # Keyword argument: type
        type=type,
        # Keyword argument: threat_actor_id
        threat_actor_id=uuid.UUID(threat_actor_id) if threat_actor_id else None,
        # Keyword argument: target_platform
        target_platform=target_platform,
        # Keyword argument: tags
        tags=tags or [],
        # Keyword argument: created_by
        created_by=creator_id,
        # Keyword argument: scheduled_at
        scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None,
    )
    # Stage new record(s) for database insertion
    db.add(campaign)
    # Flush changes to DB without committing the transaction
    db.flush()
    # Return serialize_campaign(db, campaign)
    return serialize_campaign(db, campaign)


def submit_campaign_for_approval(
    db: Session,
    campaign_id: str,
    *,
    submitter_id: uuid.UUID,
    submitter_role: str,
) -> Campaign:
    """Submit a draft campaign into the manager's approval queue.

    Raises EntityNotFoundError, PermissionViolation, BusinessRuleViolation.
    Does not commit; caller commits.
    """
    campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    if not campaign:
        raise EntityNotFoundError("Campaign", campaign_id)

    if str(campaign.created_by) != str(submitter_id) and submitter_role != "admin":
        raise PermissionViolation("Only the creator or admin can submit this campaign for approval")

    if campaign.status != "draft":
        raise BusinessRuleViolation("Only draft campaigns can be submitted for approval")

    test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).count()
    if test_count == 0:
        raise BusinessRuleViolation("Campaign must have at least one test to submit for approval")

    campaign.status = "pending_approval"
    db.flush()
    return campaign


def approve_campaign(
    db: Session,
    campaign_id: str,
    *,
    approver_id: uuid.UUID,
    start_date: str,
) -> Campaign:
    """Approve a pending campaign: fix its start date and activate it.

    Raises EntityNotFoundError, BusinessRuleViolation.
    Does not commit; caller commits.
    """
    campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    if not campaign:
        raise EntityNotFoundError("Campaign", campaign_id)

    if campaign.status != "pending_approval":
        raise BusinessRuleViolation("Only campaigns pending approval can be approved")

    if not start_date:
        raise BusinessRuleViolation("start_date is required to approve a campaign")

    campaign.start_date = datetime.fromisoformat(start_date)
    campaign.status = "active"
    campaign.approved_by = approver_id
    campaign.approved_at = datetime.utcnow()
    campaign.rejection_reason = None
    db.flush()
    return campaign


def reject_campaign(
    db: Session,
    campaign_id: str,
    *,
    rejecter_id: uuid.UUID,
    reason: str,
) -> Campaign:
    """Reject a pending campaign, returning it to draft with a mandatory reason.

    Raises EntityNotFoundError, BusinessRuleViolation.
    Does not commit; caller commits.
    """
    campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    if not campaign:
        raise EntityNotFoundError("Campaign", campaign_id)

    if campaign.status != "pending_approval":
        raise BusinessRuleViolation("Only campaigns pending approval can be rejected")

    if not reason or not reason.strip():
        raise BusinessRuleViolation("A rejection reason is required")

    campaign.status = "draft"
    campaign.rejection_reason = reason.strip()
    campaign.approved_by = None
    campaign.approved_at = None
    db.flush()
    return campaign


# Define function get_campaign_detail
def get_campaign_detail(db: Session, campaign_id: str) -> dict:
    """Get detailed campaign info including tests and progress.

    Raises EntityNotFoundError if campaign not found.
    """
    # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    # Check: not campaign
    if not campaign:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Campaign", campaign_id)
    # Return serialize_campaign(db, campaign)
    return serialize_campaign(db, campaign)


# Define function update_campaign
def update_campaign(
    # Entry: db
    db: Session,
    # Entry: campaign_id
    campaign_id: str,
    *,
    # Entry: updater_id
    updater_id: uuid.UUID,
    # Entry: updater_role
    updater_role: str,
    **fields: object,
) -> dict:
    """Update a campaign. Only allowed in draft or active state.

    Raises EntityNotFoundError, BusinessRuleViolation, or PermissionViolation.
    Does not commit; caller commits.
    """
    # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    # Check: not campaign
    if not campaign:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Campaign", campaign_id)

    # Check: campaign.status not in ("draft", "active")
    if campaign.status not in ("draft", "active"):
        # Raise BusinessRuleViolation
        raise BusinessRuleViolation("Can only update draft or active campaigns")

    # Check: str(campaign.created_by) != str(updater_id) and updater_role != "ad...
    if str(campaign.created_by) != str(updater_id) and updater_role != "admin":
        # Raise PermissionViolation
        raise PermissionViolation("Only the creator or admin can update this campaign")

    # Check: "scheduled_at" in fields and fields["scheduled_at"]
    if "scheduled_at" in fields and fields["scheduled_at"]:
        # Assign fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])
        fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])

    # Iterate over fields.items()
    for field, value in fields.items():
        # Call setattr()
        setattr(campaign, field, value)

    # Flush changes to DB without committing the transaction
    db.flush()
    # Return serialize_campaign(db, campaign)
    return serialize_campaign(db, campaign)


# Define function add_test_to_campaign
def add_test_to_campaign(
    # Entry: db
    db: Session,
    # Entry: campaign_id
    campaign_id: str,
    *,
    # Entry: test_id
    test_id: str,
    # Entry: order_index
    order_index: Optional[int] = None,
    # Entry: depends_on
    depends_on: Optional[str] = None,
    # Entry: phase
    phase: Optional[str] = None,
    # Entry: allowed_statuses
    allowed_statuses: tuple[str, ...] = ("draft",),
) -> dict:
    """Add a test to a campaign with optional ordering and dependency.

    Raises EntityNotFoundError for missing campaign or test.
    Raises BusinessRuleViolation for invalid state or circular dependency.
    Does not commit; caller commits.
    """
    # Assign campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    # Check: not campaign
    if not campaign:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Campaign", campaign_id)

    # Check: campaign.status not in allowed_statuses
    if campaign.status not in allowed_statuses:
        # Raise BusinessRuleViolation
        raise BusinessRuleViolation(
            f"Can only add tests to a campaign in one of: {', '.join(allowed_statuses)}"
        )

    # Assign test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first()
    test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first()
    # Check: not test
    if not test:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Test", test_id)

    # Check: order_index is not None
    if order_index is not None:
        # Assign final_order_index = order_index
        final_order_index = order_index
    # Fallback: handle remaining cases
    else:
        # Assign max_order = (
        max_order = (
            db.query(CampaignTest.order_index)
            # Chain .filter() call
            .filter(CampaignTest.campaign_id == uuid.UUID(campaign_id))
            # Chain .order_by() call
            .order_by(CampaignTest.order_index.desc())
            # Chain .first() call
            .first()
        )
        # Assign final_order_index = (max_order[0] + 1) if max_order else 0
        final_order_index = (max_order[0] + 1) if max_order else 0

    # Assign depends_on_uuid = uuid.UUID(depends_on) if depends_on else None
    depends_on_uuid = uuid.UUID(depends_on) if depends_on else None

    # Assign ct_id = uuid.uuid4()
    ct_id = uuid.uuid4()
    # Check: depends_on_uuid
    if depends_on_uuid:
        # Call validate_no_circular_dependency()
        validate_no_circular_dependency(db, uuid.UUID(campaign_id), ct_id, depends_on_uuid)

    # Check: not phase and test.technique_id
    if not phase and test.technique_id:
        # Assign technique = db.query(Technique).filter(Technique.id == test.technique_id).first()
        technique = db.query(Technique).filter(Technique.id == test.technique_id).first()
        # Check: technique and technique.tactic
        if technique and technique.tactic:
            # Assign phase = TACTIC_TO_PHASE.get(technique.tactic, None)
            phase = TACTIC_TO_PHASE.get(technique.tactic, None)

    # Assign campaign_test = CampaignTest(
    campaign_test = CampaignTest(
        # Keyword argument: id
        id=ct_id,
        # Keyword argument: campaign_id
        campaign_id=uuid.UUID(campaign_id),
        # Keyword argument: test_id
        test_id=uuid.UUID(test_id),
        # Keyword argument: order_index
        order_index=final_order_index,
        # Keyword argument: depends_on
        depends_on=depends_on_uuid,
        # Keyword argument: phase
        phase=phase,
    )
    # Stage new record(s) for database insertion
    db.add(campaign_test)
    # Flush changes to DB without committing the transaction
    db.flush()

    # Return {
    return {
        # Literal argument value
        "id": str(campaign_test.id),
        # Literal argument value
        "campaign_id": str(campaign_test.campaign_id),
        # Literal argument value
        "test_id": str(campaign_test.test_id),
        # Literal argument value
        "order_index": campaign_test.order_index,
        # Literal argument value
        "depends_on": str(campaign_test.depends_on) if campaign_test.depends_on else None,
        # Literal argument value
        "phase": campaign_test.phase,
    }


# Define function remove_test_from_campaign
def remove_test_from_campaign(
    db: Session,
    campaign_id: str,
    campaign_test_id: str,
    *,
    # Entry: allowed_statuses
    allowed_statuses: tuple[str, ...] = ("draft",),
) -> None:
    """Remove a test from a campaign.

    Raises EntityNotFoundError for missing campaign or campaign test.
    Raises BusinessRuleViolation for invalid state.
    Does not commit; caller commits.
    """
    # Assign campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    # Check: not campaign
    if not campaign:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Campaign", campaign_id)

    # Check: campaign.status not in allowed_statuses
    if campaign.status not in allowed_statuses:
        # Raise BusinessRuleViolation
        raise BusinessRuleViolation(
            f"Can only modify tests on a campaign in one of: {', '.join(allowed_statuses)}"
        )

    # Assign ct = (
    ct = (
        db.query(CampaignTest)
        # Chain .filter() call
        .filter(
            CampaignTest.id == uuid.UUID(campaign_test_id),
            CampaignTest.campaign_id == uuid.UUID(campaign_id),
        )
        # Chain .first() call
        .first()
    )
    # Check: not ct
    if not ct:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("CampaignTest", campaign_test_id)

    # Assign dep_id = uuid.UUID(campaign_test_id)
    dep_id = uuid.UUID(campaign_test_id)
    # Assign dependents = db.query(CampaignTest).filter(CampaignTest.depends_on == dep_id).all()
    dependents = db.query(CampaignTest).filter(CampaignTest.depends_on == dep_id).all()
    # Iterate over dependents
    for dep in dependents:
        # Assign dep.depends_on = None
        dep.depends_on = None

    # Keep a reference to the underlying test before deleting the join record
    test_id = ct.test_id
    technique_id = None
    test_obj = db.query(Test).filter(Test.id == test_id).first()
    if test_obj:
        technique_id = test_obj.technique_id

    db.delete(ct)
    # Flush changes to DB without committing the transaction
    db.flush()

    # Also delete the actual test record (it was created for this campaign)
    if test_obj:
        db.delete(test_obj)
        db.flush()

    # Recalculate technique status_global so coverage metrics stay consistent
    if technique_id:
        technique = db.query(Technique).filter(Technique.id == technique_id).first()
        if technique:
            recalculate_technique_status(db, technique)
            db.flush()


# Define function activate_campaign
def activate_campaign(db: Session, campaign_id: str) -> Campaign:
    """Activate a campaign, moving it from draft to active.

    Raises EntityNotFoundError, BusinessRuleViolation.
    Does not commit; caller commits.
    """
    # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    # Check: not campaign
    if not campaign:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Campaign", campaign_id)

    # Check: campaign.status != "draft"
    if campaign.status != "draft":
        # Raise BusinessRuleViolation
        raise BusinessRuleViolation("Only draft campaigns can be activated")

    # Assign test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_...
    test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).count()
    # Check: test_count == 0
    if test_count == 0:
        # Raise BusinessRuleViolation
        raise BusinessRuleViolation("Campaign must have at least one test to activate")

    # Assign campaign.status = "active"
    campaign.status = "active"
    # Flush changes to DB without committing the transaction
    db.flush()
    # Return campaign
    return campaign


# Define function complete_campaign
def complete_campaign(db: Session, campaign_id: str) -> Campaign:
    """Mark a campaign as completed.

    Raises EntityNotFoundError, BusinessRuleViolation.
    Does not commit; caller commits.
    """
    # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    # Check: not campaign
    if not campaign:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Campaign", campaign_id)

    # Check: campaign.status != "active"
    if campaign.status != "active":
        # Raise BusinessRuleViolation
        raise BusinessRuleViolation("Only active campaigns can be completed")

    # Assign campaign.status = "completed"
    campaign.status = "completed"
    # Assign campaign.completed_at = datetime.utcnow()
    campaign.completed_at = datetime.utcnow()
    # Flush changes to DB without committing the transaction
    db.flush()
    # Return campaign
    return campaign


# Define function get_campaign_progress_data
def get_campaign_progress_data(db: Session, campaign_id: str) -> dict:
    """Get progress statistics for a campaign.

    Raises EntityNotFoundError if campaign not found.
    """
    # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    # Check: not campaign
    if not campaign:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Campaign", campaign_id)

    # Assign progress = get_campaign_progress(db, uuid.UUID(campaign_id))
    progress = get_campaign_progress(db, uuid.UUID(campaign_id))
    # Return {
    return {
        # Literal argument value
        "campaign_id": str(campaign.id),
        # Literal argument value
        "campaign_name": campaign.name,
        **progress,
    }


# Define function schedule_campaign
def schedule_campaign(
    # Entry: db
    db: Session,
    # Entry: campaign_id
    campaign_id: str,
    *,
    # Entry: owner_id
    owner_id: uuid.UUID,
    # Entry: owner_role
    owner_role: str,
    # Entry: is_recurring
    is_recurring: bool,
    # Entry: recurrence_pattern
    recurrence_pattern: Optional[str] = None,
    # Entry: next_run_at
    next_run_at: Optional[str] = None,
) -> Campaign:
    """Configure or update the recurrence schedule for a campaign.

    Raises EntityNotFoundError, PermissionViolation, BusinessRuleViolation.
    Does not commit; caller commits.
    """
    # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    # Check: not campaign
    if not campaign:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Campaign", campaign_id)

    # Check: str(campaign.created_by) != str(owner_id) and owner_role != "admin"
    if str(campaign.created_by) != str(owner_id) and owner_role != "admin":
        # Raise PermissionViolation
        raise PermissionViolation("Only the creator or admin can configure scheduling")

    # Assign campaign.is_recurring = is_recurring
    campaign.is_recurring = is_recurring

    # Check: is_recurring
    if is_recurring:
        # Check: recurrence_pattern not in ("weekly", "monthly", "quarterly")
        if recurrence_pattern not in ("weekly", "monthly", "quarterly"):
            # Raise BusinessRuleViolation
            raise BusinessRuleViolation(
                # Literal argument value
                "recurrence_pattern must be 'weekly', 'monthly', or 'quarterly'"
            )
        # Assign campaign.recurrence_pattern = recurrence_pattern
        campaign.recurrence_pattern = recurrence_pattern
        # Check: next_run_at
        if next_run_at:
            # Assign campaign.next_run_at = datetime.fromisoformat(
            campaign.next_run_at = datetime.fromisoformat(
                next_run_at.replace("Z", "+00:00").replace("+00:00", "")
            )
        # Alternative: not campaign.next_run_at
        elif not campaign.next_run_at:
            # Assign campaign.next_run_at = calculate_next_run(datetime.utcnow(), recurrence_pattern)
            campaign.next_run_at = calculate_next_run(datetime.utcnow(), recurrence_pattern)
    # Fallback: handle remaining cases
    else:
        # Assign campaign.recurrence_pattern = None
        campaign.recurrence_pattern = None
        # Assign campaign.next_run_at = None
        campaign.next_run_at = None

    # Flush changes to DB without committing the transaction
    db.flush()
    # Return campaign
    return campaign


def delete_campaign(
    db: Session,
    campaign_id: str,
    *,
    deleter_id: uuid.UUID,
    deleter_role: str,
    delete_tests: bool = False,
) -> None:
    """Delete a campaign.

    Only draft campaigns can be deleted unless the caller is admin.
    If delete_tests=True, the associated Test objects are also deleted.
    Does not commit; caller commits.
    """
    campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    if not campaign:
        raise EntityNotFoundError("Campaign", campaign_id)

    if campaign.status != "draft" and deleter_role != "admin":
        raise BusinessRuleViolation("Only draft campaigns can be deleted")

    if str(campaign.created_by) != str(deleter_id) and deleter_role != "admin":
        raise PermissionViolation("Only the creator or admin can delete this campaign")

    # Collect test IDs before removing associations
    campaign_tests = (
        db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_id).all()
    )
    test_ids = [ct.test_id for ct in campaign_tests]

    # Remove CampaignTest join rows (clear depends_on refs first to avoid FK cycles)
    for ct in campaign_tests:
        ct.depends_on = None
    db.flush()
    for ct in campaign_tests:
        db.delete(ct)
    db.flush()

    # Optionally delete the associated tests
    if delete_tests:
        affected_technique_ids: set = set()
        for test_id in test_ids:
            test = db.query(Test).filter(Test.id == test_id).first()
            if test:
                if test.technique_id:
                    affected_technique_ids.add(test.technique_id)
                db.delete(test)
        db.flush()
        # Recalculate status_global for every affected technique so the
        # coverage metrics stay consistent after test deletion.
        for tech_id in affected_technique_ids:
            technique = db.query(Technique).filter(Technique.id == tech_id).first()
            if technique:
                recalculate_technique_status(db, technique)
        db.flush()

    # Null-out parent_campaign_id on child campaigns to avoid FK violation
    db.query(Campaign).filter(Campaign.parent_campaign_id == campaign.id).update(
        {"parent_campaign_id": None}
    )
    db.flush()

    db.delete(campaign)
    db.flush()


def get_campaign_history(db: Session, campaign_id: str) -> dict:
    """List all child campaigns (execution history) of a recurring campaign.

    Raises EntityNotFoundError if campaign not found.
    """
    # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
    # Check: not campaign
    if not campaign:
        # Raise EntityNotFoundError
        raise EntityNotFoundError("Campaign", campaign_id)

    # Assign campaign_uuid = uuid.UUID(campaign_id)
    campaign_uuid = uuid.UUID(campaign_id)
    # Assign children = (
    children = (
        db.query(Campaign)
        # Chain .filter() call
        .filter(Campaign.parent_campaign_id == campaign_uuid)
        # Chain .order_by() call
        .order_by(Campaign.created_at.desc())
        # Chain .all() call
        .all()
    )

    # Return {
    return {
        # Literal argument value
        "campaign_id": str(campaign.id),
        # Literal argument value
        "campaign_name": campaign.name,
        # Literal argument value
        "items": [
            {
                # Literal argument value
                "id": str(child.id),
                # Literal argument value
                "name": child.name,
                # Literal argument value
                "status": child.status,
                # Literal argument value
                "test_count": db.query(CampaignTest).filter(CampaignTest.campaign_id == child.id).count(),
                # Literal argument value
                "completion_pct": get_campaign_progress(db, child.id)["completion_pct"],
                # Literal argument value
                "created_at": child.created_at.isoformat() if child.created_at else None,
                # Literal argument value
                "completed_at": child.completed_at.isoformat() if child.completed_at else None,
            }
            for child in children
        ],
    }


# ── Campaign Modification Requests ──────────────────────────────────────


def serialize_modification_request(db: Session, request: CampaignModificationRequest) -> dict:
    """Serialize a modification request, including the test's display name."""
    test = request.test
    return {
        "id": str(request.id),
        "campaign_id": str(request.campaign_id),
        "campaign_name": request.campaign.name if request.campaign else None,
        "requested_by": str(request.requested_by) if request.requested_by else None,
        "action": request.action,
        "test_id": str(request.test_id) if request.test_id else None,
        "test_name": test.name if test else None,
        "order_index": request.order_index,
        "phase": request.phase,
        "justification": request.justification,
        "status": request.status,
        "reviewed_by": str(request.reviewed_by) if request.reviewed_by else None,
        "reviewed_at": request.reviewed_at.isoformat() if request.reviewed_at else None,
        "review_notes": request.review_notes,
        "created_at": request.created_at.isoformat() if request.created_at else None,
    }


def create_modification_request(
    db: Session,
    campaign_id: str,
    *,
    requester_id: uuid.UUID,
    action: str,
    test_id: str,
    justification: str,
    order_index: Optional[int] = None,
    phase: Optional[str] = None,
) -> CampaignModificationRequest:
    """File a request to add/remove a test on an active campaign.

    Raises EntityNotFoundError, BusinessRuleViolation.
    Does not commit; caller commits.
    """
    campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    if not campaign:
        raise EntityNotFoundError("Campaign", campaign_id)

    if campaign.status != "active":
        raise BusinessRuleViolation("Modification requests can only be created for active campaigns")

    if action not in ("add_test", "remove_test"):
        raise BusinessRuleViolation("action must be 'add_test' or 'remove_test'")

    if not justification or not justification.strip():
        raise BusinessRuleViolation("A justification is required")

    test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first()
    if not test:
        raise EntityNotFoundError("Test", test_id)

    if action == "remove_test":
        ct = (
            db.query(CampaignTest)
            .filter(CampaignTest.campaign_id == campaign.id, CampaignTest.test_id == test.id)
            .first()
        )
        if not ct:
            raise EntityNotFoundError("CampaignTest", test_id)

    request = CampaignModificationRequest(
        campaign_id=campaign.id,
        requested_by=requester_id,
        action=action,
        test_id=test.id,
        order_index=order_index,
        phase=phase,
        justification=justification.strip(),
        status="pending",
    )
    db.add(request)
    db.flush()
    return request


def approve_modification_request(
    db: Session,
    request_id: str,
    *,
    reviewer_id: uuid.UUID,
) -> CampaignModificationRequest:
    """Approve a pending modification request and apply the underlying test change.

    Raises EntityNotFoundError, BusinessRuleViolation.
    Does not commit; caller commits.
    """
    request = (
        db.query(CampaignModificationRequest)
        .filter(CampaignModificationRequest.id == uuid.UUID(request_id))
        .first()
    )
    if not request:
        raise EntityNotFoundError("CampaignModificationRequest", request_id)

    if request.status != "pending":
        raise BusinessRuleViolation("Only pending modification requests can be approved")

    # A "remove_test" approval deletes the underlying Test row below, which
    # nulls this row's test_id (test_id uses ON DELETE SET NULL specifically
    # so this audit record survives that delete instead of being cascaded
    # away with it — see CampaignModificationRequest.test_id).
    request.status = "approved"
    request.reviewed_by = reviewer_id
    request.reviewed_at = datetime.utcnow()
    db.flush()

    if request.action == "add_test":
        add_test_to_campaign(
            db, str(request.campaign_id),
            test_id=str(request.test_id),
            order_index=request.order_index,
            phase=request.phase,
            allowed_statuses=("active",),
        )
    else:
        ct = (
            db.query(CampaignTest)
            .filter(
                CampaignTest.campaign_id == request.campaign_id,
                CampaignTest.test_id == request.test_id,
            )
            .first()
        )
        if not ct:
            raise EntityNotFoundError("CampaignTest", str(request.test_id))
        remove_test_from_campaign(
            db, str(request.campaign_id), str(ct.id), allowed_statuses=("active",),
        )

    db.flush()
    return request


def reject_modification_request(
    db: Session,
    request_id: str,
    *,
    reviewer_id: uuid.UUID,
    review_notes: str,
) -> CampaignModificationRequest:
    """Reject a pending modification request. No change is applied to the campaign.

    Raises EntityNotFoundError, BusinessRuleViolation.
    Does not commit; caller commits.
    """
    request = (
        db.query(CampaignModificationRequest)
        .filter(CampaignModificationRequest.id == uuid.UUID(request_id))
        .first()
    )
    if not request:
        raise EntityNotFoundError("CampaignModificationRequest", request_id)

    if request.status != "pending":
        raise BusinessRuleViolation("Only pending modification requests can be rejected")

    if not review_notes or not review_notes.strip():
        raise BusinessRuleViolation("Review notes are required to reject a modification request")

    request.status = "rejected"
    request.reviewed_by = reviewer_id
    request.reviewed_at = datetime.utcnow()
    request.review_notes = review_notes.strip()
    db.flush()
    return request


def list_modification_requests(
    db: Session,
    *,
    campaign_id: Optional[str] = None,
    status: Optional[str] = None,
) -> list[dict]:
    """List modification requests, optionally filtered by campaign and/or status."""
    query = db.query(CampaignModificationRequest)
    if campaign_id:
        query = query.filter(CampaignModificationRequest.campaign_id == uuid.UUID(campaign_id))
    if status:
        query = query.filter(CampaignModificationRequest.status == status)
    requests = query.order_by(CampaignModificationRequest.created_at.desc()).all()
    return [serialize_modification_request(db, r) for r in requests]


def get_campaign_timeline(db: Session, campaign_id: str) -> list[dict]:
    """Return the chronological audit-log history for a campaign.

    Raises EntityNotFoundError if the campaign does not exist.
    """
    campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
    if not campaign:
        raise EntityNotFoundError("Campaign", campaign_id)

    logs = (
        db.query(AuditLog)
        .filter(AuditLog.entity_type == "campaign", AuditLog.entity_id == str(campaign_id))
        .order_by(AuditLog.timestamp.asc())
        .all()
    )

    return [
        {
            "id": str(log.id),
            "action": log.action,
            "user_id": str(log.user_id) if log.user_id else None,
            "timestamp": log.timestamp.isoformat() if log.timestamp else None,
            "details": log.details or {},
        }
        for log in logs
    ]
