from typing import Optional, List
from sqlalchemy.orm import Session
from src.core.exceptions import ForbiddenError, NotFoundError, APIException
from src.apps.payment_providers import crud
from src.apps.payment_providers.models.payment_provider import PaymentProvider
from src.apps.payment_providers.models.merchant_provider_config import MerchantProviderConfig
from src.apps.payment_providers.schemas.requests import (
    AdminCreateProviderRequest,
    AdminAssignProviderRequest,
    AdminUpdateProviderRequest,
)
from src.apps.payment_providers.schemas.responses import (
    PaymentProviderResponse,
    MerchantProviderConfigResponse,
    AdminMerchantProviderConfigResponse,
    OnboardingStateResponse,
    OnboardingSchemaResponse,
    OnboardingFieldSchema,
    SelectProviderResponse,
)


def get_providers_for_merchant(merchant, db: Session) -> List[PaymentProviderResponse]:
    """Return all providers available to this merchant with config state."""
    merchant_configs = crud.get_available_providers_for_merchant(merchant.id, db)
    config_map = {mc.provider_id: mc for mc in merchant_configs}

    active_providers = crud.get_active_providers(db)

    result = []
    for provider in active_providers:
        mc = config_map.get(provider.id)
        if mc is None:
            continue  # Provider not assigned to this merchant
        config_resp = MerchantProviderConfigResponse(
            onboarding_status=mc.onboarding_status,
            is_active=mc.is_active,
            is_active_provider=(merchant.active_provider_id == provider.id),
            config_data=mc.config_data,
        )
        result.append(PaymentProviderResponse(
            id=provider.id,
            name=provider.name,
            slug=provider.slug,
            description=provider.description,
            logo_url=provider.logo_url,
            is_active=provider.is_active,
            is_default=provider.is_default,
            supported_payment_methods=provider.supported_payment_methods,
            config=config_resp,
        ))
    return result


def get_active_provider_for_merchant(merchant, db: Session) -> Optional[PaymentProviderResponse]:
    """Return the merchant's currently active provider."""
    if merchant.active_provider_id is None:
        # Legacy: find the default provider
        provider = crud.get_default_provider(db)
        if not provider:
            return None
    else:
        provider = crud.get_provider_by_id(merchant.active_provider_id, db)
        if not provider:
            return None

    mc = crud.get_merchant_provider_config(merchant.id, provider.id, db)
    config_resp = None
    if mc:
        config_resp = MerchantProviderConfigResponse(
            onboarding_status=mc.onboarding_status,
            is_active=mc.is_active,
            is_active_provider=True,
            config_data=mc.config_data,
        )
    return PaymentProviderResponse(
        id=provider.id,
        name=provider.name,
        slug=provider.slug,
        description=provider.description,
        logo_url=provider.logo_url,
        is_active=provider.is_active,
        is_default=provider.is_default,
        supported_payment_methods=provider.supported_payment_methods,
        config=config_resp,
    )


def select_active_provider(merchant, provider_id: int, db: Session) -> SelectProviderResponse:
    """Set a provider as the merchant's active provider."""
    provider = crud.get_provider_by_id(provider_id, db)
    if not provider:
        raise NotFoundError(message=f"Provider {provider_id} not found")

    mc = crud.get_merchant_provider_config(merchant.id, provider_id, db)
    if not mc:
        raise ForbiddenError(message="This provider is not configured for your account. Please contact support.")

    if not mc.is_active:
        raise ForbiddenError(message="This provider is disabled for your account.")

    if mc.onboarding_status != "active":
        raise APIException(
            status_code=400,
            message=f"Provider onboarding is incomplete (status: {mc.onboarding_status}). Please complete onboarding first.",
            error="onboarding_required",
        )

    # Capture old provider slug before switching (best-effort)
    old_provider_slug: str | None = None
    try:
        if merchant.active_provider_id:
            old_provider = crud.get_provider_by_id(merchant.active_provider_id, db)
            if old_provider:
                old_provider_slug = old_provider.slug
    except Exception:
        pass

    # Update merchant's active provider
    crud.update_merchant_active_provider(merchant.id, provider_id, db)
    db.commit()

    # Emit merchants.provider_switched domain event (best-effort; non-fatal)
    try:
        import asyncio
        from datetime import datetime, timezone
        from src.events.base import BaseEvent
        from src.events.dispatcher import EventDispatcher

        switched_event = BaseEvent(
            event_type="merchants.provider_switched",
            data={
                "merchant_id": merchant.id,
                "old_provider_slug": old_provider_slug,
                "new_provider_slug": provider.slug,
                "switched_at": datetime.now(timezone.utc).isoformat(),
            },
        )

        # Use get_running_loop() — preferred over deprecated get_event_loop().
        # This function is always called from an async FastAPI route, so a
        # running loop is guaranteed.  asyncio.ensure_future schedules the
        # coroutine on that loop without blocking the response.
        try:
            running_loop = asyncio.get_running_loop()
            asyncio.ensure_future(EventDispatcher.dispatch(switched_event))
        except RuntimeError:
            # No running loop (e.g. called from a sync test context).
            asyncio.run(EventDispatcher.dispatch(switched_event))
    except Exception as _evt_exc:
        import logging as _log
        _log.getLogger(__name__).warning(
            "merchants.provider_switched event dispatch failed for merchant %s: %s",
            merchant.id,
            _evt_exc,
        )

    return SelectProviderResponse(
        message="Active provider updated successfully",
        active_provider_id=provider_id,
    )


def get_onboarding_state(merchant, provider_id: int, db: Session) -> OnboardingStateResponse:
    """Return the onboarding state for a merchant + provider."""
    provider = crud.get_provider_by_id(provider_id, db)
    if not provider:
        raise NotFoundError(message=f"Provider {provider_id} not found")

    mc = crud.get_merchant_provider_config(merchant.id, provider_id, db)
    status = mc.onboarding_status if mc else "not_started"

    completed_keys = []
    schema_resp = None

    if provider.onboarding_schema:
        fields_raw = provider.onboarding_schema.get("fields", [])
        schema_resp = OnboardingSchemaResponse(
            fields=[
                OnboardingFieldSchema(
                    key=f["key"],
                    label=f["label"],
                    type=f.get("type", "text"),
                    required=f.get("required", True),
                    help_text=f.get("help_text"),
                )
                for f in fields_raw
            ]
        )

    if mc:
        completed_keys = crud.get_completed_credential_keys(mc.id, db)

    return OnboardingStateResponse(
        provider_id=provider_id,
        onboarding_status=status,
        schema_=schema_resp,
        completed_fields=completed_keys,
    )


async def submit_onboarding(
    merchant, provider_id: int, credentials_dict: dict, db: Session
) -> OnboardingStateResponse:
    """Submit onboarding credentials for a provider. Encrypts and saves credentials."""
    provider = crud.get_provider_by_id(provider_id, db)
    if not provider:
        raise NotFoundError(message=f"Provider {provider_id} not found")

    if not provider.is_active:
        raise ForbiddenError(message="This provider is not available.")

    # Derive the schema-declared field keys.  These are the ONLY keys we will
    # accept from the merchant.  Any key not declared in onboarding_schema is
    # rejected to prevent mass-assignment of arbitrary credential names.
    if provider.onboarding_schema is None:
        raise APIException(
            status_code=400,
            message="Provider does not support credential onboarding.",
            error="onboarding_not_supported",
        )

    schema_fields = [f["key"] for f in provider.onboarding_schema.get("fields", [])]
    required_fields = [
        f["key"] for f in provider.onboarding_schema.get("fields", [])
        if f.get("required", True) and not f.get("ephemeral", False)
    ]

    # For TSYS, the schema includes ephemeral fields (user_id, password) that are
    # used once to generate a transaction_key and must NOT be stored.
    # We validate them as required inputs here but they are removed before storage.
    all_required_for_validation = [
        f["key"] for f in provider.onboarding_schema.get("fields", [])
        if f.get("required", True)
    ]

    # If schema_fields is an empty list, no credentials are required — store
    # nothing and proceed directly to status update.
    if schema_fields:
        # Reject any keys submitted by the merchant that are not in the schema.
        unknown_keys = [k for k in credentials_dict if k not in schema_fields]
        if unknown_keys:
            raise APIException(
                status_code=422,
                message=f"Unknown credential fields: {', '.join(unknown_keys)}. Allowed fields: {', '.join(schema_fields)}",
                error="validation_error",
            )

    missing = [k for k in all_required_for_validation if not credentials_dict.get(k)]
    if missing:
        raise APIException(
            status_code=422,
            message=f"Missing required credential fields: {', '.join(missing)}",
            error="validation_error",
        )

    # ── TSYS-specific: exchange user_id + password for a transaction_key ──────
    if provider.slug == "tsys":
        from src.core.providers.implementations.tsys import TSYSProvider
        tsys_provider = TSYSProvider()
        try:
            transaction_key = tsys_provider._generate_transaction_key(
                merchant_id=credentials_dict.get("merchant_id", ""),
                user_id=credentials_dict.get("user_id", ""),
                password=credentials_dict.get("password", ""),
            )
        except (ValueError, RuntimeError) as e:
            mc = crud.upsert_merchant_provider_config(
                merchant_id=merchant.id,
                provider_id=provider_id,
                onboarding_status="rejected",
                config_data=None,
                db=db,
            )
            db.flush()
            raise APIException(
                status_code=400,
                message=str(e),
                error="tsys_generate_key_failed",
            )

        # Replace credentials_dict — discard ephemeral user_id and password,
        # add the generated transaction_key.  Never store raw credentials.
        credentials_dict = {
            "merchant_id": credentials_dict["merchant_id"],
            "device_id": credentials_dict["device_id"],
            "transaction_key": transaction_key,
        }
        schema_fields = ["merchant_id", "device_id", "transaction_key"]
        required_fields = ["merchant_id", "device_id", "transaction_key"]

    # Upsert provider config
    mc = crud.upsert_merchant_provider_config(
        merchant_id=merchant.id,
        provider_id=provider_id,
        onboarding_status="in_progress",
        config_data=None,
        db=db,
    )

    # Save each schema-declared credential (encrypted); skip empty values.
    for key, value in credentials_dict.items():
        if value and key in schema_fields:
            crud.upsert_credential(mc.id, key, value, db)

    # For TSYS, record key generation timestamp in config_data
    if provider.slug == "tsys":
        from datetime import datetime, timezone
        mc.config_data = {**(mc.config_data or {}), "key_generated_at": datetime.now(timezone.utc).isoformat()}
        db.flush()

    # Check if all required fields are now complete
    completed_keys = crud.get_completed_credential_keys(mc.id, db)
    all_required_complete = all(k in completed_keys for k in required_fields)

    new_status = "active" if all_required_complete else "in_progress"
    mc.onboarding_status = new_status
    db.flush()

    return OnboardingStateResponse(
        provider_id=provider_id,
        onboarding_status=new_status,
        completed_fields=completed_keys,
    )


def remove_provider_credentials(merchant, provider_id: int, db: Session) -> None:
    """Remove credentials and reset onboarding for a provider."""
    if merchant.active_provider_id == provider_id:
        raise ForbiddenError(
            message="Cannot remove credentials for the currently active provider. Switch to a different provider first."
        )

    mc = crud.get_merchant_provider_config(merchant.id, provider_id, db)
    if not mc:
        raise NotFoundError(message="Provider configuration not found.")

    # Ensure the merchant retains at least one configured provider
    if mc.onboarding_status != "not_started":
        configured_count = crud.count_configured_providers_for_merchant(merchant.id, db)
        if configured_count <= 1:
            raise ForbiddenError(
                message="Cannot remove your only configured payment provider. Please configure another provider first."
            )

    crud.delete_credentials(mc.id, db)
    mc.onboarding_status = "not_started"
    db.flush()


# ─── Admin Service Functions ──────────────────────────────────────────────────

def create_provider(data: AdminCreateProviderRequest, db: Session) -> PaymentProvider:
    """Admin: create a new master payment provider."""
    provider = PaymentProvider(
        name=data.name,
        slug=data.slug,
        description=data.description,
        logo_url=data.logo_url,
        is_active=data.is_active,
        is_default=data.is_default,
        supported_payment_methods=data.supported_payment_methods,
        onboarding_schema=data.onboarding_schema,
    )
    db.add(provider)
    db.flush()
    db.refresh(provider)
    return provider


def admin_assign_provider_to_merchant(
    merchant_id: int,
    provider_id: int,
    data: AdminAssignProviderRequest,
    db: Session,
) -> dict:
    """Admin: assign a provider to a merchant.

    If the merchant already has a config for this provider, only `is_active` is
    updated — the onboarding state is preserved.  Otherwise a new config record
    is created with status ``not_started``.
    """
    existing = crud.get_merchant_provider_config(merchant_id, provider_id, db)
    if existing:
        existing.is_active = data.is_enabled
        db.flush()
        return {"message": "Provider assignment updated for merchant."}

    mc = crud.upsert_merchant_provider_config(
        merchant_id=merchant_id,
        provider_id=provider_id,
        onboarding_status="not_started",
        config_data=None,
        db=db,
    )
    mc.is_active = data.is_enabled
    db.flush()
    return {"message": "Provider assigned to merchant."}


def admin_list_merchant_providers(merchant_id: int, db: Session) -> List[AdminMerchantProviderConfigResponse]:
    """Admin: return all provider configs for a merchant."""
    configs = crud.get_available_providers_for_merchant(merchant_id, db)
    return [
        AdminMerchantProviderConfigResponse(
            provider_id=c.provider_id,
            is_active=c.is_active,
            onboarding_status=c.onboarding_status,
            config_data=c.config_data,
        )
        for c in configs
    ]


def admin_update_provider(
    provider_id: int,
    payload: AdminUpdateProviderRequest,
    db: Session,
) -> "PaymentProvider":
    """Admin: update fields on a master payment provider."""
    provider = crud.get_provider_by_id(provider_id, db)
    if not provider:
        raise NotFoundError(message=f"Provider {provider_id} not found")
    if payload.name is not None:
        provider.name = payload.name
    if payload.description is not None:
        provider.description = payload.description
    if payload.logo_url is not None:
        provider.logo_url = payload.logo_url
    if payload.is_active is not None:
        provider.is_active = payload.is_active
    if payload.is_default is True:
        crud.clear_default_flag(provider_id, db)
    if payload.is_default is not None:
        provider.is_default = payload.is_default
    if payload.supported_payment_methods is not None:
        provider.supported_payment_methods = payload.supported_payment_methods
    if payload.onboarding_schema is not None:
        provider.onboarding_schema = payload.onboarding_schema
    db.flush()
    return provider
