"""Unit tests for ProgramService."""

import uuid

import pytest

from app.modules.loyalty.exceptions import (
    LoyaltyProgramAlreadyExistsException,
    LoyaltyProgramNotFoundException,
)
from app.modules.loyalty.models import LoyaltyProgram
from app.modules.loyalty.models.loyalty_program import LoyaltyType
from app.modules.loyalty.schemas.program import ProgramCreate, ProgramUpdate
from app.modules.loyalty.services.program_service import ProgramService
from app.modules.tenancy.models import Merchant, User


@pytest.mark.unit
@pytest.mark.loyalty
class TestProgramService:
    """Test suite for ProgramService."""

    def setup_method(self):
        self.service = ProgramService()

    def test_service_instantiation(self):
        """Service can be instantiated."""
        assert self.service is not None


# ============================================================================
# Fixtures
# ============================================================================


@pytest.fixture
def ps_merchant(db):
    """Create a merchant for program service tests."""
    from middleware.auth import AuthManager

    auth = AuthManager()
    uid = uuid.uuid4().hex[:8]

    owner = User(
        email=f"psowner_{uid}@test.com",
        username=f"psowner_{uid}",
        hashed_password=auth.hash_password("testpass123"),
        role="merchant_owner",
        is_active=True,
        is_email_verified=True,
    )
    db.add(owner)
    db.commit()
    db.refresh(owner)

    merchant = Merchant(
        name=f"PS Test Merchant {uid}",
        owner_user_id=owner.id,
        contact_email=owner.email,
        is_active=True,
        is_verified=True,
    )
    db.add(merchant)
    db.commit()
    db.refresh(merchant)

    return merchant


@pytest.fixture
def ps_program(db, ps_merchant):
    """Create a program for program service tests."""
    program = LoyaltyProgram(
        merchant_id=ps_merchant.id,
        loyalty_type=LoyaltyType.POINTS.value,
        points_per_euro=10,
        welcome_bonus_points=50,
        minimum_redemption_points=100,
        minimum_purchase_cents=0,
        cooldown_minutes=0,
        max_daily_stamps=10,
        require_staff_pin=False,
        card_name="PS Test Rewards",
        card_color="#4F46E5",
        is_active=True,
        points_rewards=[
            {"id": "reward_1", "name": "5 EUR off", "points_required": 100, "is_active": True},
        ],
    )
    db.add(program)
    db.commit()
    db.refresh(program)
    return program


# ============================================================================
# Read Operations
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestGetProgram:
    """Tests for get_program and get_program_by_merchant."""

    def setup_method(self):
        self.service = ProgramService()

    def test_get_program_by_id(self, db, ps_program):
        """Get a program by its ID."""
        result = self.service.get_program(db, ps_program.id)
        assert result is not None
        assert result.id == ps_program.id

    def test_get_program_not_found(self, db):
        """Returns None for non-existent program."""
        result = self.service.get_program(db, 999999)
        assert result is None

    def test_get_program_by_merchant(self, db, ps_program, ps_merchant):
        """Get a program by merchant ID."""
        result = self.service.get_program_by_merchant(db, ps_merchant.id)
        assert result is not None
        assert result.merchant_id == ps_merchant.id

    def test_get_program_by_merchant_not_found(self, db):
        """Returns None when merchant has no program."""
        result = self.service.get_program_by_merchant(db, 999999)
        assert result is None


@pytest.mark.unit
@pytest.mark.loyalty
class TestRequireProgram:
    """Tests for require_program and require_program_by_merchant."""

    def setup_method(self):
        self.service = ProgramService()

    def test_require_program_found(self, db, ps_program):
        """Returns program when it exists."""
        result = self.service.require_program(db, ps_program.id)
        assert result.id == ps_program.id

    def test_require_program_raises_not_found(self, db):
        """Raises exception when program doesn't exist."""
        with pytest.raises(LoyaltyProgramNotFoundException):
            self.service.require_program(db, 999999)

    def test_require_program_by_merchant_found(self, db, ps_program, ps_merchant):
        """Returns program for merchant."""
        result = self.service.require_program_by_merchant(db, ps_merchant.id)
        assert result.merchant_id == ps_merchant.id

    def test_require_program_by_merchant_raises_not_found(self, db):
        """Raises exception when merchant has no program."""
        with pytest.raises(LoyaltyProgramNotFoundException):
            self.service.require_program_by_merchant(db, 999999)


# ============================================================================
# Create Operations
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestCreateProgram:
    """Tests for create_program."""

    def setup_method(self):
        self.service = ProgramService()

    def test_create_program_points(self, db, ps_merchant):
        """Create a points-based program."""
        data = ProgramCreate(
            loyalty_type="points",
            points_per_euro=5,
            card_name="Unit Test Rewards",
            card_color="#FF0000",
        )
        program = self.service.create_program(db, ps_merchant.id, data)

        assert program.id is not None
        assert program.merchant_id == ps_merchant.id
        assert program.loyalty_type == "points"
        assert program.points_per_euro == 5
        assert program.card_name == "Unit Test Rewards"
        assert program.is_active is True

    def test_create_program_stamps(self, db, ps_merchant):
        """Create a stamps-based program."""
        data = ProgramCreate(
            loyalty_type="stamps",
            stamps_target=8,
            stamps_reward_description="Free coffee",
        )
        program = self.service.create_program(db, ps_merchant.id, data)

        assert program.loyalty_type == "stamps"
        assert program.stamps_target == 8
        assert program.stamps_reward_description == "Free coffee"

    def test_create_program_with_rewards(self, db, ps_merchant):
        """Create program with configured rewards."""
        from app.modules.loyalty.schemas.program import PointsRewardConfig

        data = ProgramCreate(
            loyalty_type="points",
            points_per_euro=10,
            points_rewards=[
                PointsRewardConfig(
                    id="r1",
                    name="5 EUR off",
                    points_required=100,
                    is_active=True,
                ),
            ],
        )
        program = self.service.create_program(db, ps_merchant.id, data)

        assert len(program.points_rewards) == 1
        assert program.points_rewards[0]["name"] == "5 EUR off"

    def test_create_program_duplicate_raises(self, db, ps_program, ps_merchant):
        """Cannot create two programs for the same merchant."""
        data = ProgramCreate(loyalty_type="points")
        with pytest.raises(LoyaltyProgramAlreadyExistsException):
            self.service.create_program(db, ps_merchant.id, data)

    def test_create_program_creates_merchant_settings(self, db, ps_merchant):
        """Creating a program also creates merchant loyalty settings."""
        data = ProgramCreate(loyalty_type="points")
        self.service.create_program(db, ps_merchant.id, data)

        settings = self.service.get_merchant_settings(db, ps_merchant.id)
        assert settings is not None

    def test_create_program_with_existing_settings_no_error(self, db, ps_merchant):
        """Creating a program when MerchantLoyaltySettings already exists succeeds (Item 12)."""
        from app.modules.loyalty.models import MerchantLoyaltySettings

        # Pre-create settings
        settings = MerchantLoyaltySettings(merchant_id=ps_merchant.id)
        db.add(settings)
        db.commit()

        # Should not raise IntegrityError
        data = ProgramCreate(loyalty_type="points")
        program = self.service.create_program(db, ps_merchant.id, data)
        assert program is not None

        # Should still have exactly one settings row
        count = (
            db.query(MerchantLoyaltySettings)
            .filter(MerchantLoyaltySettings.merchant_id == ps_merchant.id)
            .count()
        )
        assert count == 1

    def test_duplicate_program_exception_uses_merchant_id(self, db, ps_program, ps_merchant):
        """LoyaltyProgramAlreadyExistsException message references merchant, not store (Item 6)."""
        data = ProgramCreate(loyalty_type="points")
        with pytest.raises(LoyaltyProgramAlreadyExistsException) as exc_info:
            self.service.create_program(db, ps_merchant.id, data)

        assert "Merchant" in str(exc_info.value.message)
        assert str(ps_merchant.id) in str(exc_info.value.message)
        assert exc_info.value.details["merchant_id"] == ps_merchant.id


# ============================================================================
# Update Operations
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestUpdateProgram:
    """Tests for update_program."""

    def setup_method(self):
        self.service = ProgramService()

    def test_update_program_fields(self, db, ps_program):
        """Update specific fields."""
        data = ProgramUpdate(points_per_euro=20, card_name="Updated Name")
        result = self.service.update_program(db, ps_program.id, data)

        assert result.points_per_euro == 20
        assert result.card_name == "Updated Name"

    def test_update_program_partial(self, db, ps_program):
        """Partial update preserves unchanged fields."""
        original_points = ps_program.points_per_euro
        data = ProgramUpdate(card_name="Only Name")
        result = self.service.update_program(db, ps_program.id, data)

        assert result.card_name == "Only Name"
        assert result.points_per_euro == original_points

    def test_update_nonexistent_raises(self, db):
        """Updating non-existent program raises exception."""
        data = ProgramUpdate(card_name="Ghost")
        with pytest.raises(LoyaltyProgramNotFoundException):
            self.service.update_program(db, 999999, data)


# ============================================================================
# Activate / Deactivate
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestActivateDeactivate:
    """Tests for activate_program and deactivate_program."""

    def setup_method(self):
        self.service = ProgramService()

    def test_deactivate_program(self, db, ps_program):
        """Deactivate an active program."""
        assert ps_program.is_active is True
        result = self.service.deactivate_program(db, ps_program.id)
        assert result.is_active is False

    def test_activate_program(self, db, ps_program):
        """Activate an inactive program."""
        ps_program.is_active = False
        db.commit()

        result = self.service.activate_program(db, ps_program.id)
        assert result.is_active is True

    def test_activate_nonexistent_raises(self, db):
        """Activating non-existent program raises exception."""
        with pytest.raises(LoyaltyProgramNotFoundException):
            self.service.activate_program(db, 999999)

    def test_deactivate_nonexistent_raises(self, db):
        """Deactivating non-existent program raises exception."""
        with pytest.raises(LoyaltyProgramNotFoundException):
            self.service.deactivate_program(db, 999999)


# ============================================================================
# Delete Operations
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestDeleteProgram:
    """Tests for delete_program."""

    def setup_method(self):
        self.service = ProgramService()

    def test_delete_program(self, db, ps_program):
        """Delete a program removes it from DB."""
        program_id = ps_program.id
        self.service.delete_program(db, program_id)

        result = self.service.get_program(db, program_id)
        assert result is None

    def test_delete_program_also_deletes_settings(self, db, ps_merchant):
        """Deleting a program also removes merchant settings."""
        data = ProgramCreate(loyalty_type="points")
        program = self.service.create_program(db, ps_merchant.id, data)

        # Verify settings exist
        settings = self.service.get_merchant_settings(db, ps_merchant.id)
        assert settings is not None

        self.service.delete_program(db, program.id)

        # Settings should be gone too
        settings = self.service.get_merchant_settings(db, ps_merchant.id)
        assert settings is None

    def test_delete_nonexistent_raises(self, db):
        """Deleting non-existent program raises exception."""
        with pytest.raises(LoyaltyProgramNotFoundException):
            self.service.delete_program(db, 999999)


# ============================================================================
# Stats
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestGetProgramStats:
    """Tests for get_program_stats."""

    def setup_method(self):
        self.service = ProgramService()

    def test_stats_returns_all_fields(self, db, ps_program):
        """Stats response includes all required fields."""
        stats = self.service.get_program_stats(db, ps_program.id)

        assert "total_cards" in stats
        assert "active_cards" in stats
        assert "new_this_month" in stats
        assert "total_points_balance" in stats
        assert "avg_points_per_member" in stats
        assert "transactions_30d" in stats
        assert "points_issued_30d" in stats
        assert "points_redeemed_30d" in stats
        assert "points_this_month" in stats
        assert "points_redeemed_this_month" in stats
        assert "estimated_liability_cents" in stats

    def test_stats_empty_program(self, db, ps_program):
        """Stats for program with no cards."""
        stats = self.service.get_program_stats(db, ps_program.id)

        assert stats["total_cards"] == 0
        assert stats["active_cards"] == 0
        assert stats["new_this_month"] == 0
        assert stats["total_points_balance"] == 0
        assert stats["avg_points_per_member"] == 0

    def test_stats_with_cards(self, db, ps_program, ps_merchant):
        """Stats reflect actual card data."""
        from datetime import UTC, datetime

        from app.modules.customers.models.customer import Customer
        from app.modules.loyalty.models import LoyaltyCard
        from app.modules.tenancy.models import Store

        uid_store = uuid.uuid4().hex[:8]
        store = Store(
            merchant_id=ps_merchant.id,
            store_code=f"STAT_{uid_store.upper()}",
            subdomain=f"stat{uid_store}",
            name=f"Stats Store {uid_store}",
            is_active=True,
            is_verified=True,
        )
        db.add(store)
        db.flush()

        # Create cards with customers
        customers = []
        for i in range(3):
            uid = uuid.uuid4().hex[:8]
            customers.append(Customer(
                email=f"stat_{uid}@test.com",
                first_name="Stat",
                last_name=f"Customer{i}",
                hashed_password="!unused!",  # noqa: SEC001
                customer_number=f"SC-{uid.upper()}",
                store_id=store.id,
                is_active=True,
            ))
        db.add_all(customers)
        db.flush()

        cards = []
        for i, customer in enumerate(customers):
            cards.append(LoyaltyCard(
                merchant_id=ps_merchant.id,
                program_id=ps_program.id,
                customer_id=customer.id,
                card_number=f"STAT-{i}-{uuid.uuid4().hex[:6]}",
                points_balance=100 * (i + 1),
                total_points_earned=100 * (i + 1),
                is_active=True,
                last_activity_at=datetime.now(UTC),
            ))
        db.add_all(cards)
        db.commit()

        stats = self.service.get_program_stats(db, ps_program.id)

        assert stats["total_cards"] == 3
        assert stats["active_cards"] == 3
        assert stats["total_points_balance"] == 600  # 100+200+300
        assert stats["avg_points_per_member"] == 200.0  # 600/3


# ============================================================================
# Merchant Stats
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestGetMerchantStats:
    """Tests for get_merchant_stats."""

    def setup_method(self):
        self.service = ProgramService()

    def test_merchant_stats_returns_all_fields(self, db, ps_program, ps_merchant):
        """Merchant stats include all required fields."""
        stats = self.service.get_merchant_stats(db, ps_merchant.id)

        expected_fields = [
            "merchant_id",
            "program_id",
            "total_cards",
            "active_cards",
            "new_this_month",
            "total_points_issued",
            "total_points_redeemed",
            "points_issued_30d",
            "points_redeemed_30d",
            "transactions_30d",
            "estimated_liability_cents",
            "program",
            "locations",
        ]
        for field in expected_fields:
            assert field in stats, f"Missing field: {field}"

    def test_merchant_stats_empty_program(self, db, ps_program, ps_merchant):
        """Merchant stats for program with no cards."""
        stats = self.service.get_merchant_stats(db, ps_merchant.id)

        assert stats["merchant_id"] == ps_merchant.id
        assert stats["program_id"] == ps_program.id
        assert stats["total_cards"] == 0
        assert stats["active_cards"] == 0
        assert stats["new_this_month"] == 0
        assert stats["estimated_liability_cents"] == 0

    def test_merchant_stats_no_program(self, db):
        """Returns empty stats when merchant has no program."""
        from middleware.auth import AuthManager

        auth = AuthManager()
        uid = uuid.uuid4().hex[:8]

        owner = User(
            email=f"noprg_{uid}@test.com",
            username=f"noprg_{uid}",
            hashed_password=auth.hash_password("testpass123"),
            role="merchant_owner",
            is_active=True,
            is_email_verified=True,
        )
        db.add(owner)
        db.commit()
        db.refresh(owner)

        merchant = Merchant(
            name=f"No Program Merchant {uid}",
            owner_user_id=owner.id,
            contact_email=owner.email,
            is_active=True,
            is_verified=True,
        )
        db.add(merchant)
        db.commit()
        db.refresh(merchant)

        stats = self.service.get_merchant_stats(db, merchant.id)
        assert stats["program_id"] is None
        assert stats["total_cards"] == 0
        assert stats["program"] is None
        assert stats["locations"] == []

    def test_merchant_stats_includes_program_info(self, db, ps_program, ps_merchant):
        """Merchant stats include program configuration."""
        stats = self.service.get_merchant_stats(db, ps_merchant.id)

        assert stats["program"] is not None
        assert stats["program"]["id"] == ps_program.id
        assert stats["program"]["loyalty_type"] == "points"
        assert stats["program"]["points_per_euro"] == 10

    def test_merchant_stats_with_cards(self, db, ps_program, ps_merchant):
        """Merchant stats reflect card data including new fields."""
        from datetime import UTC, datetime

        from app.modules.customers.models.customer import Customer
        from app.modules.loyalty.models import LoyaltyCard
        from app.modules.tenancy.models import Store

        uid_store = uuid.uuid4().hex[:8]
        store = Store(
            merchant_id=ps_merchant.id,
            store_code=f"MSTAT_{uid_store.upper()}",
            subdomain=f"mstat{uid_store}",
            name=f"Merchant Stats Store {uid_store}",
            is_active=True,
            is_verified=True,
        )
        db.add(store)
        db.flush()

        customers = []
        for i in range(2):
            uid = uuid.uuid4().hex[:8]
            customers.append(Customer(
                email=f"mstat_{uid}@test.com",
                first_name="MS",
                last_name=f"Customer{i}",
                hashed_password="!unused!",  # noqa: SEC001
                customer_number=f"MS-{uid.upper()}",
                store_id=store.id,
                is_active=True,
            ))
        db.add_all(customers)
        db.flush()

        cards = []
        for i, customer in enumerate(customers):
            cards.append(LoyaltyCard(
                merchant_id=ps_merchant.id,
                program_id=ps_program.id,
                customer_id=customer.id,
                enrolled_at_store_id=store.id,
                card_number=f"MSTAT-{i}-{uuid.uuid4().hex[:6]}",
                points_balance=200,
                total_points_earned=200,
                is_active=True,
                last_activity_at=datetime.now(UTC),
            ))
        db.add_all(cards)
        db.commit()

        stats = self.service.get_merchant_stats(db, ps_merchant.id)

        assert stats["total_cards"] == 2
        assert stats["active_cards"] == 2
        # new_this_month should include cards created today
        assert stats["new_this_month"] == 2
        # estimated_liability_cents: 2 cards * 200 points each = 400 points
        # 400 // 100 * 100 = 400 cents
        assert stats["estimated_liability_cents"] == 400

    def test_merchant_stats_location_breakdown(self, db, ps_program, ps_merchant):
        """Location breakdown includes per-store data."""
        from datetime import UTC, datetime

        from app.modules.customers.models.customer import Customer
        from app.modules.loyalty.models import LoyaltyCard
        from app.modules.tenancy.models import Store

        uid = uuid.uuid4().hex[:8]
        store = Store(
            merchant_id=ps_merchant.id,
            store_code=f"MLOC_{uid.upper()}",
            subdomain=f"mloc{uid}",
            name=f"Location Store {uid}",
            is_active=True,
            is_verified=True,
        )
        db.add(store)
        db.flush()

        customer = Customer(
            email=f"mloc_{uid}@test.com",
            first_name="Loc",
            last_name="Customer",
            hashed_password="!unused!",  # noqa: SEC001
            customer_number=f"LC-{uid.upper()}",
            store_id=store.id,
            is_active=True,
        )
        db.add(customer)
        db.flush()

        card = LoyaltyCard(
            merchant_id=ps_merchant.id,
            program_id=ps_program.id,
            customer_id=customer.id,
            enrolled_at_store_id=store.id,
            card_number=f"MLOC-{uuid.uuid4().hex[:6]}",
            points_balance=50,
            total_points_earned=50,
            is_active=True,
            last_activity_at=datetime.now(UTC),
        )
        db.add(card)
        db.commit()

        stats = self.service.get_merchant_stats(db, ps_merchant.id)

        assert len(stats["locations"]) >= 1
        # Find our store in locations
        our_loc = next(
            (loc for loc in stats["locations"] if loc["store_id"] == store.id),
            None,
        )
        assert our_loc is not None
        assert our_loc["store_name"] == store.name
        assert our_loc["enrolled_count"] == 1


# ============================================================================
# Platform Stats
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestGetPlatformStats:
    """Tests for get_platform_stats."""

    def setup_method(self):
        self.service = ProgramService()

    def test_platform_stats_returns_all_fields(self, db):
        """Platform stats include all required fields."""
        stats = self.service.get_platform_stats(db)

        expected_fields = [
            "total_programs",
            "active_programs",
            "merchants_with_programs",
            "total_cards",
            "active_cards",
            "transactions_30d",
            "points_issued_30d",
            "points_redeemed_30d",
            "total_points_issued",
            "total_points_redeemed",
            "total_points_balance",
            "new_this_month",
            "estimated_liability_cents",
        ]
        for field in expected_fields:
            assert field in stats, f"Missing field: {field}"

    def test_platform_stats_counts_programs(self, db, ps_program):
        """Platform stats count programs correctly."""
        stats = self.service.get_platform_stats(db)

        assert stats["total_programs"] >= 1
        assert stats["active_programs"] >= 1
        assert stats["merchants_with_programs"] >= 1

    def test_platform_stats_with_cards(self, db, ps_program, ps_merchant):
        """Platform stats reflect card data across all programs."""
        from datetime import UTC, datetime

        from app.modules.customers.models.customer import Customer
        from app.modules.loyalty.models import LoyaltyCard
        from app.modules.tenancy.models import Store

        uid = uuid.uuid4().hex[:8]
        store = Store(
            merchant_id=ps_merchant.id,
            store_code=f"PLAT_{uid.upper()}",
            subdomain=f"plat{uid}",
            name=f"Platform Stats Store {uid}",
            is_active=True,
            is_verified=True,
        )
        db.add(store)
        db.flush()

        customer = Customer(
            email=f"plat_{uid}@test.com",
            first_name="Plat",
            last_name="Customer",
            hashed_password="!unused!",  # noqa: SEC001
            customer_number=f"PC-{uid.upper()}",
            store_id=store.id,
            is_active=True,
        )
        db.add(customer)
        db.flush()

        card = LoyaltyCard(
            merchant_id=ps_merchant.id,
            program_id=ps_program.id,
            customer_id=customer.id,
            card_number=f"PLAT-{uuid.uuid4().hex[:6]}",
            points_balance=300,
            total_points_earned=300,
            is_active=True,
            last_activity_at=datetime.now(UTC),
        )
        db.add(card)
        db.commit()

        stats = self.service.get_platform_stats(db)

        assert stats["total_cards"] >= 1
        assert stats["active_cards"] >= 1
        assert stats["total_points_balance"] >= 300
        assert stats["new_this_month"] >= 1
        assert stats["estimated_liability_cents"] >= 0

    def test_platform_stats_zero_when_empty(self, db):
        """All numeric fields are zero or positive."""
        stats = self.service.get_platform_stats(db)

        assert stats["total_cards"] >= 0
        assert stats["active_cards"] >= 0
        assert stats["transactions_30d"] >= 0
        assert stats["points_issued_30d"] >= 0
        assert stats["points_redeemed_30d"] >= 0
        assert stats["total_points_issued"] >= 0
        assert stats["total_points_redeemed"] >= 0
        assert stats["total_points_balance"] >= 0
        assert stats["new_this_month"] >= 0
        assert stats["estimated_liability_cents"] >= 0
