# app/modules/loyalty/tasks/point_expiration.py
"""
Point expiration task.

Handles expiring points that are older than the configured
expiration period based on card inactivity.

Runs daily at 02:00 via the scheduled task configuration in definition.py.

Processing is chunked (LIMIT 500 + FOR UPDATE SKIP LOCKED) to avoid
holding long-running row locks on the loyalty_cards table.
"""

import logging
from datetime import UTC, datetime, timedelta

from celery import shared_task
from sqlalchemy import or_
from sqlalchemy.orm import Session

from app.core.database import SessionLocal
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
from app.modules.loyalty.models.loyalty_transaction import TransactionType

logger = logging.getLogger(__name__)

CHUNK_SIZE = 500


@shared_task(name="loyalty.expire_points")
def expire_points() -> dict:
    """
    Expire points that are past their expiration date based on card inactivity.

    For each program with points_expiration_days configured:
    1. Send 14-day warning emails to cards approaching expiry
    2. Expire points in chunks of 500, committing after each chunk
    3. Send expired notifications

    Returns:
        Summary of expired points
    """
    logger.info("Starting point expiration task...")

    db: Session = SessionLocal()
    try:
        result = _process_point_expiration(db)
        logger.info(
            f"Point expiration complete: {result['cards_processed']} cards, "
            f"{result['points_expired']} points expired, "
            f"{result['warnings_sent']} warnings sent"
        )
        return result
    except Exception as e:
        db.rollback()
        logger.error(f"Point expiration task failed: {e}", exc_info=True)
        return {
            "status": "error",
            "error": str(e),
            "cards_processed": 0,
            "points_expired": 0,
            "warnings_sent": 0,
        }
    finally:
        db.close()


def _process_point_expiration(db: Session) -> dict:
    """Process point expiration for all programs."""
    total_cards = 0
    total_points = 0
    total_warnings = 0
    programs_processed = 0

    programs = (
        db.query(LoyaltyProgram)
        .filter(
            LoyaltyProgram.is_active == True,  # noqa: E712
            LoyaltyProgram.points_expiration_days.isnot(None),
            LoyaltyProgram.points_expiration_days > 0,
        )
        .all()
    )

    logger.info(f"Found {len(programs)} programs with point expiration configured")

    for program in programs:
        cards, points, warnings = _process_program(db, program)
        total_cards += cards
        total_points += points
        total_warnings += warnings
        programs_processed += 1

    return {
        "status": "success",
        "programs_processed": programs_processed,
        "cards_processed": total_cards,
        "points_expired": total_points,
        "warnings_sent": total_warnings,
    }


def _process_program(
    db: Session, program: LoyaltyProgram
) -> tuple[int, int, int]:
    """Process warnings + expiration for a single program.

    Returns (cards_expired, points_expired, warnings_sent).
    """
    if not program.points_expiration_days:
        return 0, 0, 0

    now = datetime.now(UTC)
    expiration_threshold = now - timedelta(days=program.points_expiration_days)

    # --- Phase 1: Send 14-day warning emails (chunked) ---
    warning_days = 14
    warning_threshold = now - timedelta(
        days=program.points_expiration_days - warning_days
    )
    warnings_sent = _send_expiration_warnings_chunked(
        db, program, warning_threshold, expiration_threshold, warning_days, now
    )

    # --- Phase 2: Expire points (chunked) ---
    cards_expired, points_expired = _expire_points_chunked(
        db, program, expiration_threshold, now
    )

    return cards_expired, points_expired, warnings_sent


# =========================================================================
# Chunked expiration
# =========================================================================


def _expire_points_chunked(
    db: Session,
    program: LoyaltyProgram,
    expiration_threshold: datetime,
    now: datetime,
) -> tuple[int, int]:
    """Expire points in chunks to avoid long-held row locks.

    Each chunk:
    1. SELECT ... LIMIT 500 FOR UPDATE SKIP LOCKED
    2. Create POINTS_EXPIRED transactions
    3. Update card balances
    4. Commit (releases locks for this chunk)

    Returns (total_cards, total_points).
    """
    total_cards = 0
    total_points = 0

    while True:
        # Fetch next chunk with row-level locks; SKIP LOCKED means
        # concurrent workers won't block on the same rows.
        card_ids_and_balances = (
            db.query(LoyaltyCard.id, LoyaltyCard.points_balance, LoyaltyCard.stamp_count)
            .filter(
                LoyaltyCard.merchant_id == program.merchant_id,
                LoyaltyCard.points_balance > 0,
                LoyaltyCard.last_activity_at < expiration_threshold,
                LoyaltyCard.is_active == True,  # noqa: E712
            )
            .limit(CHUNK_SIZE)
            .with_for_update(skip_locked=True)
            .all()
        )

        if not card_ids_and_balances:
            break

        chunk_cards = 0
        chunk_points = 0

        for card_id, balance, stamp_count in card_ids_and_balances:
            if balance <= 0:
                continue

            # Create expiration transaction
            db.add(
                LoyaltyTransaction(
                    card_id=card_id,
                    merchant_id=program.merchant_id,
                    store_id=None,
                    transaction_type=TransactionType.POINTS_EXPIRED.value,
                    points_delta=-balance,
                    points_balance_after=0,
                    stamps_delta=0,
                    stamps_balance_after=stamp_count,
                    notes=(
                        f"Points expired after {program.points_expiration_days} "
                        f"days of inactivity"
                    ),
                    transaction_at=now,
                )
            )

            # Bulk-update the card in the same transaction
            db.query(LoyaltyCard).filter(LoyaltyCard.id == card_id).update(
                {
                    LoyaltyCard.points_balance: 0,
                    LoyaltyCard.total_points_voided: (
                        LoyaltyCard.total_points_voided + balance
                    ),
                },
                synchronize_session=False,
            )

            chunk_cards += 1
            chunk_points += balance

        # Commit this chunk — releases row locks
        db.commit()

        # Send notifications AFTER commit (outside the lock window)
        for card_id, balance, _stamp_count in card_ids_and_balances:
            if balance <= 0:
                continue
            try:
                card = db.query(LoyaltyCard).get(card_id)
                if card:
                    from app.modules.loyalty.services.notification_service import (
                        notification_service,
                    )

                    notification_service.send_points_expired(db, card, balance)
            except Exception:
                logger.warning(
                    f"Failed to queue expiration notification for card {card_id}",
                    exc_info=True,
                )

        total_cards += chunk_cards
        total_points += chunk_points

        logger.info(
            f"Program {program.id}: expired chunk of {chunk_cards} cards "
            f"({chunk_points} pts), total so far: {total_cards} cards"
        )

    return total_cards, total_points


# =========================================================================
# Chunked expiration warnings
# =========================================================================


def _send_expiration_warnings_chunked(
    db: Session,
    program: LoyaltyProgram,
    warning_threshold: datetime,
    expiration_threshold: datetime,
    warning_days: int,
    now: datetime,
) -> int:
    """Send expiration warning emails in chunks.

    Only sends one warning per expiration cycle (tracked via
    last_expiration_warning_at on the card).
    """
    total_warnings = 0
    expiration_date = (now + timedelta(days=warning_days)).strftime("%Y-%m-%d")

    while True:
        cards = (
            db.query(LoyaltyCard)
            .filter(
                LoyaltyCard.merchant_id == program.merchant_id,
                LoyaltyCard.points_balance > 0,
                LoyaltyCard.is_active == True,  # noqa: E712
                LoyaltyCard.last_activity_at < warning_threshold,
                LoyaltyCard.last_activity_at >= expiration_threshold,
                or_(
                    LoyaltyCard.last_expiration_warning_at.is_(None),
                    LoyaltyCard.last_expiration_warning_at < warning_threshold,
                ),
            )
            .limit(CHUNK_SIZE)
            .all()
        )

        if not cards:
            break

        chunk_warnings = 0
        for card in cards:
            try:
                from app.modules.loyalty.services.notification_service import (
                    notification_service,
                )

                notification_service.send_points_expiration_warning(
                    db,
                    card,
                    expiring_points=card.points_balance,
                    days_remaining=warning_days,
                    expiration_date=expiration_date,
                )
                card.last_expiration_warning_at = now
                chunk_warnings += 1
            except Exception:
                logger.warning(
                    f"Failed to queue expiration warning for card {card.id}",
                    exc_info=True,
                )

        db.commit()
        total_warnings += chunk_warnings

    if total_warnings:
        logger.info(
            f"Sent {total_warnings} expiration warnings for program {program.id}"
        )
    return total_warnings


# Allow running directly for testing
if __name__ == "__main__":
    import sys

    logging.basicConfig(level=logging.DEBUG)
    result = expire_points()
    print(f"Result: {result}")
    sys.exit(0 if result["status"] == "success" else 1)
