//! Ask Ash AI credits wallet + ledger.
//!
//! Phase 1 of docs/ASK_ASH_BILLING_ARCHITECTURE.md (nxtgauge-ai-assistant
//! repo). Mirrors the reserve/capture/release + row-locked-transaction
//! pattern already proven by `tracecoin_wallet.rs` for TraceCoins -- see
//! that file for the pattern this one is deliberately consistent with.
//! This is a completely separate currency/schema from TraceCoins; nothing
//! here should ever read or write `tracecoin_wallets`/`tracecoin_ledger`.

use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool, Postgres, Transaction};
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct AiWallet {
    pub id: Uuid,
    pub user_id: Uuid,
    pub plan_id: Uuid,
    pub role_code: Option<String>,
    pub monthly_credits_total: i32,
    pub monthly_credits_used: i32,
    pub purchased_credits_total: i32,
    pub purchased_credits_used: i32,
    pub bonus_credits_total: i32,
    pub bonus_credits_used: i32,
    pub reserved_credits: i32,
    pub locked_credits: i32,
    pub lifetime_purchased_credits: i32,
    pub lifetime_used_credits: i32,
    pub daily_actions_used: i32,
    pub daily_credits_used: i32,
    pub daily_usage_date: NaiveDate,
    pub purchased_credits_expire_at: Option<DateTime<Utc>>,
    pub billing_cycle: String,
    pub auto_renew: bool,
    pub current_period_start: DateTime<Utc>,
    pub current_period_end: DateTime<Utc>,
    pub status: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl AiWallet {
    /// Credits actually spendable right now. Reserved/locked credits are
    /// excluded even though they're still "owned" -- see Section 4.1 of
    /// the architecture doc for the full pool breakdown.
    pub fn available_credits(&self) -> i32 {
        let owned = (self.monthly_credits_total - self.monthly_credits_used)
            + (self.purchased_credits_total - self.purchased_credits_used)
            + (self.bonus_credits_total - self.bonus_credits_used);
        (owned - self.reserved_credits - self.locked_credits).max(0)
    }
}

#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct AiPlan {
    pub id: Uuid,
    pub code: String,
    pub name: String,
    pub monthly_credits: i32,
    pub daily_action_limit: i32,
    pub daily_credit_limit: Option<i32>,
    pub allowed_models: serde_json::Value,
    pub allowed_features: serde_json::Value,
    pub is_active: bool,
}

#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct AiFeatureCost {
    pub id: Uuid,
    pub feature_code: String,
    pub display_name: String,
    pub default_model: String,
    pub credit_cost: i32,
    pub max_input_tokens: Option<i32>,
    pub max_output_tokens: Option<i32>,
    pub min_plan_code: Option<String>,
    pub timeout_ms: i32,
    pub fallback_model: Option<String>,
    pub is_active: bool,
}

#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct AiReservationHold {
    pub id: Uuid,
    pub wallet_id: Uuid,
    pub credits_held: i32,
    pub feature_code: String,
    pub status: String,
}

#[derive(Debug, thiserror::Error)]
pub enum AiCreditsError {
    #[error("Database error: {0}")]
    Db(#[from] sqlx::Error),
    #[error("Unknown AI feature '{0}'")]
    UnknownFeature(String),
    #[error("Insufficient AI credits")]
    InsufficientCredits,
    #[error("Daily AI action limit reached")]
    DailyActionLimitReached,
    #[error("Daily AI credit limit reached")]
    DailyCreditLimitReached,
    #[error("Reservation not found or already resolved")]
    ReservationNotFound,
    #[error("Invalid amount: {0}")]
    InvalidAmount(String),
}

pub struct AiCreditsRepository;

impl AiCreditsRepository {
    /// Get-or-create the wallet for a user, defaulting to the `free` plan.
    /// Uses the same `INSERT ... ON CONFLICT DO NOTHING` + re-fetch shape
    /// as `TracecoinWalletRepository::ensure_wallet` so a race between two
    /// concurrent first-time callers can't surface a raw duplicate-key
    /// error to either caller.
    pub async fn ensure_wallet(pool: &PgPool, user_id: Uuid) -> Result<AiWallet, sqlx::Error> {
        if let Some(wallet) = Self::get_wallet(pool, user_id).await? {
            return Ok(wallet);
        }

        let free_plan: (Uuid, i32) = sqlx::query_as(
            "SELECT id, monthly_credits FROM ai_plans WHERE code = 'free' AND is_active = TRUE",
        )
        .fetch_one(pool)
        .await?;

        sqlx::query(
            r#"
            INSERT INTO user_ai_subscriptions
                (user_id, plan_id, monthly_credits_total, current_period_start, current_period_end)
            VALUES ($1, $2, $3, NOW(), NOW() + INTERVAL '30 days')
            ON CONFLICT (user_id) DO NOTHING
            "#,
        )
        .bind(user_id)
        .bind(free_plan.0)
        .bind(free_plan.1)
        .execute(pool)
        .await?;

        Self::get_wallet(pool, user_id)
            .await?
            .ok_or_else(|| sqlx::Error::RowNotFound)
    }

    pub async fn get_wallet(pool: &PgPool, user_id: Uuid) -> Result<Option<AiWallet>, sqlx::Error> {
        sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE user_id = $1")
            .bind(user_id)
            .fetch_optional(pool)
            .await
    }

    pub async fn get_plan(pool: &PgPool, plan_id: Uuid) -> Result<Option<AiPlan>, sqlx::Error> {
        sqlx::query_as::<_, AiPlan>("SELECT id, code, name, monthly_credits, daily_action_limit, daily_credit_limit, allowed_models, allowed_features, is_active FROM ai_plans WHERE id = $1")
            .bind(plan_id)
            .fetch_optional(pool)
            .await
    }

    /// Credit purchased credits to a user's wallet (e.g. after a verified
    /// payment). Row-locked and idempotency-keyed like every other wallet
    /// mutation in this file -- a retried/replayed verify callback with
    /// the same `idempotency_key` is a no-op, not a double-credit.
    /// Returns `false` (no-op) if the idempotency key was already used.
    pub async fn add_purchased_credits(
        pool: &PgPool,
        user_id: Uuid,
        credits: i32,
        idempotency_key: &str,
        reference_type: &str,
        reference_id: Option<Uuid>,
    ) -> Result<bool, sqlx::Error> {
        let mut tx = pool.begin().await?;

        if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM ai_credit_ledger WHERE idempotency_key = $1")
            .bind(idempotency_key)
            .fetch_one(&mut *tx)
            .await?
            > 0
        {
            tx.rollback().await?;
            return Ok(false);
        }

        let wallet = sqlx::query_as::<_, AiWallet>(
            "SELECT * FROM user_ai_subscriptions WHERE user_id = $1 FOR UPDATE",
        )
        .bind(user_id)
        .fetch_one(&mut *tx)
        .await?;

        sqlx::query(
            r#"
            UPDATE user_ai_subscriptions
            SET purchased_credits_total = purchased_credits_total + $1,
                lifetime_purchased_credits = lifetime_purchased_credits + $1,
                updated_at = NOW()
            WHERE id = $2
            "#,
        )
        .bind(credits)
        .bind(wallet.id)
        .execute(&mut *tx)
        .await?;

        let balance_after = wallet.available_credits() + credits;
        sqlx::query(
            r#"
            INSERT INTO ai_credit_ledger
                (wallet_id, entry_type, credits, balance_after, idempotency_key, reference_type, reference_id, actor_type)
            VALUES ($1, 'purchase', $2, $3, $4, $5, $6, 'system')
            "#,
        )
        .bind(wallet.id)
        .bind(credits)
        .bind(balance_after)
        .bind(idempotency_key)
        .bind(reference_type)
        .bind(reference_id)
        .execute(&mut *tx)
        .await?;

        tx.commit().await?;
        Ok(true)
    }

    /// Admin-issued credit adjustment (e.g. goodwill/compensation credit,
    /// or a manual correction) -- mirrors the shape of the existing
    /// TraceCoins admin adjust endpoint (`POST /api/admin/credits/adjust`,
    /// {user_id, amount, type: ADD|DEDUCT, reason, reference_id?}) so the
    /// admin UI can reuse the same form pattern for both currencies.
    /// `reason` is mandatory -- every admin-issued credit change must be
    /// explained, both for audit and to guard against casual misuse.
    /// ADD grants bonus credits (not "purchased" -- no money changed
    /// hands); DEDUCT consumes bonus first, then monthly, then purchased,
    /// same order as `try_capture_reservation`.
    pub async fn admin_adjust_credits(
        pool: &PgPool,
        user_id: Uuid,
        amount: i32,
        is_add: bool,
        reason: &str,
        actor_id: Uuid,
        idempotency_key: Option<&str>,
    ) -> Result<AiWallet, AiCreditsError> {
        if amount <= 0 {
            return Err(AiCreditsError::InvalidAmount("amount must be positive".to_string()));
        }

        let mut tx = pool.begin().await?;

        if let Some(key) = idempotency_key {
            if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM ai_credit_ledger WHERE idempotency_key = $1")
                .bind(key)
                .fetch_one(&mut *tx)
                .await?
                > 0
            {
                let wallet = sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE user_id = $1")
                    .bind(user_id)
                    .fetch_one(&mut *tx)
                    .await?;
                tx.rollback().await?;
                return Ok(wallet);
            }
        }

        let wallet = sqlx::query_as::<_, AiWallet>(
            "SELECT * FROM user_ai_subscriptions WHERE user_id = $1 FOR UPDATE",
        )
        .bind(user_id)
        .fetch_one(&mut *tx)
        .await?;

        let (entry_type, balance_after) = if is_add {
            sqlx::query(
                "UPDATE user_ai_subscriptions SET bonus_credits_total = bonus_credits_total + $1, updated_at = NOW() WHERE id = $2",
            )
            .bind(amount)
            .bind(wallet.id)
            .execute(&mut *tx)
            .await?;
            ("admin_adjustment_credit", wallet.available_credits() + amount)
        } else {
            if wallet.available_credits() < amount {
                tx.rollback().await?;
                return Err(AiCreditsError::InsufficientCredits);
            }

            let bonus_available = wallet.bonus_credits_total - wallet.bonus_credits_used;
            let from_bonus = amount.min(bonus_available.max(0));
            let remaining = amount - from_bonus;
            let monthly_available = wallet.monthly_credits_total - wallet.monthly_credits_used;
            let from_monthly = remaining.min(monthly_available.max(0));
            let from_purchased = remaining - from_monthly;

            sqlx::query(
                r#"
                UPDATE user_ai_subscriptions
                SET bonus_credits_used = bonus_credits_used + $1,
                    monthly_credits_used = monthly_credits_used + $2,
                    purchased_credits_used = purchased_credits_used + $3,
                    lifetime_used_credits = lifetime_used_credits + $4,
                    updated_at = NOW()
                WHERE id = $5
                "#,
            )
            .bind(from_bonus)
            .bind(from_monthly)
            .bind(from_purchased)
            .bind(amount)
            .bind(wallet.id)
            .execute(&mut *tx)
            .await?;
            ("admin_adjustment_debit", wallet.available_credits() - amount)
        };

        sqlx::query(
            r#"
            INSERT INTO ai_credit_ledger
                (wallet_id, entry_type, credits, balance_after, idempotency_key, reference_type, actor_type, actor_id, metadata)
            VALUES ($1, $2, $3, $4, $5, 'admin_action', 'admin', $6, jsonb_build_object('reason', $7::text))
            "#,
        )
        .bind(wallet.id)
        .bind(entry_type)
        .bind(if is_add { amount } else { -amount })
        .bind(balance_after)
        .bind(idempotency_key)
        .bind(actor_id)
        .bind(reason)
        .execute(&mut *tx)
        .await?;

        tx.commit().await?;

        sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE id = $1")
            .bind(wallet.id)
            .fetch_one(pool)
            .await
            .map_err(AiCreditsError::Db)
    }

    pub async fn get_feature_cost(
        pool: &PgPool,
        feature_code: &str,
    ) -> Result<Option<AiFeatureCost>, sqlx::Error> {
        sqlx::query_as::<_, AiFeatureCost>(
            r#"
            SELECT id, feature_code, display_name, default_model, credit_cost,
                   max_input_tokens, max_output_tokens, min_plan_code, timeout_ms,
                   fallback_model, is_active
            FROM ai_feature_costs
            WHERE feature_code = $1 AND is_active = TRUE
            "#,
        )
        .bind(feature_code)
        .fetch_optional(pool)
        .await
    }

    /// Roll `daily_actions_used`/`daily_credits_used` over to zero if the
    /// wallet's stored usage date isn't today. Called inline, inside the
    /// same locked transaction as a reserve, rather than depending on a
    /// separate cron job -- there is no reset job in this codebase yet
    /// (Section 20 Phase 2 of the architecture doc), and an inline reset
    /// is strictly safer than shipping enforcement that depends on a job
    /// that doesn't exist.
    async fn reset_daily_counters_if_needed(
        tx: &mut Transaction<'_, Postgres>,
        wallet_id: Uuid,
        stored_date: NaiveDate,
    ) -> Result<(), sqlx::Error> {
        let today = Utc::now().date_naive();
        if stored_date < today {
            sqlx::query(
                r#"
                UPDATE user_ai_subscriptions
                SET daily_actions_used = 0, daily_credits_used = 0, daily_usage_date = $2
                WHERE id = $1
                "#,
            )
            .bind(wallet_id)
            .bind(today)
            .execute(&mut **tx)
            .await?;
        }
        Ok(())
    }

    /// Atomically reserve `credits` against a user's wallet for a feature
    /// call. Mirrors `TracecoinWalletRepository::try_reserve_tracecoins`:
    /// `pool.begin()` -> `SELECT ... FOR UPDATE` -> check -> mutate ->
    /// ledger insert -> commit (or rollback on any failed check). Callers
    /// must follow up with `try_capture_reservation` on success or
    /// `try_release_reservation` on failure -- never leave a hold
    /// unresolved (a background reaper for abandoned holds is a Phase 1
    /// follow-up, not yet implemented; `expires_at` is written now so that
    /// reaper has something to key off of once it exists).
    #[allow(clippy::too_many_arguments)]
    pub async fn try_reserve_credits(
        pool: &PgPool,
        user_id: Uuid,
        feature_code: &str,
        credits: i32,
        request_id: Option<&str>,
        idempotency_key: Option<&str>,
    ) -> Result<AiReservationHold, AiCreditsError> {
        // Ensure the wallet exists before opening the locked transaction --
        // ensure_wallet does its own get-or-create round trip and would
        // deadlock with itself if run inside the same FOR UPDATE tx.
        Self::ensure_wallet(pool, user_id).await?;

        let mut tx = pool.begin().await?;

        // Idempotency: a retried request with the same key returns the
        // existing hold instead of reserving twice.
        if let Some(key) = idempotency_key {
            if let Some(existing) = sqlx::query_as::<_, AiReservationHold>(
                "SELECT id, wallet_id, credits_held, feature_code, status FROM ai_reservation_holds WHERE idempotency_key = $1",
            )
            .bind(key)
            .fetch_optional(&mut *tx)
            .await?
            {
                tx.rollback().await?;
                return Ok(existing);
            }
        }

        let wallet = sqlx::query_as::<_, AiWallet>(
            "SELECT * FROM user_ai_subscriptions WHERE user_id = $1 FOR UPDATE",
        )
        .bind(user_id)
        .fetch_one(&mut *tx)
        .await?;

        Self::reset_daily_counters_if_needed(&mut tx, wallet.id, wallet.daily_usage_date).await?;

        // Re-read after the possible reset so the checks below see fresh
        // counters (cheap: still inside the same row lock).
        let wallet = sqlx::query_as::<_, AiWallet>(
            "SELECT * FROM user_ai_subscriptions WHERE id = $1 FOR UPDATE",
        )
        .bind(wallet.id)
        .fetch_one(&mut *tx)
        .await?;

        let plan = sqlx::query_as::<_, AiPlan>(
            "SELECT id, code, name, monthly_credits, daily_action_limit, daily_credit_limit, allowed_models, allowed_features, is_active FROM ai_plans WHERE id = $1",
        )
        .bind(wallet.plan_id)
        .fetch_one(&mut *tx)
        .await?;

        if wallet.daily_actions_used >= plan.daily_action_limit {
            tx.rollback().await?;
            return Err(AiCreditsError::DailyActionLimitReached);
        }

        if let Some(daily_credit_limit) = plan.daily_credit_limit {
            if wallet.daily_credits_used + credits > daily_credit_limit {
                tx.rollback().await?;
                return Err(AiCreditsError::DailyCreditLimitReached);
            }
        }

        if wallet.available_credits() < credits {
            tx.rollback().await?;
            return Err(AiCreditsError::InsufficientCredits);
        }

        sqlx::query(
            r#"
            UPDATE user_ai_subscriptions
            SET reserved_credits = reserved_credits + $1,
                daily_actions_used = daily_actions_used + 1,
                daily_credits_used = daily_credits_used + $1,
                updated_at = NOW()
            WHERE id = $2
            "#,
        )
        .bind(credits)
        .bind(wallet.id)
        .execute(&mut *tx)
        .await?;

        let hold_id: Uuid = sqlx::query_scalar(
            r#"
            INSERT INTO ai_reservation_holds
                (wallet_id, credits_held, feature_code, request_id, idempotency_key, status)
            VALUES ($1, $2, $3, $4, $5, 'held')
            RETURNING id
            "#,
        )
        .bind(wallet.id)
        .bind(credits)
        .bind(feature_code)
        .bind(request_id)
        .bind(idempotency_key)
        .fetch_one(&mut *tx)
        .await?;

        let balance_after = wallet.available_credits() - credits;
        sqlx::query(
            r#"
            INSERT INTO ai_credit_ledger
                (wallet_id, entry_type, credits, balance_after, reference_type, reference_id, actor_type)
            VALUES ($1, 'reservation_hold', $2, $3, 'reservation', $4, 'user')
            "#,
        )
        .bind(wallet.id)
        .bind(-credits)
        .bind(balance_after)
        .bind(hold_id)
        .execute(&mut *tx)
        .await?;

        tx.commit().await?;

        Ok(AiReservationHold {
            id: hold_id,
            wallet_id: wallet.id,
            credits_held: credits,
            feature_code: feature_code.to_string(),
            status: "held".to_string(),
        })
    }

    /// Convert a hold into an actual charge. `actual_credits` lets a
    /// caller capture less than was held (e.g. a streaming response that
    /// used fewer tokens than the reservation's ceiling); the difference
    /// is simply never moved into a `_used` column, which makes it
    /// available again with no separate "release remainder" step needed.
    /// Consumption order: bonus credits first, then monthly, then
    /// purchased -- see Section 4.1 of the architecture doc.
    pub async fn try_capture_reservation(
        pool: &PgPool,
        hold_id: Uuid,
        actual_credits: Option<i32>,
    ) -> Result<bool, AiCreditsError> {
        let mut tx = pool.begin().await?;

        let hold = sqlx::query_as::<_, AiReservationHold>(
            "SELECT id, wallet_id, credits_held, feature_code, status FROM ai_reservation_holds WHERE id = $1 AND status = 'held' FOR UPDATE",
        )
        .bind(hold_id)
        .fetch_optional(&mut *tx)
        .await?;

        let Some(hold) = hold else {
            tx.rollback().await?;
            return Ok(false);
        };

        let captured = actual_credits.unwrap_or(hold.credits_held).min(hold.credits_held).max(0);

        let wallet = sqlx::query_as::<_, AiWallet>(
            "SELECT * FROM user_ai_subscriptions WHERE id = $1 FOR UPDATE",
        )
        .bind(hold.wallet_id)
        .fetch_one(&mut *tx)
        .await?;

        let bonus_available = wallet.bonus_credits_total - wallet.bonus_credits_used;
        let from_bonus = captured.min(bonus_available.max(0));
        let remaining = captured - from_bonus;

        let monthly_available = wallet.monthly_credits_total - wallet.monthly_credits_used;
        let from_monthly = remaining.min(monthly_available.max(0));
        let from_purchased = remaining - from_monthly;

        sqlx::query(
            r#"
            UPDATE user_ai_subscriptions
            SET reserved_credits = reserved_credits - $1,
                bonus_credits_used = bonus_credits_used + $2,
                monthly_credits_used = monthly_credits_used + $3,
                purchased_credits_used = purchased_credits_used + $4,
                lifetime_used_credits = lifetime_used_credits + $5,
                updated_at = NOW()
            WHERE id = $6
            "#,
        )
        .bind(hold.credits_held)
        .bind(from_bonus)
        .bind(from_monthly)
        .bind(from_purchased)
        .bind(captured)
        .bind(wallet.id)
        .execute(&mut *tx)
        .await?;

        let balance_after = wallet.available_credits() + (hold.credits_held - captured);
        sqlx::query(
            r#"
            INSERT INTO ai_credit_ledger
                (wallet_id, entry_type, credits, balance_after, reference_type, reference_id, actor_type)
            VALUES ($1, 'reservation_capture', $2, $3, 'reservation', $4, 'system')
            "#,
        )
        .bind(wallet.id)
        .bind(-captured)
        .bind(balance_after)
        .bind(hold.id)
        .execute(&mut *tx)
        .await?;

        sqlx::query("UPDATE ai_reservation_holds SET status = 'captured', resolved_at = NOW() WHERE id = $1")
            .bind(hold.id)
            .execute(&mut *tx)
            .await?;

        tx.commit().await?;
        Ok(true)
    }

    /// Release a hold without charging -- the LLM call failed, timed out,
    /// or a downstream validation error occurred after the reserve.
    pub async fn try_release_reservation(pool: &PgPool, hold_id: Uuid) -> Result<bool, AiCreditsError> {
        let mut tx = pool.begin().await?;

        let hold = sqlx::query_as::<_, AiReservationHold>(
            "SELECT id, wallet_id, credits_held, feature_code, status FROM ai_reservation_holds WHERE id = $1 AND status = 'held' FOR UPDATE",
        )
        .bind(hold_id)
        .fetch_optional(&mut *tx)
        .await?;

        let Some(hold) = hold else {
            tx.rollback().await?;
            return Ok(false);
        };

        let wallet = sqlx::query_as::<_, AiWallet>(
            "SELECT * FROM user_ai_subscriptions WHERE id = $1 FOR UPDATE",
        )
        .bind(hold.wallet_id)
        .fetch_one(&mut *tx)
        .await?;

        // Roll back both the reservation itself AND the daily-action/
        // daily-credit counters bumped when it was created (try_reserve_credits)
        // -- otherwise a request that fails after reserving (e.g. the LLM
        // call errors) still permanently burns the user's daily quota even
        // though they were never charged.
        sqlx::query(
            r#"
            UPDATE user_ai_subscriptions
            SET reserved_credits = reserved_credits - $1,
                daily_actions_used = GREATEST(daily_actions_used - 1, 0),
                daily_credits_used = GREATEST(daily_credits_used - $1, 0),
                updated_at = NOW()
            WHERE id = $2
            "#,
        )
        .bind(hold.credits_held)
        .bind(wallet.id)
        .execute(&mut *tx)
        .await?;

        let balance_after = wallet.available_credits() + hold.credits_held;
        sqlx::query(
            r#"
            INSERT INTO ai_credit_ledger
                (wallet_id, entry_type, credits, balance_after, reference_type, reference_id, actor_type)
            VALUES ($1, 'reservation_release', $2, $3, 'reservation', $4, 'system')
            "#,
        )
        .bind(wallet.id)
        .bind(hold.credits_held)
        .bind(balance_after)
        .bind(hold.id)
        .execute(&mut *tx)
        .await?;

        sqlx::query("UPDATE ai_reservation_holds SET status = 'released', resolved_at = NOW() WHERE id = $1")
            .bind(hold.id)
            .execute(&mut *tx)
            .await?;

        tx.commit().await?;
        Ok(true)
    }

    /// Process a refund for a completed AI credit charge.
    /// 
    /// This creates a new ledger entry with entry_type='refund' and credits back
    /// the specified amount to the user's wallet. The refund is tied to the
    /// original debit ledger entry for audit purposes.
    /// 
    /// # Arguments
    /// * `pool` - Database connection pool
    /// * `user_id` - The user receiving the refund
    /// * `ledger_debit_entry_id` - The original debit ledger entry being refunded
    /// * `credits_to_refund` - Amount of credits to refund
    /// * `reason` - One of: provider_failure, timeout, validation_failure, manual, user_dispute
    /// * `initiated_by` - Who initiated: system, admin, user_dispute
    /// * `actor_id` - The admin/user who approved the refund (if applicable)
    /// * `idempotency_key` - Optional idempotency key
    /// * `notes` - Optional notes about the refund
    /// 
    /// # Returns
    /// * `Ok((refund_id, AiWallet))` - The refund record ID and updated wallet
    /// * `Err(AiCreditsError)` - If the refund cannot be processed
    #[allow(clippy::too_many_arguments)]
    pub async fn refund_credits(
        pool: &PgPool,
        user_id: Uuid,
        ledger_debit_entry_id: Uuid,
        credits_to_refund: i32,
        reason: &str,
        initiated_by: &str,
        actor_id: Option<Uuid>,
        idempotency_key: Option<&str>,
        notes: Option<&str>,
    ) -> Result<(Uuid, AiWallet), AiCreditsError> {
        if credits_to_refund <= 0 {
            return Err(AiCreditsError::InvalidAmount("credits_to_refund must be positive".to_string()));
        }

        let mut tx = pool.begin().await?;

        // Idempotency check
        if let Some(key) = idempotency_key {
            if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM ai_refunds WHERE idempotency_key = $1")
                .bind(key)
                .fetch_one(&mut *tx)
                .await? > 0
            {
                let refund_id: Uuid = sqlx::query_scalar("SELECT id FROM ai_refunds WHERE idempotency_key = $1")
                    .bind(key)
                    .fetch_one(&mut *tx)
                    .await?;
                let wallet = sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE user_id = $1")
                    .bind(user_id)
                    .fetch_one(&mut *tx)
                    .await?;
                tx.rollback().await?;
                return Ok((refund_id, wallet));
            }
        }

        // Verify the original debit entry exists and is a charge
        let debit_entry: Option<(Uuid, i32, String)> = sqlx::query_as(
            "SELECT id, credits, entry_type FROM ai_credit_ledger WHERE id = $1"
        )
        .bind(ledger_debit_entry_id)
        .fetch_optional(&mut *tx)
        .await?;

        let Some((_, debit_credits, _entry_type)) = debit_entry else {
            tx.rollback().await?;
            return Err(AiCreditsError::ReservationNotFound);
        };

        // Ensure it's actually a debit (negative credits)
        if debit_credits >= 0 {
            tx.rollback().await?;
            return Err(AiCreditsError::InvalidAmount("ledger entry is not a debit".to_string()));
        }

        // Get wallet and lock it
        let wallet = sqlx::query_as::<_, AiWallet>(
            "SELECT * FROM user_ai_subscriptions WHERE user_id = $1 FOR UPDATE",
        )
        .bind(user_id)
        .fetch_one(&mut *tx)
        .await?;

        // Refund as bonus credits (goodwill/compensation)
        sqlx::query(
            "UPDATE user_ai_subscriptions SET bonus_credits_total = bonus_credits_total + $1, updated_at = NOW() WHERE id = $2",
        )
        .bind(credits_to_refund)
        .bind(wallet.id)
        .execute(&mut *tx)
        .await?;

        // Create refund record
        let refund_id: Uuid = sqlx::query_scalar(
            r#"
            INSERT INTO ai_refunds
                (user_id, ledger_debit_entry_id, credits_refunded, reason, initiated_by, status, notes, admin_actor_id, idempotency_key, resolved_at)
            VALUES ($1, $2, $3, $4, $5, 'completed', $6, $7, $8, NOW())
            RETURNING id
            "#,
        )
        .bind(user_id)
        .bind(ledger_debit_entry_id)
        .bind(credits_to_refund)
        .bind(reason)
        .bind(initiated_by)
        .bind(notes)
        .bind(actor_id)
        .bind(idempotency_key)
        .fetch_one(&mut *tx)
        .await?;

        // Create ledger entry for the refund
        let balance_after = wallet.available_credits() + credits_to_refund;
        sqlx::query(
            r#"
            INSERT INTO ai_credit_ledger
                (wallet_id, entry_type, credits, balance_after, reference_type, reference_id, actor_type, actor_id, metadata)
            VALUES ($1, 'refund', $2, $3, 'refund', $4, 'system', $5, jsonb_build_object('reason', $6, 'refund_id', $7))
            "#,
        )
        .bind(wallet.id)
        .bind(credits_to_refund) // Positive for credit
        .bind(balance_after)
        .bind(ledger_debit_entry_id)
        .bind(actor_id)
        .bind(reason)
        .bind(refund_id)
        .execute(&mut *tx)
        .await?;

        tx.commit().await?;

        // Return updated wallet
        let updated_wallet = sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE id = $1")
            .bind(wallet.id)
            .fetch_one(pool)
            .await
            .map_err(AiCreditsError::Db)?;

        Ok((refund_id, updated_wallet))
    }

    /// List refunds for a user (paginated)
    pub async fn list_refunds(
        pool: &PgPool,
        user_id: Uuid,
        page: i64,
        limit: i64,
    ) -> Result<Vec<RefundRow>, sqlx::Error> {
        let offset = (page - 1) * limit;
        sqlx::query_as::<_, RefundRow>(
            r#"
            SELECT 
                r.id,
                r.user_id,
                r.ledger_debit_entry_id,
                r.credits_refunded,
                r.reason,
                r.initiated_by,
                r.status,
                r.notes,
                r.admin_actor_id,
                r.created_at,
                r.resolved_at,
                l.entry_type as original_entry_type,
                l.credits as original_credits
            FROM ai_refunds r
            JOIN ai_credit_ledger l ON r.ledger_debit_entry_id = l.id
            WHERE r.user_id = $1
            ORDER BY r.created_at DESC
            LIMIT $2 OFFSET $3
            "#,
        )
        .bind(user_id)
        .bind(limit)
        .bind(offset)
        .fetch_all(pool)
        .await
    }
}

#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct RefundRow {
    pub id: Uuid,
    pub user_id: Uuid,
    pub ledger_debit_entry_id: Uuid,
    pub credits_refunded: i32,
    pub reason: String,
    pub initiated_by: String,
    pub status: String,
    pub notes: Option<String>,
    pub admin_actor_id: Option<Uuid>,
    pub created_at: DateTime<Utc>,
    pub resolved_at: Option<DateTime<Utc>>,
    pub original_entry_type: String,
    pub original_credits: i32,
}
