import {EmailSourceType} from '@plunk/db';
import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types';
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';

import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';

/**
 * Billing Limit Service
 * Handles usage tracking and enforcement of billing limits per email category
 *
 * FREE TIER LIMITS:
 * - Free tier projects (billing enabled, no subscription) have a total limit of 1000 emails/month
 * - This limit is shared across all email types (workflows + campaigns + transactional)
 * - Paid tier projects (with subscription) can have custom per-category limits or unlimited
 *
 * PERFORMANCE CONSIDERATIONS:
 * - Operates at scale with 1M+ contacts/month (potentially millions of emails)
 * - Uses Redis caching (5-min TTL) to avoid expensive DB queries on every email send
 * - Composite index on (projectId, sourceType, createdAt) enables fast filtered counts
 * - Graceful degradation: cache misses fall back to DB without blocking
 * - Non-blocking: errors are logged but don't prevent email sending
 */
export class BillingLimitService {
  private static readonly CACHE_TTL = 300; // 5 minutes
  private static readonly WARNING_THRESHOLD = 0.8; // 80%
  private static readonly FREE_TIER_TOTAL_LIMIT = 1000; // Total emails per month for free tier projects

  /**
   * Get total usage count across all email categories
   * Used for free tier total limit enforcement
   *
   * @param projectId - Project ID
   * @returns Total email count for the calendar month (all types combined)
   */
  public static async getTotalUsage(projectId: string): Promise<number> {
    const {start, end} = this.getCurrentMonthRange();

    try {
      const count = await prisma.email.count({
        where: {
          projectId,
          createdAt: {
            gte: start,
            lt: end,
          },
        },
      });

      return count;
    } catch (error) {
      signale.error(`[BILLING_LIMIT] Failed to query total usage for ${projectId}:`, error);
      return 0; // Return 0 on error to avoid blocking
    }
  }

  /**
   * Get current usage count for a specific email category
   * Uses Redis cache with 5-minute TTL to minimize DB queries
   *
   * @param projectId - Project ID
   * @param sourceType - Email category (TRANSACTIONAL, CAMPAIGN, WORKFLOW)
   * @returns Current usage count for the calendar month
   */
  public static async getUsage(projectId: string, sourceType: EmailSourceType): Promise<number> {
    const cacheKey = this.getCacheKey(projectId, sourceType);

    try {
      // Try to get from cache first
      const cached = await redis.get(cacheKey);
      if (cached !== null) {
        return parseInt(cached, 10);
      }
    } catch (error) {
      signale.warn(`[BILLING_LIMIT] Cache read failed for ${cacheKey}:`, error);
      // Continue to DB query on cache failure
    }

    // Cache miss - query database
    const {start, end} = this.getCurrentMonthRange();

    try {
      const count = await prisma.email.count({
        where: {
          projectId,
          sourceType,
          createdAt: {
            gte: start,
            lt: end,
          },
        },
      });

      // Cache the result
      try {
        await redis.setex(cacheKey, this.CACHE_TTL, count.toString());
      } catch (error) {
        signale.warn(`[BILLING_LIMIT] Failed to cache usage for ${cacheKey}:`, error);
      }

      return count;
    } catch (error) {
      signale.error(`[BILLING_LIMIT] Failed to query usage for ${projectId}/${sourceType}:`, error);
      return 0; // Return 0 on error to avoid blocking
    }
  }

  /**
   * Increment usage counter in cache (called after successful email send)
   * This keeps the cache accurate without requiring frequent DB queries
   *
   * @param projectId - Project ID
   * @param sourceType - Email category
   */
  public static async incrementUsage(projectId: string, sourceType: EmailSourceType): Promise<void> {
    const cacheKey = this.getCacheKey(projectId, sourceType);

    try {
      const exists = await redis.exists(cacheKey);
      if (exists) {
        // Only increment if key exists (was previously cached)
        await redis.incr(cacheKey);
      }
      // If not cached, next getUsage call will fetch from DB
    } catch (error) {
      signale.warn(`[BILLING_LIMIT] Failed to increment usage cache for ${cacheKey}:`, error);
      // Non-blocking: continue even if cache increment fails
    }
  }

  /**
   * Check if sending an email would exceed the billing limit
   * Returns detailed result including warning status
   *
   * For free tier projects (no subscription): checks total usage across all categories against 1000/month limit
   * For paid tier projects (with subscription): checks per-category limits if set
   *
   * @param projectId - Project ID
   * @param sourceType - Email category
   * @returns LimitCheckResult with allowed/warning status
   */
  public static async checkLimit(projectId: string, sourceType: EmailSourceType): Promise<LimitCheckResult> {
    try {
      // Get project billing info
      const project = await prisma.project.findUnique({
        where: {id: projectId},
        select: {
          name: true,
          subscription: true,
          billingLimitWorkflows: true,
          billingLimitCampaigns: true,
          billingLimitTransactional: true,
          billingLimitInbound: true,
        },
      });

      if (!project) {
        signale.warn(`[BILLING_LIMIT] Project ${projectId} not found`);
        return {
          allowed: true,
          warning: false,
          usage: 0,
          limit: null,
          percentage: 0,
        };
      }

      // Determine which limit to use based on source type
      let limit: number | null;
      switch (sourceType) {
        case EmailSourceType.WORKFLOW:
          limit = project.billingLimitWorkflows;
          break;
        case EmailSourceType.CAMPAIGN:
          limit = project.billingLimitCampaigns;
          break;
        case EmailSourceType.TRANSACTIONAL:
          limit = project.billingLimitTransactional;
          break;
        case EmailSourceType.INBOUND:
          limit = project.billingLimitInbound;
          break;
        default:
          limit = null;
      }

      // Check if any custom per-category limits are set
      const hasCustomLimits =
        project.billingLimitWorkflows !== null ||
        project.billingLimitCampaigns !== null ||
        project.billingLimitTransactional !== null ||
        project.billingLimitInbound !== null;

      // Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
      // Only enforce free tier limits if billing is enabled
      if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
        const totalUsage = await this.getTotalUsage(projectId);
        const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
        const percentage = (totalUsage / freeLimit) * 100;

        // Check if blocked (at or over limit)
        if (totalUsage >= freeLimit) {
          await NtfyService.notifyBillingLimitExceeded(
            project.name,
            projectId,
            totalUsage,
            freeLimit,
            EmailSourceType.TRANSACTIONAL, // Use generic type for notification
          );

          // Send email notification
          await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');

          return {
            allowed: false,
            warning: false,
            usage: totalUsage,
            limit: freeLimit,
            percentage,
            message: `Free tier limit reached. You've sent ${totalUsage}/${freeLimit} emails this month. Upgrade to continue sending.`,
          };
        }

        // Check if warning (80% or more)
        const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
        if (isWarning) {
          await NtfyService.notifyBillingLimitApproaching(
            project.name,
            projectId,
            totalUsage,
            freeLimit,
            percentage,
            EmailSourceType.TRANSACTIONAL, // Use generic type for notification
          );

          // Send email notification (only once per month)
          await this.sendWarningEmail(
            projectId,
            project.name,
            totalUsage,
            freeLimit,
            percentage,
            'Free Tier (All Types)',
          );
        }

        return {
          allowed: true,
          warning: isWarning,
          usage: totalUsage,
          limit: freeLimit,
          percentage,
          message: isWarning
            ? `Warning: You've used ${Math.round(percentage)}% of your free tier limit (${totalUsage}/${freeLimit} emails)`
            : undefined,
        };
      }

      // If no limit set for paid tier, allow unlimited
      if (limit === null) {
        return {
          allowed: true,
          warning: false,
          usage: 0,
          limit: null,
          percentage: 0,
        };
      }

      // Get current usage
      const usage = await this.getUsage(projectId, sourceType);
      const percentage = limit > 0 ? (usage / limit) * 100 : 0;

      // Check if blocked (at or over limit)
      if (usage >= limit) {
        // Get project name for notification
        const project = await prisma.project.findUnique({
          where: {id: projectId},
          select: {name: true},
        });

        if (project) {
          // Send notification about limit exceeded
          await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);

          // Send email notification
          await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
        }

        return {
          allowed: false,
          warning: false,
          usage,
          limit,
          percentage,
          message: `Billing limit reached for ${sourceType.toLowerCase()} emails. Current usage: ${usage}/${limit} (${Math.round(percentage)}%)`,
        };
      }

      // Check if warning (80% or more)
      const isWarning = percentage >= this.WARNING_THRESHOLD * 100;

      // Send notification for warning threshold
      if (isWarning) {
        const project = await prisma.project.findUnique({
          where: {id: projectId},
          select: {name: true},
        });

        if (project) {
          await NtfyService.notifyBillingLimitApproaching(
            project.name,
            projectId,
            usage,
            limit,
            percentage,
            sourceType,
          );

          // Send email notification (only once per month)
          await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
        }
      }

      return {
        allowed: true,
        warning: isWarning,
        usage,
        limit,
        percentage,
        message: isWarning
          ? `Warning: ${sourceType.toLowerCase()} emails at ${Math.round(percentage)}% of limit (${usage}/${limit})`
          : undefined,
      };
    } catch (error) {
      signale.error(`[BILLING_LIMIT] Error checking limit for ${projectId}/${sourceType}:`, error);
      // On error, allow the email to prevent service disruption
      return {
        allowed: true,
        warning: false,
        usage: 0,
        limit: null,
        percentage: 0,
      };
    }
  }

  /**
   * Get complete billing limits and usage for all categories
   * Used for displaying limits in UI
   *
   * For free tier projects: shows total usage across all categories with 1000/month limit
   * For paid tier projects: shows per-category usage with custom limits
   *
   * @param projectId - Project ID
   * @returns Complete billing limits and usage information
   */
  public static async getLimitsAndUsage(projectId: string): Promise<BillingLimitsResponse> {
    try {
      // Get project info
      const project = await prisma.project.findUnique({
        where: {id: projectId},
        select: {
          customer: true,
          subscription: true,
          billingLimitWorkflows: true,
          billingLimitCampaigns: true,
          billingLimitTransactional: true,
          billingLimitInbound: true,
        },
      });

      if (!project) {
        throw new Error('Project not found');
      }

      // Get currency from Stripe customer if available
      let currency: string | null = null;
      if (stripe && project.customer) {
        try {
          const customer = await stripe.customers.retrieve(project.customer);
          if (!customer.deleted) {
            currency = customer.currency || 'usd';
          }
        } catch (error) {
          signale.warn(`[BILLING_LIMIT] Failed to fetch currency for customer ${project.customer}:`, error);
          // Continue without currency - will be null in response
        }
      }

      // Get usage for all categories in parallel
      const [workflowUsage, campaignUsage, transactionalUsage, inboundUsage] = await Promise.all([
        this.getUsage(projectId, EmailSourceType.WORKFLOW),
        this.getUsage(projectId, EmailSourceType.CAMPAIGN),
        this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
        this.getUsage(projectId, EmailSourceType.INBOUND),
      ]);

      // Helper to calculate category usage
      const calculateCategoryUsage = (usage: number, limit: number | null): CategoryUsage => {
        const percentage = limit !== null && limit > 0 ? (usage / limit) * 100 : 0;
        return {
          limit,
          usage,
          percentage,
          isWarning: limit !== null && percentage >= this.WARNING_THRESHOLD * 100,
          isBlocked: limit !== null && usage >= limit,
        };
      };

      // Check if any custom per-category limits are set
      const hasCustomLimits =
        project.billingLimitWorkflows !== null ||
        project.billingLimitCampaigns !== null ||
        project.billingLimitTransactional !== null ||
        project.billingLimitInbound !== null;

      // Free tier projects (no subscription and no custom limits): show total usage with shared limit
      // Only show free tier limits if billing is enabled
      if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
        const totalUsage = workflowUsage + campaignUsage + transactionalUsage + inboundUsage;
        const limit = this.FREE_TIER_TOTAL_LIMIT;
        const percentage = (totalUsage / limit) * 100;
        const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
        const isBlocked = totalUsage >= limit;

        // For free tier, show the same limit and total usage for all four categories
        // This makes it clear in the UI that it's a shared limit
        const sharedUsageInfo: CategoryUsage = {
          limit,
          usage: totalUsage,
          percentage,
          isWarning,
          isBlocked,
        };

        return {
          workflows: sharedUsageInfo,
          campaigns: sharedUsageInfo,
          transactional: sharedUsageInfo,
          inbound: sharedUsageInfo,
          currency,
        };
      }

      // Projects with subscription or custom limits: show per-category limits
      return {
        workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
        campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
        transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
        inbound: calculateCategoryUsage(inboundUsage, project.billingLimitInbound),
        currency,
      };
    } catch (error) {
      signale.error(`[BILLING_LIMIT] Error getting limits and usage for ${projectId}:`, error);
      throw error;
    }
  }

  /**
   * Invalidate usage cache for a project
   * Primarily used in tests to reset cache state between scenarios.
   * In production, the cache naturally expires after 5 minutes.
   *
   * NOTE: Updating billing limits does NOT require clearing usage cache
   * (use clearNotificationCacheForChangedLimits instead)
   *
   * @param projectId - Project ID
   */
  public static async invalidateCache(projectId: string): Promise<void> {
    try {
      const keys = [
        this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
        this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
        this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
        this.getCacheKey(projectId, EmailSourceType.INBOUND),
      ];

      await Promise.all(keys.map(key => redis.del(key)));
      signale.debug(`[BILLING_LIMIT] Invalidated cache for project ${projectId}`);
    } catch (error) {
      signale.warn(`[BILLING_LIMIT] Failed to invalidate cache for ${projectId}:`, error);
    }
  }

  /**
   * Clear notification cache keys for billing limits that have changed
   * This allows new warning/limit emails to be sent when updated limits are reached
   *
   * @param projectId - Project ID
   * @param oldLimits - Previous billing limits
   * @param newLimits - New billing limits
   */
  public static async clearNotificationCacheForChangedLimits(
    projectId: string,
    oldLimits: {
      workflows: number | null;
      campaigns: number | null;
      transactional: number | null;
      inbound: number | null;
    },
    newLimits: {
      workflows: number | null;
      campaigns: number | null;
      transactional: number | null;
      inbound: number | null;
    },
  ): Promise<void> {
    try {
      const now = new Date();
      const year = now.getFullYear();
      const month = String(now.getMonth() + 1).padStart(2, '0');

      const keysToDelete: string[] = [];

      // Check workflows limit
      if (oldLimits.workflows !== newLimits.workflows) {
        keysToDelete.push(
          Keys.Billing.warningEmail(projectId, EmailSourceType.WORKFLOW, year, month),
          Keys.Billing.limitEmail(projectId, EmailSourceType.WORKFLOW, year, month),
        );
      }

      // Check campaigns limit
      if (oldLimits.campaigns !== newLimits.campaigns) {
        keysToDelete.push(
          Keys.Billing.warningEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
          Keys.Billing.limitEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
        );
      }

      // Check transactional limit
      if (oldLimits.transactional !== newLimits.transactional) {
        keysToDelete.push(
          Keys.Billing.warningEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
          Keys.Billing.limitEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
        );
      }

      // Check inbound limit
      if (oldLimits.inbound !== newLimits.inbound) {
        keysToDelete.push(
          Keys.Billing.warningEmail(projectId, EmailSourceType.INBOUND, year, month),
          Keys.Billing.limitEmail(projectId, EmailSourceType.INBOUND, year, month),
        );
      }

      if (keysToDelete.length > 0) {
        await Promise.all(keysToDelete.map(key => redis.del(key)));
        signale.debug(
          `[BILLING_LIMIT] Cleared notification cache for changed limits in project ${projectId} (${keysToDelete.length} keys)`,
        );
      }
    } catch (error) {
      signale.warn(`[BILLING_LIMIT] Failed to clear notification cache for ${projectId}:`, error);
    }
  }

  /**
   * Get Redis cache key for usage count
   */
  private static getCacheKey(projectId: string, sourceType: EmailSourceType): string {
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, '0');
    return Keys.Billing.usage(projectId, sourceType, year, month);
  }

  /**
   * Get start and end dates for current calendar month
   */
  private static getCurrentMonthRange(): {start: Date; end: Date} {
    const now = new Date();
    const start = new Date(now.getFullYear(), now.getMonth(), 1);
    const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
    return {start, end};
  }

  /**
   * Send billing limit warning email to project members
   */
  private static async sendWarningEmail(
    projectId: string,
    projectName: string,
    usage: number,
    limit: number,
    percentage: number,
    sourceType: string,
  ): Promise<void> {
    try {
      // Check if we've already sent this warning email this month
      const now = new Date();
      const year = now.getFullYear();
      const month = String(now.getMonth() + 1).padStart(2, '0');
      const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);

      // Use SETNX (SET if Not eXists) to atomically check and set the flag
      // This prevents race conditions where multiple concurrent requests could all pass the check
      const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
      const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);

      // SETNX returns 1 if key was set (didn't exist), 0 if key already existed
      const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
      if (!wasSet) {
        // Email was already sent this month
        return;
      }

      const members = await MembershipService.getMembers(projectId);
      const emails = members.map(m => m.email);
      if (emails.length === 0) {
        return;
      }

      const template = React.createElement(BillingLimitWarningEmail, {
        projectName,
        projectId,
        usage,
        limit,
        percentage,
        sourceType,
        dashboardUrl: DASHBOARD_URI,
        landingUrl: LANDING_URI,
      });

      await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
    } catch (error) {
      signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
    }
  }

  /**
   * Send billing limit exceeded email to project members
   */
  private static async sendLimitExceededEmail(
    projectId: string,
    projectName: string,
    usage: number,
    limit: number,
    sourceType: string,
  ): Promise<void> {
    try {
      // Check if we've already sent this warning email this month
      const now = new Date();
      const year = now.getFullYear();
      const month = String(now.getMonth() + 1).padStart(2, '0');
      const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);

      // Use SETNX (SET if Not eXists) to atomically check and set the flag
      // This prevents race conditions where multiple concurrent requests could all pass the check
      const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
      const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);

      // SETNX returns 1 if key was set (didn't exist), 0 if key already existed
      const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
      if (!wasSet) {
        // Email was already sent this month
        return;
      }

      const members = await MembershipService.getMembers(projectId);
      const emails = members.map(m => m.email);
      if (emails.length === 0) {
        return;
      }

      const template = React.createElement(BillingLimitExceededEmail, {
        projectName,
        projectId,
        usage,
        limit,
        sourceType,
        dashboardUrl: DASHBOARD_URI,
        landingUrl: LANDING_URI,
      });

      await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
    } catch (error) {
      signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
    }
  }
}
