# app/modules/loyalty/services/stamp_service.py
"""
Stamp service.

Merchant-based stamp operations:
- Stamps earned at any store count toward merchant total
- Stamps can be redeemed at any store within the merchant
- Supports voiding stamps for returns

Handles stamp operations including:
- Adding stamps with anti-fraud checks
- Redeeming stamps for rewards
- Voiding stamps (for returns)
- Daily limit tracking
"""

import logging
from datetime import UTC, datetime, timedelta

from sqlalchemy.orm import Session

from app.modules.loyalty.exceptions import (
    DailyStampLimitException,
    InsufficientStampsException,
    LoyaltyCardInactiveException,
    LoyaltyProgramInactiveException,
    StaffPinRequiredException,
    StampCooldownException,
)
from app.modules.loyalty.models import LoyaltyTransaction, TransactionType
from app.modules.loyalty.services.card_service import card_service
from app.modules.loyalty.services.pin_service import pin_service

logger = logging.getLogger(__name__)


class StampService:
    """Service for stamp operations."""

    def add_stamp(
        self,
        db: Session,
        *,
        store_id: int,
        card_id: int | None = None,
        qr_code: str | None = None,
        card_number: str | None = None,
        staff_pin: str | None = None,
        category_ids: list[int] | None = None,
        ip_address: str | None = None,
        user_agent: str | None = None,
        notes: str | None = None,
        acting_terminal_device_id: int | None = None,
    ) -> dict:
        """
        Add a stamp to a loyalty card.

        Performs all anti-fraud checks:
        - Staff PIN verification (if required)
        - Cooldown period check
        - Daily limit check

        Args:
            db: Database session
            store_id: Store ID (where stamp is being added)
            card_id: Card ID
            qr_code: QR code data
            card_number: Card number
            staff_pin: Staff PIN for verification
            ip_address: Request IP for audit
            user_agent: Request user agent for audit
            notes: Optional notes

        Returns:
            Dict with operation result

        Raises:
            LoyaltyCardNotFoundException: Card not found
            LoyaltyCardInactiveException: Card is inactive
            LoyaltyProgramInactiveException: Program is inactive
            StaffPinRequiredException: PIN required but not provided
            InvalidStaffPinException: PIN is invalid
            StampCooldownException: Cooldown period not elapsed
            DailyStampLimitException: Daily limit reached
        """
        # Look up the card (validates it belongs to store's merchant)
        card = card_service.lookup_card_for_store(
            db,
            store_id,
            card_id=card_id,
            qr_code=qr_code,
            card_number=card_number,
        )

        # Validate card and program
        if not card.is_active:
            raise LoyaltyCardInactiveException(card.id)

        program = card.program
        if not program.is_active:
            raise LoyaltyProgramInactiveException(program.id)

        # Check if stamps are enabled
        if not program.is_stamps_enabled:
            logger.warning(f"Stamp attempted on points-only program {program.id}")
            raise LoyaltyCardInactiveException(card.id)

        # Verify staff PIN if required.
        # Paired terminal devices ARE the principal — the cashier already
        # bcrypt-verified locally against the device's cached hash list.
        # Web-terminal user JWTs still require the per-action PIN.
        verified_pin = None
        if program.require_staff_pin and acting_terminal_device_id is None:
            if not staff_pin:
                raise StaffPinRequiredException()
            verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id)

        # Re-fetch with row lock to prevent concurrent modification
        card = card_service.get_card_for_update(db, card.id)

        # Check cooldown AFTER acquiring lock to prevent TOCTOU race
        now = datetime.now(UTC)
        if card.last_stamp_at:
            cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes)
            if now < cooldown_ends:
                raise StampCooldownException(
                    cooldown_ends.isoformat(),
                    program.cooldown_minutes,
                )

        # Check daily limit AFTER acquiring lock
        stamps_today = card_service.get_stamps_today(db, card.id)
        if stamps_today >= program.max_daily_stamps:
            raise DailyStampLimitException(program.max_daily_stamps, stamps_today)

        # Add the stamp
        card.stamp_count += 1
        card.total_stamps_earned += 1
        card.last_stamp_at = now
        card.last_activity_at = now

        # Check if reward earned
        reward_earned = card.stamp_count >= program.stamps_target

        # Create transaction
        transaction = LoyaltyTransaction(
            merchant_id=card.merchant_id,
            card_id=card.id,
            store_id=store_id,
            staff_pin_id=verified_pin.id if verified_pin else None,
            acting_terminal_device_id=acting_terminal_device_id,
            category_ids=category_ids,
            transaction_type=TransactionType.STAMP_EARNED.value,
            stamps_delta=1,
            stamps_balance_after=card.stamp_count,
            points_balance_after=card.points_balance,
            ip_address=ip_address,
            user_agent=user_agent,
            notes=notes,
            transaction_at=now,
        )
        db.add(transaction)

        db.commit()
        db.refresh(card)

        # Sync wallet passes with updated stamp count
        from app.modules.loyalty.services.wallet_service import wallet_service

        wallet_service.sync_card_to_wallets(db, card)

        # Notify customer when they've earned a reward
        if reward_earned:
            try:
                from app.modules.loyalty.services.notification_service import (
                    notification_service,
                )

                notification_service.send_reward_available(
                    db, card, program.stamps_reward_description or "Reward"
                )
            except Exception:
                logger.warning(
                    f"Failed to queue reward notification for card {card.id}",
                    exc_info=True,
                )

        stamps_today += 1

        logger.info(
            f"Added stamp to card {card.id} at store {store_id} "
            f"(stamps: {card.stamp_count}/{program.stamps_target}, "
            f"today: {stamps_today}/{program.max_daily_stamps})"
        )

        # Calculate next stamp availability
        next_stamp_at = now + timedelta(minutes=program.cooldown_minutes)

        return {
            "success": True,
            "message": "Stamp added successfully",
            "card_id": card.id,
            "card_number": card.card_number,
            "stamp_count": card.stamp_count,
            "stamps_target": program.stamps_target,
            "stamps_until_reward": max(0, program.stamps_target - card.stamp_count),
            "reward_earned": reward_earned,
            "reward_description": program.stamps_reward_description if reward_earned else None,
            "next_stamp_available_at": next_stamp_at,
            "stamps_today": stamps_today,
            "stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today),
            "store_id": store_id,
        }

    def redeem_stamps(
        self,
        db: Session,
        *,
        store_id: int,
        card_id: int | None = None,
        qr_code: str | None = None,
        card_number: str | None = None,
        staff_pin: str | None = None,
        ip_address: str | None = None,
        user_agent: str | None = None,
        notes: str | None = None,
        acting_terminal_device_id: int | None = None,
    ) -> dict:
        """
        Redeem stamps for a reward.

        Args:
            db: Database session
            store_id: Store ID (where redemption is happening)
            card_id: Card ID
            qr_code: QR code data
            card_number: Card number
            staff_pin: Staff PIN for verification
            ip_address: Request IP for audit
            user_agent: Request user agent for audit
            notes: Optional notes

        Returns:
            Dict with operation result

        Raises:
            LoyaltyCardNotFoundException: Card not found
            InsufficientStampsException: Not enough stamps
            StaffPinRequiredException: PIN required but not provided
        """
        # Look up the card (validates it belongs to store's merchant)
        card = card_service.lookup_card_for_store(
            db,
            store_id,
            card_id=card_id,
            qr_code=qr_code,
            card_number=card_number,
        )

        # Validate card and program
        if not card.is_active:
            raise LoyaltyCardInactiveException(card.id)

        program = card.program
        if not program.is_active:
            raise LoyaltyProgramInactiveException(program.id)

        # Paired-device principal bypasses the per-action PIN; see add_stamp.
        verified_pin = None
        if program.require_staff_pin and acting_terminal_device_id is None:
            if not staff_pin:
                raise StaffPinRequiredException()
            verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id)

        # Re-fetch with row lock to prevent concurrent modification
        card = card_service.get_card_for_update(db, card.id)

        # Check stamp count AFTER acquiring lock to prevent TOCTOU race
        if card.stamp_count < program.stamps_target:
            raise InsufficientStampsException(card.stamp_count, program.stamps_target)

        # Redeem stamps
        now = datetime.now(UTC)
        stamps_redeemed = program.stamps_target
        card.stamp_count -= stamps_redeemed
        card.stamps_redeemed += 1
        card.last_redemption_at = now
        card.last_activity_at = now

        # Create transaction
        transaction = LoyaltyTransaction(
            merchant_id=card.merchant_id,
            card_id=card.id,
            store_id=store_id,
            staff_pin_id=verified_pin.id if verified_pin else None,
            acting_terminal_device_id=acting_terminal_device_id,
            transaction_type=TransactionType.STAMP_REDEEMED.value,
            stamps_delta=-stamps_redeemed,
            stamps_balance_after=card.stamp_count,
            points_balance_after=card.points_balance,
            reward_description=program.stamps_reward_description,
            ip_address=ip_address,
            user_agent=user_agent,
            notes=notes,
            transaction_at=now,
        )
        db.add(transaction)

        db.commit()
        db.refresh(card)

        # Sync wallet passes with updated stamp count
        from app.modules.loyalty.services.wallet_service import wallet_service

        wallet_service.sync_card_to_wallets(db, card)

        logger.info(
            f"Redeemed stamps from card {card.id} at store {store_id} "
            f"(reward: {program.stamps_reward_description}, "
            f"total redemptions: {card.stamps_redeemed})"
        )

        return {
            "success": True,
            "message": "Reward redeemed successfully",
            "card_id": card.id,
            "card_number": card.card_number,
            "stamp_count": card.stamp_count,
            "stamps_target": program.stamps_target,
            "reward_description": program.stamps_reward_description,
            "total_redemptions": card.stamps_redeemed,
            "store_id": store_id,
        }

    def void_stamps(
        self,
        db: Session,
        *,
        store_id: int,
        card_id: int | None = None,
        qr_code: str | None = None,
        card_number: str | None = None,
        stamps_to_void: int | None = None,
        original_transaction_id: int | None = None,
        staff_pin: str | None = None,
        ip_address: str | None = None,
        user_agent: str | None = None,
        notes: str | None = None,
        acting_terminal_device_id: int | None = None,
    ) -> dict:
        """
        Void stamps for a return.

        Args:
            db: Database session
            store_id: Store ID
            card_id: Card ID
            qr_code: QR code data
            card_number: Card number
            stamps_to_void: Number of stamps to void (if not using original_transaction_id)
            original_transaction_id: ID of original stamp transaction to void
            staff_pin: Staff PIN for verification
            ip_address: Request IP for audit
            user_agent: Request user agent for audit
            notes: Reason for voiding

        Returns:
            Dict with operation result
        """
        # Look up the card
        card = card_service.lookup_card_for_store(
            db,
            store_id,
            card_id=card_id,
            qr_code=qr_code,
            card_number=card_number,
        )

        program = card.program

        # Check if void transactions are allowed
        from app.modules.loyalty.services.program_service import program_service
        settings = program_service.get_merchant_settings(db, card.merchant_id)
        if settings and not settings.allow_void_transactions:
            raise LoyaltyCardInactiveException(card.id)

        # Paired-device principal bypasses the per-action PIN; see add_stamp.
        verified_pin = None
        if program.require_staff_pin and acting_terminal_device_id is None:
            if not staff_pin:
                raise StaffPinRequiredException()
            verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id)

        # Determine stamps to void
        original_transaction = None
        if original_transaction_id:
            original_transaction = (
                db.query(LoyaltyTransaction)
                .filter(
                    LoyaltyTransaction.id == original_transaction_id,
                    LoyaltyTransaction.card_id == card.id,
                    LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value,
                )
                .first()
            )
            if original_transaction:
                stamps_to_void = original_transaction.stamps_delta

        if not stamps_to_void or stamps_to_void <= 0:
            return {
                "success": False,
                "message": "No stamps to void",
                "card_id": card.id,
                "card_number": card.card_number,
                "stamp_count": card.stamp_count,
            }

        # Re-fetch with row lock to prevent concurrent modification
        card = card_service.get_card_for_update(db, card.id)

        # Void the stamps (can reduce balance below what was earned)
        now = datetime.now(UTC)
        actual_voided = min(stamps_to_void, card.stamp_count)
        card.stamp_count = max(0, card.stamp_count - stamps_to_void)
        card.last_activity_at = now

        # Create void transaction
        transaction = LoyaltyTransaction(
            merchant_id=card.merchant_id,
            card_id=card.id,
            store_id=store_id,
            staff_pin_id=verified_pin.id if verified_pin else None,
            acting_terminal_device_id=acting_terminal_device_id,
            transaction_type=TransactionType.STAMP_VOIDED.value,
            stamps_delta=-actual_voided,
            stamps_balance_after=card.stamp_count,
            points_balance_after=card.points_balance,
            related_transaction_id=original_transaction.id if original_transaction else None,
            ip_address=ip_address,
            user_agent=user_agent,
            notes=notes or "Stamps voided for return",
            transaction_at=now,
        )
        db.add(transaction)

        db.commit()
        db.refresh(card)

        # Sync wallet passes with updated stamp count
        from app.modules.loyalty.services.wallet_service import wallet_service

        wallet_service.sync_card_to_wallets(db, card)

        logger.info(
            f"Voided {actual_voided} stamps from card {card.id} at store {store_id} "
            f"(balance: {card.stamp_count})"
        )

        return {
            "success": True,
            "message": "Stamps voided successfully",
            "stamps_voided": actual_voided,
            "card_id": card.id,
            "card_number": card.card_number,
            "stamp_count": card.stamp_count,
            "store_id": store_id,
        }


# Singleton instance
stamp_service = StampService()
