"""Provider-agnostic billing contracts, lifecycle services, and one-liner route registration for multi-tenant SaaS."""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/18_utils_billing.ipynb.

# %% ../nbs/18_utils_billing.ipynb #a0000003
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Dict, Any, List, Protocol, runtime_checkable
from datetime import datetime, timedelta
from starlette.responses import Response, RedirectResponse, JSONResponse
import os
import json
import logging

logger = logging.getLogger(__name__)

# %% auto #0
__all__ = ['logger', 'SubscriptionStatus', 'BillingConfig', 'validate_billing_config', 'AccessDecision', 'PlanStore',
           'SubscriptionStore', 'UserContextProvider', 'BillingUIAdapter', 'HostDBPlanStore', 'HostDBSubscriptionStore',
           'RequestUserContext', 'init_trial_on_first_login', 'resolve_subscription_access', 'resolve_checkout_return',
           'register_billing_routes', 'attach_billing_auth_hooks']

# %% ../nbs/18_utils_billing.ipynb #a0000005
class SubscriptionStatus(str, Enum):
    """Canonical subscription status values.
    
    Every subscription in the system maps to one of these statuses.
    Use `SubscriptionStatus.normalize()` to map raw Stripe/legacy values.
    """
    trialing = 'trialing'
    active = 'active'
    past_due = 'past_due'
    canceled = 'canceled'
    none = 'none'
    checkout_pending = 'checkout_pending'
    completed = 'completed'

    @classmethod
    def normalize(cls, raw: str) -> 'SubscriptionStatus':
        """Map raw status strings to canonical values."""
        if not raw:
            return cls.none
        raw = raw.strip().lower()
        try:
            return cls(raw)
        except ValueError:
            pass
        _ALIASES = {
            'cancelled': cls.canceled,
            'incomplete': cls.past_due,
            'incomplete_expired': cls.past_due,
            'unpaid': cls.past_due,
        }
        if raw in _ALIASES:
            return _ALIASES[raw]
        logger.warning(f"Unknown subscription status '{raw}', mapping to 'none'")
        return cls.none

# %% ../nbs/18_utils_billing.ipynb #a0000006
_PRICING_MODES = ('public_and_post_login_pricing', 'post_login_only', 'public_only')


@dataclass
class BillingConfig:
    """Configuration for the billing orchestrator."""
    stripe_secret_key: str = ''
    stripe_webhook_secret: str = None
    monthly_price_id: str = None
    yearly_price_id: str = None
    trial_days: int = 30
    grace_period_days: int = 3
    base_url: str = 'http://localhost:5001'
    pricing_path: str = '/pricing'
    checkout_path: str = '/checkout/{plan_type}'
    success_path: str = '/payment-success'
    cancel_path: str = '/settings/payment'
    webhook_path: str = '/stripe/webhook'
    portal_path: str = '/billing-portal'
    pricing_mode: str = 'public_and_post_login_pricing'
    allow_promotions: bool = True
    is_development: bool = False

    @classmethod
    def from_env(cls) -> 'BillingConfig':
        """Create BillingConfig from environment variables."""
        is_dev = os.getenv('ENVIRONMENT', 'production').lower() in ('development', 'dev', 'local')
        return cls(
            stripe_secret_key=os.getenv('STRIPE_SECRET_KEY', ''),
            stripe_webhook_secret=os.getenv('STRIPE_WEBHOOK_SECRET'),
            monthly_price_id=os.getenv('STRIPE_MONTHLY_PRICE_ID'),
            yearly_price_id=os.getenv('STRIPE_YEARLY_PRICE_ID'),
            trial_days=int(os.getenv('STRIPE_TRIAL_DAYS', '30')),
            grace_period_days=int(os.getenv('STRIPE_GRACE_DAYS', '3')),
            base_url=os.getenv('STRIPE_BASE_URL', 'http://localhost:5001'),
            pricing_path=os.getenv('BILLING_PRICING_PATH', '/pricing'),
            checkout_path=os.getenv('BILLING_CHECKOUT_PATH', '/checkout/{plan_type}'),
            success_path=os.getenv('BILLING_SUCCESS_PATH',
                                   os.getenv('STRIPE_SUCCESS_PATH', '/payment-success')),
            cancel_path=os.getenv('BILLING_CANCEL_PATH',
                                  os.getenv('STRIPE_CANCEL_PATH', '/settings/payment')),
            webhook_path=os.getenv('BILLING_WEBHOOK_PATH', '/stripe/webhook'),
            portal_path=os.getenv('BILLING_PORTAL_PATH', '/billing-portal'),
            pricing_mode=os.getenv('BILLING_PRICING_MODE', 'public_and_post_login_pricing'),
            allow_promotions=os.getenv('STRIPE_ALLOW_PROMOTIONS', 'true').lower() == 'true',
            is_development=is_dev,
        )

    def to_stripe_config(self):
        """Convert to a StripeConfig for backward-compatible use."""
        from fh_saas.utils_stripe import StripeConfig
        return StripeConfig(
            secret_key=self.stripe_secret_key,
            webhook_secret=self.stripe_webhook_secret,
            monthly_price_id=self.monthly_price_id,
            yearly_price_id=self.yearly_price_id,
            trial_days=self.trial_days,
            grace_period_days=self.grace_period_days,
            base_url=self.base_url,
            success_path=self.success_path,
            cancel_path=self.cancel_path,
            allow_promotions=self.allow_promotions,
            is_development=self.is_development,
        )

# %% ../nbs/18_utils_billing.ipynb #a0000007
def validate_billing_config(config: BillingConfig) -> List[str]:
    """Validate billing configuration and return actionable error messages."""
    errors = []
    if not config.stripe_secret_key:
        errors.append('STRIPE_SECRET_KEY is required. Set the environment variable or pass stripe_secret_key.')
    if not config.is_development and not config.stripe_webhook_secret:
        errors.append(
            'STRIPE_WEBHOOK_SECRET is required in production. '
            'Set the environment variable or set is_development=True for local testing.'
        )
    if not config.monthly_price_id and not config.yearly_price_id:
        errors.append(
            'At least one price ID is required. '
            'Set STRIPE_MONTHLY_PRICE_ID and/or STRIPE_YEARLY_PRICE_ID.'
        )
    if config.pricing_mode not in _PRICING_MODES:
        errors.append(
            f"Invalid pricing_mode '{config.pricing_mode}'. "
            f"Must be one of: {', '.join(_PRICING_MODES)}"
        )
    if '{plan_type}' not in config.checkout_path:
        errors.append(
            f"checkout_path must contain '{{plan_type}}' placeholder. Got: '{config.checkout_path}'"
        )
    return errors

# %% ../nbs/18_utils_billing.ipynb #a0000008
@dataclass
class AccessDecision:
    """Result of a subscription access check."""
    allowed: bool
    reason: SubscriptionStatus
    redirect_to: Optional[str] = None

# %% ../nbs/18_utils_billing.ipynb #a0000010
@runtime_checkable
class PlanStore(Protocol):
    """Interface for accessing pricing plan data."""
    def get_active_plans(self) -> list: ...
    def get_plan(self, plan_id: str): ...

# %% ../nbs/18_utils_billing.ipynb #a0000011
@runtime_checkable
class SubscriptionStore(Protocol):
    """Interface for subscription persistence and webhook idempotency."""
    def get_subscription(self, tenant_id: str): ...
    def upsert_subscription(self, **fields) -> None: ...
    def has_processed_event(self, event_id: str) -> bool: ...
    def record_event(self, event_id: str, event_type: str, status: str,
                     payload_json: str = '') -> None: ...

# %% ../nbs/18_utils_billing.ipynb #a0000012
@runtime_checkable
class UserContextProvider(Protocol):
    """Interface for extracting user context from HTTP requests."""
    def get_tenant_id(self, request) -> Optional[str]: ...
    def get_user_email(self, request) -> Optional[str]: ...
    def get_stripe_customer_id(self, request) -> Optional[str]: ...

# %% ../nbs/18_utils_billing.ipynb #a0000013
@runtime_checkable
class BillingUIAdapter(Protocol):
    """Optional UI rendering callbacks for billing routes (headless-first)."""
    def render_pricing(self, plans: list, user_ctx: Optional[Dict] = None): ...
    def render_checkout_pending(self, session_id: str): ...
    def render_billing_status(self, subscription, status: SubscriptionStatus): ...

# %% ../nbs/18_utils_billing.ipynb #a0000015
class HostDBPlanStore:
    """Default PlanStore backed by HostDatabase."""
    def __init__(self, host_db):
        self._db = host_db

    def get_active_plans(self) -> list:
        try:
            plans = self._db.pricing_plans(where="is_active = TRUE")
            return sorted(plans, key=lambda p: (getattr(p, 'sort_order', 0) or 0,
                                                getattr(p, 'tier_level', 0) or 0))
        except Exception as e:
            logger.error(f"Error fetching pricing plans: {e}")
            return []

    def get_plan(self, plan_id: str):
        try:
            return self._db.pricing_plans[plan_id]
        except Exception:
            return None

# %% ../nbs/18_utils_billing.ipynb #a0000016
class HostDBSubscriptionStore:
    """Default SubscriptionStore backed by HostDatabase."""
    def __init__(self, host_db):
        self._db = host_db

    def get_subscription(self, tenant_id: str):
        try:
            subs = self._db.subscriptions(
                where="tenant_id = :tid AND payment_type = 'subscription'",
                where_args={"tid": tenant_id},
            )
            if not subs:
                return None
            return sorted(subs, key=lambda s: s.created_at or '', reverse=True)[0]
        except Exception as e:
            logger.error(f"Error getting subscription for {tenant_id}: {e}")
            return None

    def upsert_subscription(self, **fields) -> None:
        from fh_saas.db_host import Subscription, gen_id, timestamp as ts
        stripe_sub_id = fields.get('stripe_sub_id', '')
        existing = None
        try:
            results = self._db.subscriptions(
                where="stripe_sub_id = :sid", where_args={"sid": stripe_sub_id}
            )
            existing = results[0] if results else None
        except Exception:
            pass
        if existing:
            for k, v in fields.items():
                if v is not None and hasattr(existing, k):
                    setattr(existing, k, v)
            self._db.subscriptions.update(existing)
        else:
            sub = Subscription(
                id=gen_id(),
                tenant_id=fields.get('tenant_id', ''),
                stripe_sub_id=stripe_sub_id,
                stripe_cust_id=fields.get('stripe_cust_id', ''),
                plan_tier=fields.get('plan_tier', 'monthly'),
                status=fields.get('status', 'active'),
                current_period_end=fields.get('current_period_end'),
                cancel_at_period_end=fields.get('cancel_at_period_end', False),
                payment_type=fields.get('payment_type', 'subscription'),
                amount_cents=fields.get('amount_cents'),
                product_name=fields.get('product_name'),
                trial_end=fields.get('trial_end'),
                created_at=ts(),
            )
            self._db.subscriptions.insert(sub)
        self._db.commit()

    def has_processed_event(self, event_id: str) -> bool:
        try:
            rows = self._db.stripe_webhook_events(
                where="event_id = :eid", where_args={"eid": event_id}
            )
            return len(rows) > 0
        except Exception:
            return False

    def record_event(self, event_id: str, event_type: str, status: str,
                     payload_json: str = '') -> None:
        from fh_saas.db_host import StripeWebhookEvent, gen_id, timestamp as ts
        try:
            evt = StripeWebhookEvent(
                id=gen_id(),
                event_id=event_id,
                event_type=event_type,
                status=status,
                payload_json=payload_json,
                created_at=ts(),
            )
            self._db.stripe_webhook_events.insert(evt)
            self._db.commit()
        except Exception as e:
            logger.error(f"Error recording webhook event {event_id}: {e}")

# %% ../nbs/18_utils_billing.ipynb #a0000017
class RequestUserContext:
    """Default UserContextProvider reading from request.state.user."""
    def _user(self, request) -> dict:
        return getattr(request.state, 'user', {}) or {}

    def get_tenant_id(self, request) -> Optional[str]:
        u = self._user(request)
        return u.get('tenant_id') or getattr(request.state, 'tenant_id', None)

    def get_user_email(self, request) -> Optional[str]:
        return self._user(request).get('email')

    def get_stripe_customer_id(self, request) -> Optional[str]:
        return self._user(request).get('stripe_cust_id')

# %% ../nbs/18_utils_billing.ipynb #a0000019
def init_trial_on_first_login(
    tenant_id: str,
    user_email: str,
    subscription_store: SubscriptionStore,
    config: BillingConfig,
):
    """Create a trial subscription if the tenant has no subscription yet. Idempotent."""
    existing = subscription_store.get_subscription(tenant_id)
    if existing is not None:
        return None
    trial_end = (datetime.utcnow() + timedelta(days=config.trial_days)).isoformat()
    subscription_store.upsert_subscription(
        tenant_id=tenant_id,
        stripe_sub_id=f'trial_{tenant_id}',
        stripe_cust_id='',
        plan_tier='trial',
        status=SubscriptionStatus.trialing.value,
        trial_end=trial_end,
        current_period_end=trial_end,
        payment_type='subscription',
    )
    logger.info(f"Trial started for tenant {tenant_id} (ends {trial_end})")
    return subscription_store.get_subscription(tenant_id)

# %% ../nbs/18_utils_billing.ipynb #a0000020
def resolve_subscription_access(
    subscription,
    config: BillingConfig,
    now: datetime = None,
) -> AccessDecision:
    """Determine whether a user should be allowed access based on subscription state. Pure function."""
    if now is None:
        now = datetime.utcnow()
    if subscription is None:
        return AccessDecision(allowed=False, reason=SubscriptionStatus.none, redirect_to=config.pricing_path)
    status = SubscriptionStatus.normalize(getattr(subscription, 'status', 'none'))
    if status == SubscriptionStatus.active:
        return AccessDecision(allowed=True, reason=status)
    if status == SubscriptionStatus.trialing:
        trial_end = getattr(subscription, 'trial_end', None)
        if trial_end:
            try:
                if datetime.fromisoformat(trial_end) > now:
                    return AccessDecision(allowed=True, reason=status)
            except (ValueError, TypeError):
                pass
        return AccessDecision(allowed=False, reason=SubscriptionStatus.trialing, redirect_to=config.pricing_path)
    if status == SubscriptionStatus.past_due:
        period_end = getattr(subscription, 'current_period_end', None)
        if period_end:
            try:
                grace_end = datetime.fromisoformat(period_end) + timedelta(days=config.grace_period_days)
                if now < grace_end:
                    return AccessDecision(allowed=True, reason=status)
            except (ValueError, TypeError):
                pass
        return AccessDecision(allowed=False, reason=status, redirect_to=config.portal_path)
    if status == SubscriptionStatus.checkout_pending:
        return AccessDecision(allowed=False, reason=status, redirect_to=config.success_path)
    return AccessDecision(allowed=False, reason=status, redirect_to=config.pricing_path)

# %% ../nbs/18_utils_billing.ipynb #a0000021
def resolve_checkout_return(
    session_id: str,
    tenant_id: str,
    subscription_store: SubscriptionStore,
) -> SubscriptionStatus:
    """Determine subscription state after a checkout redirect."""
    sub = subscription_store.get_subscription(tenant_id)
    if sub is not None:
        normalized = SubscriptionStatus.normalize(getattr(sub, 'status', 'none'))
        if normalized in (SubscriptionStatus.active, SubscriptionStatus.trialing):
            return normalized
    return SubscriptionStatus.checkout_pending

# %% ../nbs/18_utils_billing.ipynb #a0000023
def _should_apply_status_change(
    existing_status: str,
    new_status: str,
    existing_period_end: Optional[str],
    new_period_end: Optional[str],
) -> bool:
    """Guard against out-of-order webhook events."""
    old = SubscriptionStatus.normalize(existing_status)
    new = SubscriptionStatus.normalize(new_status)
    if new == SubscriptionStatus.canceled:
        return True
    if old == SubscriptionStatus.active and new == SubscriptionStatus.trialing:
        logger.warning("Ignoring status regression active -> trialing")
        return False
    if existing_period_end and new_period_end:
        try:
            if datetime.fromisoformat(new_period_end) < datetime.fromisoformat(existing_period_end):
                logger.warning(
                    f"Ignoring stale period_end: {new_period_end} < {existing_period_end}"
                )
                return False
        except (ValueError, TypeError):
            pass
    return True

# %% ../nbs/18_utils_billing.ipynb #a0000024
def _build_default_stores(host_db=None):
    """Create default PlanStore + SubscriptionStore from HostDatabase."""
    if host_db is None:
        from fh_saas.db_host import HostDatabase
        host_db = HostDatabase.from_env()
    return HostDBPlanStore(host_db), HostDBSubscriptionStore(host_db)


def register_billing_routes(
    app,
    config: BillingConfig,
    plan_store: PlanStore = None,
    subscription_store: SubscriptionStore = None,
    user_ctx: UserContextProvider = None,
    stripe_service=None,
    ui_adapter: BillingUIAdapter = None,
    host_db=None,
):
    """Wire all billing routes onto a FastHTML app in one call."""
    errors = validate_billing_config(config)
    if errors:
        raise ValueError('Billing config errors:\n  - ' + '\n  - '.join(errors))
    if plan_store is None or subscription_store is None:
        _ps, _ss = _build_default_stores(host_db)
        plan_store = plan_store or _ps
        subscription_store = subscription_store or _ss
    if user_ctx is None:
        user_ctx = RequestUserContext()
    if stripe_service is None:
        from fh_saas.utils_stripe import StripeService
        stripe_service = StripeService(config.to_stripe_config(), host_db)

    @app.get(config.pricing_path)
    def billing_pricing(request):
        plans = plan_store.get_active_plans()
        if ui_adapter and hasattr(ui_adapter, 'render_pricing'):
            tenant_id = user_ctx.get_tenant_id(request)
            user_email = user_ctx.get_user_email(request)
            ctx = {'tenant_id': tenant_id, 'email': user_email} if tenant_id else None
            return ui_adapter.render_pricing(plans, user_ctx=ctx)
        return JSONResponse([{
            'id': getattr(p, 'id', ''),
            'name': getattr(p, 'name', ''),
            'description': getattr(p, 'description', ''),
            'amount_monthly': getattr(p, 'amount_monthly', None),
            'amount_yearly': getattr(p, 'amount_yearly', None),
            'currency': getattr(p, 'currency', 'usd'),
            'features': getattr(p, 'features', None),
            'tier_level': getattr(p, 'tier_level', 0),
        } for p in plans])

    @app.get(config.checkout_path)
    def billing_checkout(request, plan_type: str):
        tenant_id = user_ctx.get_tenant_id(request)
        user_email = user_ctx.get_user_email(request)
        if not tenant_id or not user_email:
            return Response('Authentication required', status_code=401)
        if plan_type not in ('monthly', 'yearly'):
            return Response('Invalid plan type', status_code=400)
        try:
            checkout = stripe_service.create_subscription_checkout(
                plan_type=plan_type,
                tenant_id=tenant_id,
                user_email=user_email,
            )
            return RedirectResponse(checkout.url, status_code=303)
        except Exception as e:
            logger.error(f"Checkout error: {e}")
            return Response(str(e), status_code=500)

    @app.get(config.success_path)
    def billing_success(request):
        session_id = request.query_params.get('session_id', '')
        tenant_id = user_ctx.get_tenant_id(request)
        if not tenant_id:
            return Response('Authentication required', status_code=401)
        status = resolve_checkout_return(session_id, tenant_id, subscription_store)
        if ui_adapter and hasattr(ui_adapter, 'render_checkout_pending') and status == SubscriptionStatus.checkout_pending:
            return ui_adapter.render_checkout_pending(session_id)
        if ui_adapter and hasattr(ui_adapter, 'render_billing_status') and status != SubscriptionStatus.checkout_pending:
            sub = subscription_store.get_subscription(tenant_id)
            return ui_adapter.render_billing_status(sub, status)
        return JSONResponse({'status': status.value, 'session_id': session_id})

    @app.post(config.webhook_path)
    async def billing_webhook(request):
        payload = await request.body()
        sig_header = request.headers.get('stripe-signature', '')
        event = stripe_service.verify_signature(payload, sig_header)
        if not event:
            return Response('Invalid signature', status_code=400)
        event_id = event.get('id', '')
        event_type = event.get('type', '')
        if subscription_store.has_processed_event(event_id):
            logger.info(f"Duplicate webhook event skipped: {event_id}")
            return Response('OK', status_code=200)
        data_obj = event.get('data', {}).get('object', {})
        metadata = data_obj.get('metadata', {})
        tenant_id = metadata.get('tenant_id')
        if tenant_id and event_type.startswith('customer.subscription.'):
            existing_sub = subscription_store.get_subscription(tenant_id)
            if existing_sub:
                new_status = data_obj.get('status', '')
                new_period = None
                raw_pe = data_obj.get('current_period_end')
                if raw_pe and isinstance(raw_pe, (int, float)):
                    new_period = datetime.utcfromtimestamp(raw_pe).isoformat()
                if not _should_apply_status_change(
                    existing_sub.status, new_status,
                    existing_sub.current_period_end, new_period,
                ):
                    subscription_store.record_event(event_id, event_type, 'skipped_out_of_order')
                    return Response('OK', status_code=200)
        result = stripe_service.handle_event(event)
        evt_status = 'processed' if result.get('status') != 'error' else 'failed'
        subscription_store.record_event(
            event_id, event_type, evt_status,
            json.dumps({'result': result}),
        )
        if result.get('status') == 'error':
            return Response(result['message'], status_code=500)
        return Response('OK', status_code=200)

    @app.get(config.portal_path)
    def billing_portal(request):
        tenant_id = user_ctx.get_tenant_id(request)
        if not tenant_id:
            return Response('Authentication required', status_code=401)
        sub = subscription_store.get_subscription(tenant_id)
        cust_id = getattr(sub, 'stripe_cust_id', None) if sub else None
        if not cust_id:
            return Response('No subscription found', status_code=404)
        try:
            portal = stripe_service.create_customer_portal_session(cust_id)
            return RedirectResponse(portal.url, status_code=303)
        except Exception as e:
            logger.error(f"Portal error: {e}")
            return Response(str(e), status_code=500)

    logger.info(
        f"Billing routes registered: {config.pricing_path}, {config.checkout_path}, "
        f"{config.success_path}, {config.webhook_path}, {config.portal_path}"
    )

# %% ../nbs/18_utils_billing.ipynb #a0000025
def attach_billing_auth_hooks(
    app,
    config: BillingConfig,
    subscription_store: SubscriptionStore = None,
    user_ctx: UserContextProvider = None,
    host_db=None,
    skip_paths: List[str] = None,
):
    """Attach first-login trial init and paywall guard as app middleware."""
    if subscription_store is None:
        _, subscription_store = _build_default_stores(host_db)
    if user_ctx is None:
        user_ctx = RequestUserContext()
    billing_paths = [
        config.pricing_path, config.checkout_path.split('{')[0],
        config.success_path, config.webhook_path, config.portal_path,
    ]
    excluded = set(billing_paths + (skip_paths or []))

    def _billing_beforeware(request):
        path = request.url.path
        if any(path.startswith(p) for p in excluded):
            return
        tenant_id = user_ctx.get_tenant_id(request)
        if not tenant_id:
            return
        user_email = user_ctx.get_user_email(request) or ''
        init_trial_on_first_login(tenant_id, user_email, subscription_store, config)
        sub = subscription_store.get_subscription(tenant_id)
        decision = resolve_subscription_access(sub, config)
        if not decision.allowed and decision.redirect_to:
            return RedirectResponse(decision.redirect_to, status_code=303)

    try:
        from starlette.middleware.base import BaseHTTPMiddleware

        class BillingMiddleware(BaseHTTPMiddleware):
            async def dispatch(self, request, call_next):
                result = _billing_beforeware(request)
                if result is not None:
                    return result
                return await call_next(request)

        app.add_middleware(BillingMiddleware)
        logger.info("Billing auth hooks attached as middleware")
    except Exception as e:
        logger.warning(f"Could not attach billing middleware: {e}")
        app._billing_beforeware = _billing_beforeware
