"""Referral Engine — program CRUD, code issuance, and attribution lifecycle (Phase 7.5)."""
from __future__ import annotations

from datetime import datetime, timezone
from uuid import UUID, uuid4

from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.events.publisher import TransactionalEventPublisher
from app.events.types import LoyaltyEventType
from app.models.referral import ReferralAttribution, ReferralCode, ReferralProgram
from app.models.types import (
    AuditAction,
    MemberStatus,
    ReferralAttributionStatus,
    ReferralCodeStatus,
)
from app.repositories.foundation import (
    LoyaltyProgramRepository,
    MemberRepository,
    PointAccountRepository,
)
from app.repositories.referral import (
    ReferralAttributionRepository,
    ReferralCodeRepository,
    ReferralProgramRepository,
)
from app.schemas.ledger import PointEarnRequest
from app.schemas.referral import (
    ReferralAttributeRequest,
    ReferralCancelRequest,
    ReferralCodeIssueRequest,
    ReferralProgramCreate,
    ReferralProgramUpdate,
)
from app.services.audit_service import AuditService
from app.services.point_engine import PointEngineService
from app.validators.foundation import (
    release_unique_token,
    validate_code,
    validate_non_empty,
)
from app.validators.referral import (
    ensure_attribution_cancellable,
    ensure_attribution_converted,
    ensure_attribution_pending,
    ensure_code_active,
    ensure_max_referrals_not_exceeded,
    ensure_not_self_referral,
    ensure_referral_program_active,
    validate_referral_program_status,
)
from shared.exceptions import AppError, NotFoundError
from shared.security import CurrentUser


def _now() -> datetime:
    return datetime.now(timezone.utc)


def _next_referral_code() -> str:
    return f"REF-{uuid4().hex[:10].upper()}"


def _map_integrity(*, error_code: str, message: str) -> AppError:
    return AppError(message, status_code=409, error_code=error_code)


class ReferralEngineService:
    def __init__(
        self,
        session: AsyncSession,
        publisher: TransactionalEventPublisher | None = None,
    ) -> None:
        self.session = session
        self.programs = ReferralProgramRepository(session)
        self.codes = ReferralCodeRepository(session)
        self.attributions = ReferralAttributionRepository(session)
        self.loyalty_programs = LoyaltyProgramRepository(session)
        self.members = MemberRepository(session)
        self.accounts = PointAccountRepository(session)
        self.audit = AuditService(session)
        self.publisher = publisher or TransactionalEventPublisher(session)
        self.point_engine = PointEngineService(session, publisher=self.publisher)

    # ------------------------------------------------------------------
    # Referral program CRUD
    # ------------------------------------------------------------------

    async def create_program(
        self,
        tenant_id: UUID,
        body: ReferralProgramCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> ReferralProgram:
        actor_id = actor.user_id if actor else None
        if await self.loyalty_programs.get(tenant_id, body.program_id) is None:
            raise NotFoundError(
                "برنامه وفاداری یافت نشد", error_code="program_not_found"
            )
        code = validate_code(body.code)
        name = validate_non_empty(body.name, field="name")
        status = validate_referral_program_status(body.status)
        if await self.programs.get_by_code(tenant_id, body.program_id, code) is not None:
            raise AppError(
                "کد برنامه معرفی تکراری است",
                status_code=409,
                error_code="referral_program_code_exists",
            )
        entity = ReferralProgram(
            tenant_id=tenant_id,
            program_id=body.program_id,
            code=code,
            name=name,
            status=status,
            referrer_bonus_points=body.referrer_bonus_points,
            referee_bonus_points=body.referee_bonus_points,
            max_referrals_per_member=body.max_referrals_per_member,
            created_by=actor_id,
            updated_by=actor_id,
        )
        try:
            await self.programs.add(entity)
            await self.audit.record(
                tenant_id=tenant_id,
                entity_type="referral_program",
                entity_id=entity.id,
                action=AuditAction.CREATE,
                actor_user_id=actor_id,
                message=f"Referral program {entity.code} created",
            )
            await self.publisher.publish(
                event_type=LoyaltyEventType.REFERRAL_PROGRAM_CREATED,
                aggregate_type="referral_program",
                aggregate_id=entity.id,
                tenant_id=tenant_id,
                payload={"code": entity.code, "status": entity.status.value},
            )
            await self.session.commit()
            await self.session.refresh(entity)
        except IntegrityError:
            await self.session.rollback()
            raise _map_integrity(
                error_code="referral_program_code_exists",
                message="کد برنامه معرفی تکراری است",
            )
        return entity

    async def update_program(
        self,
        tenant_id: UUID,
        program_id: UUID,
        body: ReferralProgramUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> ReferralProgram:
        entity = await self.get_program(tenant_id, program_id)
        data = body.model_dump(exclude_unset=True)
        if "name" in data and data["name"] is None:
            raise AppError(
                "name نمی‌تواند null باشد",
                status_code=422,
                error_code="null_not_allowed",
                details={"field": "name"},
            )
        if "status" in data and data["status"] is None:
            raise AppError(
                "status نمی‌تواند null باشد",
                status_code=422,
                error_code="null_not_allowed",
                details={"field": "status"},
            )
        if "name" in data:
            data["name"] = validate_non_empty(data["name"], field="name")
        if "status" in data:
            data["status"] = validate_referral_program_status(data["status"])
        for key, value in data.items():
            setattr(entity, key, value)
        entity.updated_by = actor.user_id if actor else None
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="referral_program",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=entity.updated_by,
            changes=data,
        )
        await self.publisher.publish(
            event_type=LoyaltyEventType.REFERRAL_PROGRAM_UPDATED,
            aggregate_type="referral_program",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code, "status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_program(self, tenant_id: UUID, program_id: UUID) -> ReferralProgram:
        entity = await self.programs.get(tenant_id, program_id)
        if entity is None:
            raise NotFoundError(
                "برنامه معرفی یافت نشد", error_code="referral_program_not_found"
            )
        return entity

    async def list_programs(self, tenant_id: UUID, *, offset: int, limit: int):
        return await self.programs.list_by_tenant(tenant_id, offset=offset, limit=limit)

    async def soft_delete_program(
        self,
        tenant_id: UUID,
        program_id: UUID,
        *,
        actor: CurrentUser | None = None,
    ) -> ReferralProgram:
        entity = await self.get_program(tenant_id, program_id)
        entity.code = release_unique_token(entity.code, entity.id)
        await self.programs.soft_delete(
            entity, deleted_by=actor.user_id if actor else None
        )
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="referral_program",
            entity_id=entity.id,
            action=AuditAction.DELETE,
            actor_user_id=actor.user_id if actor else None,
        )
        await self.publisher.publish(
            event_type=LoyaltyEventType.REFERRAL_PROGRAM_DELETED,
            aggregate_type="referral_program",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # ------------------------------------------------------------------
    # Referral code issuance
    # ------------------------------------------------------------------

    async def issue_code(
        self,
        tenant_id: UUID,
        body: ReferralCodeIssueRequest,
        *,
        actor: CurrentUser | None = None,
    ) -> ReferralCode:
        actor_id = actor.user_id if actor else None
        program = await self.get_program(tenant_id, body.referral_program_id)
        ensure_referral_program_active(program.status)

        member = await self.members.get(tenant_id, body.member_id)
        if member is None:
            raise NotFoundError("عضو یافت نشد", error_code="member_not_found")
        if member.program_id != program.program_id:
            raise AppError(
                "عضو متعلق به برنامه این طرح معرفی نیست",
                status_code=409,
                error_code="member_program_mismatch",
            )

        existing = await self.codes.get_active_for_member(
            tenant_id, program.id, member.id
        )
        if existing is not None:
            return existing

        code_value = (
            validate_code(body.code, field="code") if body.code else _next_referral_code()
        )
        if await self.codes.get_by_code(tenant_id, code_value) is not None:
            raise AppError(
                "کد معرفی تکراری است",
                status_code=409,
                error_code="referral_code_exists",
            )

        entity = ReferralCode(
            tenant_id=tenant_id,
            referral_program_id=program.id,
            program_id=program.program_id,
            member_id=member.id,
            code=code_value,
            status=ReferralCodeStatus.ACTIVE,
            uses_count=0,
            created_by=actor_id,
        )
        try:
            await self.codes.add(entity)
            await self.audit.record(
                tenant_id=tenant_id,
                entity_type="referral_code",
                entity_id=entity.id,
                action=AuditAction.ISSUE_CODE,
                actor_user_id=actor_id,
                message=f"Referral code {entity.code} issued to member {member.membership_number}",
            )
            await self.publisher.publish(
                event_type=LoyaltyEventType.REFERRAL_CODE_ISSUED,
                aggregate_type="referral_code",
                aggregate_id=entity.id,
                tenant_id=tenant_id,
                payload={"code": entity.code, "member_id": str(member.id)},
            )
            await self.session.commit()
            await self.session.refresh(entity)
        except IntegrityError:
            await self.session.rollback()
            raise _map_integrity(
                error_code="referral_code_exists",
                message="کد معرفی تکراری است",
            )
        return entity

    async def revoke_code(
        self,
        tenant_id: UUID,
        code_id: UUID,
        *,
        actor: CurrentUser | None = None,
    ) -> ReferralCode:
        entity = await self.get_code(tenant_id, code_id)
        entity.status = ReferralCodeStatus.REVOKED
        actor_id = actor.user_id if actor else None
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="referral_code",
            entity_id=entity.id,
            action=AuditAction.REVOKE_CODE,
            actor_user_id=actor_id,
            message=f"Referral code {entity.code} revoked",
        )
        await self.publisher.publish(
            event_type=LoyaltyEventType.REFERRAL_CODE_REVOKED,
            aggregate_type="referral_code",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_code(self, tenant_id: UUID, code_id: UUID) -> ReferralCode:
        entity = await self.codes.get(tenant_id, code_id)
        if entity is None:
            raise NotFoundError(
                "کد معرفی یافت نشد", error_code="referral_code_not_found"
            )
        return entity

    async def list_codes(
        self,
        tenant_id: UUID,
        *,
        offset: int = 0,
        limit: int = 50,
        referral_program_id: UUID | None = None,
    ):
        rows = await self.codes.list_for_program(
            tenant_id, referral_program_id, offset=offset, limit=limit
        )
        total = await self.codes.count_for_program(tenant_id, referral_program_id)
        return rows, total

    # ------------------------------------------------------------------
    # Attribution lifecycle: attribute -> convert -> reward | cancel
    # ------------------------------------------------------------------

    async def attribute(
        self,
        tenant_id: UUID,
        body: ReferralAttributeRequest,
        *,
        actor: CurrentUser | None = None,
    ) -> ReferralAttribution:
        if body.idempotency_key:
            existing = await self.attributions.get_by_idempotency(
                tenant_id, body.idempotency_key
            )
            if existing is not None:
                return existing

        code = await self.codes.get_by_code(tenant_id, body.code)
        if code is None:
            raise NotFoundError(
                "کد معرفی یافت نشد", error_code="referral_code_not_found"
            )
        ensure_code_active(code.status)

        program = await self.get_program(tenant_id, code.referral_program_id)
        ensure_referral_program_active(program.status)

        referee = await self.members.get(tenant_id, body.referee_member_id)
        if referee is None:
            raise NotFoundError("عضو یافت نشد", error_code="member_not_found")

        ensure_not_self_referral(code.member_id, referee.id)

        existing_attribution = await self.attributions.get_by_referee(
            tenant_id, program.id, referee.id
        )
        if existing_attribution is not None:
            raise AppError(
                "این عضو قبلا به این طرح معرفی ارجاع داده شده است",
                status_code=409,
                error_code="referral_referee_already_attributed",
            )

        existing_count = await self.attributions.count_for_referrer(
            tenant_id,
            program.id,
            code.member_id,
            exclude_statuses=(ReferralAttributionStatus.CANCELLED,),
        )
        ensure_max_referrals_not_exceeded(existing_count, program.max_referrals_per_member)

        actor_id = actor.user_id if actor else None
        attribution = ReferralAttribution(
            tenant_id=tenant_id,
            referral_program_id=program.id,
            referral_code_id=code.id,
            program_id=program.program_id,
            referrer_member_id=code.member_id,
            referee_member_id=referee.id,
            status=ReferralAttributionStatus.PENDING,
            idempotency_key=body.idempotency_key,
            actor_user_id=actor_id,
        )
        try:
            await self.attributions.add(attribution)
            code.uses_count += 1
            await self.audit.record(
                tenant_id=tenant_id,
                entity_type="referral_attribution",
                entity_id=attribution.id,
                action=AuditAction.ATTRIBUTE,
                actor_user_id=actor_id,
                message=(
                    f"Referral code {code.code} attributed referee "
                    f"{referee.membership_number}"
                ),
                changes={
                    "referral_code_id": str(code.id),
                    "referrer_member_id": str(code.member_id),
                    "referee_member_id": str(referee.id),
                },
            )
            await self.publisher.publish(
                event_type=LoyaltyEventType.REFERRAL_ATTRIBUTED,
                aggregate_type="referral_attribution",
                aggregate_id=attribution.id,
                tenant_id=tenant_id,
                payload={
                    "referral_program_id": str(program.id),
                    "referrer_member_id": str(code.member_id),
                    "referee_member_id": str(referee.id),
                },
            )
            await self.session.commit()
            await self.session.refresh(attribution)
        except IntegrityError:
            await self.session.rollback()
            raise _map_integrity(
                error_code="referral_attribution_conflict",
                message="این ارجاع معرفی تکراری است",
            )
        return attribution

    async def get_attribution(
        self, tenant_id: UUID, attribution_id: UUID
    ) -> ReferralAttribution:
        entity = await self.attributions.get(tenant_id, attribution_id)
        if entity is None:
            raise NotFoundError(
                "ارجاع معرفی یافت نشد", error_code="referral_attribution_not_found"
            )
        return entity

    async def convert(
        self,
        tenant_id: UUID,
        attribution_id: UUID,
        *,
        actor: CurrentUser | None = None,
    ) -> ReferralAttribution:
        attribution = await self.get_attribution(tenant_id, attribution_id)
        ensure_attribution_pending(attribution.status)

        referee = await self.members.get(tenant_id, attribution.referee_member_id)
        if referee is None:
            raise NotFoundError("عضو یافت نشد", error_code="member_not_found")
        if referee.status != MemberStatus.ACTIVE:
            raise AppError(
                "عضو معرفی‌شده باید فعال باشد",
                status_code=409,
                error_code="referral_referee_not_active",
            )

        actor_id = actor.user_id if actor else None
        attribution.status = ReferralAttributionStatus.CONVERTED
        attribution.converted_at = _now()
        attribution.actor_user_id = actor_id
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="referral_attribution",
            entity_id=attribution.id,
            action=AuditAction.CONVERT,
            actor_user_id=actor_id,
            message="Referral attribution converted",
        )
        await self.publisher.publish(
            event_type=LoyaltyEventType.REFERRAL_CONVERTED,
            aggregate_type="referral_attribution",
            aggregate_id=attribution.id,
            tenant_id=tenant_id,
            payload={"referee_member_id": str(attribution.referee_member_id)},
        )
        await self.session.commit()
        await self.session.refresh(attribution)
        return attribution

    async def reward(
        self,
        tenant_id: UUID,
        attribution_id: UUID,
        *,
        actor: CurrentUser | None = None,
    ) -> ReferralAttribution:
        attribution = await self.get_attribution(tenant_id, attribution_id)
        ensure_attribution_converted(attribution.status)

        program = await self.get_program(tenant_id, attribution.referral_program_id)

        referrer_account = await self.accounts.get_by_member(
            tenant_id, attribution.referrer_member_id
        )
        if referrer_account is None:
            raise NotFoundError(
                "حساب امتیاز معرف یافت نشد", error_code="point_account_not_found"
            )
        referee_account = await self.accounts.get_by_member(
            tenant_id, attribution.referee_member_id
        )
        if referee_account is None:
            raise NotFoundError(
                "حساب امتیاز معرفی‌شده یافت نشد", error_code="point_account_not_found"
            )

        actor_id = actor.user_id if actor else None
        referrer_entry_id = attribution.referrer_ledger_entry_id
        referee_entry_id = attribution.referee_ledger_entry_id

        if program.referrer_bonus_points > 0:
            referrer_entry = await self.point_engine.earn(
                tenant_id,
                referrer_account.id,
                PointEarnRequest(
                    amount=program.referrer_bonus_points,
                    reason=f"referral:{program.code}:referrer",
                    reference_type="referral_attribution",
                    reference_id=str(attribution.id),
                    idempotency_key=f"referral-reward-{attribution.id}-referrer",
                ),
                actor=actor,
                auto_commit=False,
            )
            referrer_entry_id = referrer_entry.id

        if program.referee_bonus_points > 0:
            referee_entry = await self.point_engine.earn(
                tenant_id,
                referee_account.id,
                PointEarnRequest(
                    amount=program.referee_bonus_points,
                    reason=f"referral:{program.code}:referee",
                    reference_type="referral_attribution",
                    reference_id=str(attribution.id),
                    idempotency_key=f"referral-reward-{attribution.id}-referee",
                ),
                actor=actor,
                auto_commit=False,
            )
            referee_entry_id = referee_entry.id

        attribution.status = ReferralAttributionStatus.REWARDED
        attribution.rewarded_at = _now()
        attribution.referrer_ledger_entry_id = referrer_entry_id
        attribution.referee_ledger_entry_id = referee_entry_id
        attribution.actor_user_id = actor_id
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="referral_attribution",
            entity_id=attribution.id,
            action=AuditAction.REWARD,
            actor_user_id=actor_id,
            message="Referral attribution rewarded",
            changes={
                "referrer_bonus_points": program.referrer_bonus_points,
                "referee_bonus_points": program.referee_bonus_points,
            },
        )
        await self.publisher.publish(
            event_type=LoyaltyEventType.REFERRAL_REWARDED,
            aggregate_type="referral_attribution",
            aggregate_id=attribution.id,
            tenant_id=tenant_id,
            payload={
                "referrer_member_id": str(attribution.referrer_member_id),
                "referee_member_id": str(attribution.referee_member_id),
                "referrer_bonus_points": program.referrer_bonus_points,
                "referee_bonus_points": program.referee_bonus_points,
            },
        )
        await self.session.commit()
        await self.session.refresh(attribution)
        return attribution

    async def cancel(
        self,
        tenant_id: UUID,
        attribution_id: UUID,
        body: ReferralCancelRequest,
        *,
        actor: CurrentUser | None = None,
    ) -> ReferralAttribution:
        attribution = await self.get_attribution(tenant_id, attribution_id)
        ensure_attribution_cancellable(attribution.status)

        actor_id = actor.user_id if actor else None
        attribution.status = ReferralAttributionStatus.CANCELLED
        attribution.cancelled_at = _now()
        attribution.cancel_reason = body.reason
        attribution.actor_user_id = actor_id
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="referral_attribution",
            entity_id=attribution.id,
            action=AuditAction.CANCEL,
            actor_user_id=actor_id,
            message="Referral attribution cancelled",
            changes={"reason": body.reason},
        )
        await self.publisher.publish(
            event_type=LoyaltyEventType.REFERRAL_CANCELLED,
            aggregate_type="referral_attribution",
            aggregate_id=attribution.id,
            tenant_id=tenant_id,
            payload={"reason": body.reason},
        )
        await self.session.commit()
        await self.session.refresh(attribution)
        return attribution

    async def list_attributions(
        self,
        tenant_id: UUID,
        *,
        offset: int = 0,
        limit: int = 50,
        referral_program_id: UUID | None = None,
    ):
        rows = await self.attributions.list_for_program(
            tenant_id, referral_program_id, offset=offset, limit=limit
        )
        total = await self.attributions.count_for_program(tenant_id, referral_program_id)
        return rows, total
