import { Injectable, type OnModuleInit } from '@nestjs/common';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { LoggerService, PrismaService } from '@modules/shared';

// ─── DTOs ────────────────────────────────────────────────────────────────────

export interface SendZaloOaDto {
  /** Zalo user ID (follower UID from OA) */
  toUid: string;
  /** ZNS template ID registered in Zalo OA Manager */
  templateId: string;
  /** Template parameter key-value pairs */
  templateData: Record<string, string>;
}

export interface ZaloOaMessageResult {
  messageId: string;
}

export interface ZaloOaLinkResult {
  zaloUserId: string;
  linked: boolean;
}

// ─── Internal Zalo API shapes ─────────────────────────────────────────────────

interface ZaloOaTokenResponse {
  access_token: string;
  refresh_token: string;
  expires_in: number;
  error?: number;
  error_description?: string;
}

// ─── Constants ────────────────────────────────────────────────────────────────

const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1_000;
/** Zalo ZNS 24-hour interaction window in milliseconds */
const INTERACTION_WINDOW_MS = 24 * 60 * 60 * 1_000;
/** Refresh tokens 5 minutes before expiry */
const REFRESH_BUFFER_MS = 5 * 60 * 1_000;

const ZNS_URL = 'https://business.openapi.zalo.me/message/template';
const OA_TOKEN_URL = 'https://oauth.zaloapp.com/v4/oa/access_token';

// ─── Encryption helpers ───────────────────────────────────────────────────────

const AES_ALGO = 'aes-256-gcm';

function encryptToken(plaintext: string, keyHex: string): string {
  const key = Buffer.from(keyHex, 'hex');
  const iv = randomBytes(12);
  const cipher = createCipheriv(AES_ALGO, key, iv);
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  return `${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`;
}

function decryptToken(encoded: string, keyHex: string): string {
  const key = Buffer.from(keyHex, 'hex');
  const parts = encoded.split('.');
  if (parts.length !== 3) throw new Error('Invalid encrypted token format');
  const [ivB64, tagB64, ctB64] = parts as [string, string, string];
  const iv = Buffer.from(ivB64, 'base64url');
  const tag = Buffer.from(tagB64, 'base64url');
  const ct = Buffer.from(ctB64, 'base64url');
  const decipher = createDecipheriv(AES_ALGO, key, iv);
  decipher.setAuthTag(tag);
  return decipher.update(ct) + decipher.final('utf8');
}

// ─── Service ──────────────────────────────────────────────────────────────────

/**
 * Service for Zalo Official Account (OA) API v3 integration.
 *
 * Responsibilities:
 *   1. ZNS template message sending (with exponential-backoff retry).
 *   2. OA OAuth account linking — authorize URL generation, callback handling,
 *      and storage of per-user encrypted access/refresh tokens in `zalo_account_links`.
 *   3. sendTemplate — user-centric wrapper that looks up the linked Zalo UID,
 *      checks the 24-hour interaction window, auto-refreshes expired tokens, and
 *      calls ZNS.
 *
 * Required env vars (all mandatory for full functionality):
 *   ZALO_OA_APP_ID       — OA App ID from Zalo OA Manager
 *   ZALO_OA_SECRET       — OA App Secret
 *   ZALO_OA_REDIRECT_URI — OAuth callback URI registered with Zalo
 *   ZALO_OA_TOKEN_KEY    — 32-byte hex key for AES-256-GCM token encryption
 *
 * Legacy ZNS-only mode (backwards-compatible):
 *   ZALO_OA_ID           — OA ID (used in ZNS requests)
 *   ZALO_OA_ACCESS_TOKEN — Static access token (no OAuth linking)
 */
@Injectable()
export class ZaloOaService implements OnModuleInit {
  // Legacy static-token mode
  private oaId = '';
  private accessToken = '';
  private initialized = false;

  // OAuth linking mode
  private oaAppId = '';
  private oaSecret = '';
  private oaRedirectUri = '';
  private tokenEncKey = '';
  private oauthEnabled = false;

  constructor(
    private readonly logger: LoggerService,
    private readonly prisma: PrismaService,
  ) {}

  onModuleInit(): void {
    // Legacy mode (backwards compat)
    this.oaId = process.env['ZALO_OA_ID'] ?? '';
    this.accessToken = process.env['ZALO_OA_ACCESS_TOKEN'] ?? '';

    if (!this.oaId || !this.accessToken) {
      this.logger.warn(
        'ZALO_OA_ID or ZALO_OA_ACCESS_TOKEN not set — Zalo OA legacy ZNS disabled',
        'ZaloOaService',
      );
    } else {
      this.initialized = true;
      this.logger.log(`Zalo OA configured for OA ID "${this.oaId}"`, 'ZaloOaService');
    }

    // OAuth linking mode
    this.oaAppId = process.env['ZALO_OA_APP_ID'] ?? '';
    this.oaSecret = process.env['ZALO_OA_SECRET'] ?? '';
    this.oaRedirectUri = process.env['ZALO_OA_REDIRECT_URI'] ?? '';
    this.tokenEncKey = process.env['ZALO_OA_TOKEN_KEY'] ?? '';

    if (this.oaAppId && this.oaSecret && this.oaRedirectUri && this.tokenEncKey) {
      if (this.tokenEncKey.length !== 64) {
        this.logger.warn(
          'ZALO_OA_TOKEN_KEY must be a 64-char hex string (32 bytes) — OAuth linking disabled',
          'ZaloOaService',
        );
      } else {
        this.oauthEnabled = true;
        this.logger.log('Zalo OA OAuth linking enabled', 'ZaloOaService');
      }
    } else {
      this.logger.warn(
        'ZALO_OA_APP_ID / ZALO_OA_SECRET / ZALO_OA_REDIRECT_URI / ZALO_OA_TOKEN_KEY not fully set — OA OAuth linking disabled',
        'ZaloOaService',
      );
    }
  }

  get isAvailable(): boolean {
    return this.initialized;
  }

  get isOAuthEnabled(): boolean {
    return this.oauthEnabled;
  }

  // ─── OAuth: Account Linking ─────────────────────────────────────────────────

  /**
   * Generate the Zalo OA OAuth authorization URL.
   * The `state` parameter should be a CSRF token tied to the user's session.
   */
  getOAuthAuthorizeUrl(state: string): string {
    if (!this.oauthEnabled) {
      throw new Error('Zalo OA OAuth linking is not configured');
    }
    const params = new URLSearchParams({
      app_id: this.oaAppId,
      redirect_uri: this.oaRedirectUri,
      state,
    });
    return `https://oauth.zaloapp.com/v4/oa/permission?${params.toString()}`;
  }

  /**
   * Handle OAuth callback: exchange code for OA-scoped tokens, resolve the
   * Zalo OA user ID, and persist encrypted tokens in `zalo_account_links`.
   */
  async handleOAuthCallback(
    userId: string,
    code: string,
  ): Promise<ZaloOaLinkResult> {
    if (!this.oauthEnabled) {
      throw new Error('Zalo OA OAuth linking is not configured');
    }

    const tokenData = await this.exchangeOaCode(code);

    const zaloUserId = await this.resolveZaloUserId(tokenData.access_token);

    const expiresAt = new Date(Date.now() + tokenData.expires_in * 1_000);
    const encAccess = encryptToken(tokenData.access_token, this.tokenEncKey);
    const encRefresh = encryptToken(tokenData.refresh_token, this.tokenEncKey);

    await this.prisma.zaloAccountLink.upsert({
      where: { userId },
      create: {
        userId,
        zaloUserId,
        accessToken: encAccess,
        refreshToken: encRefresh,
        expiresAt,
      },
      update: {
        zaloUserId,
        accessToken: encAccess,
        refreshToken: encRefresh,
        expiresAt,
      },
    });

    this.logger.log(
      `Zalo OA linked for user ${userId} → Zalo UID ${zaloUserId.slice(0, 6)}***`,
      'ZaloOaService',
    );

    return { zaloUserId, linked: true };
  }

  /**
   * Unlink a user's Zalo OA account.
   */
  async unlinkAccount(userId: string): Promise<void> {
    await this.prisma.zaloAccountLink.deleteMany({ where: { userId } });
    this.logger.log(`Zalo OA unlinked for user ${userId}`, 'ZaloOaService');
  }

  // ─── sendTemplate — user-centric ZNS send ──────────────────────────────────

  /**
   * Send a ZNS template message to the Zalo OA UID linked to `userId`.
   *
   * - Resolves the linked Zalo UID.
   * - Checks 24-hour interaction window (required by Zalo ZNS policy).
   * - Auto-refreshes access token if within the refresh buffer window.
   * - Falls back to legacy static-token mode if no link exists (for backwards compat).
   *
   * @throws Error if user has no linked Zalo account and legacy mode is unavailable.
   * @throws Error if the user is outside the 24-hour interaction window.
   */
  async sendTemplate(
    userId: string,
    templateId: string,
    params: Record<string, string>,
  ): Promise<ZaloOaMessageResult> {
    // Try per-user linked token first
    if (this.oauthEnabled) {
      const link = await this.prisma.zaloAccountLink.findUnique({ where: { userId } });

      if (link) {
        // Check 24-hour interaction window
        if (!this.isWithinInteractionWindow(link.lastInteractAt)) {
          throw new Error(
            `User ${userId} is outside the 24-hour Zalo OA interaction window — cannot send ZNS template`,
          );
        }

        // Refresh token if needed
        const resolvedLink = await this.ensureFreshToken(link);

        const plainAccessToken = decryptToken(resolvedLink.accessToken, this.tokenEncKey);

        return this.sendWithRetry({
          toUid: link.zaloUserId,
          templateId,
          templateData: params,
          accessToken: plainAccessToken,
        });
      }
    }

    // Legacy static-token fallback
    if (!this.initialized) {
      throw new Error(
        `No Zalo OA link found for user ${userId} and legacy mode is not configured`,
      );
    }

    // Legacy mode: caller must supply the uid directly — log a warning
    this.logger.warn(
      `sendTemplate called for user ${userId} with no OA link — falling back to legacy static-token mode (toUid not resolved)`,
      'ZaloOaService',
    );
    throw new Error(
      `No Zalo OA link found for user ${userId}. Please link the account via OAuth first.`,
    );
  }

  // ─── Legacy sendMessage (direct UID) ───────────────────────────────────────

  /**
   * Send a template-based message to a Zalo user via ZNS (Zalo Notification Service).
   *
   * The user must be a follower of the Official Account, and the template must be
   * pre-registered and approved in the Zalo OA Manager console.
   *
   * @deprecated Prefer `sendTemplate(userId, ...)` for per-user linked tokens.
   */
  async sendMessage(dto: SendZaloOaDto): Promise<ZaloOaMessageResult> {
    return this.sendWithRetry({ ...dto, accessToken: this.accessToken });
  }

  // ─── Record interaction (called from webhook handler) ────────────────────────

  /**
   * Record that a Zalo user interacted with the OA (follow, message, etc.).
   * Updates `lastInteractAt` on the linked account so the 24-hour window is fresh.
   */
  async recordInteraction(zaloUserId: string): Promise<void> {
    const updated = await this.prisma.zaloAccountLink.updateMany({
      where: { zaloUserId },
      data: { lastInteractAt: new Date() },
    });
    if (updated.count > 0) {
      this.logger.log(
        `Recorded OA interaction for Zalo UID ${zaloUserId.slice(0, 6)}***`,
        'ZaloOaService',
      );
    }
  }

  // ─── Internal helpers ──────────────────────────────────────────────────────

  private isWithinInteractionWindow(lastInteractAt: Date | null): boolean {
    if (!lastInteractAt) return false;
    return Date.now() - lastInteractAt.getTime() < INTERACTION_WINDOW_MS;
  }

  private async ensureFreshToken(
    link: { id: string; accessToken: string; refreshToken: string; expiresAt: Date },
  ): Promise<{ accessToken: string; refreshToken: string }> {
    const msUntilExpiry = link.expiresAt.getTime() - Date.now();

    if (msUntilExpiry > REFRESH_BUFFER_MS) {
      // Token still valid
      return { accessToken: link.accessToken, refreshToken: link.refreshToken };
    }

    // Refresh
    const plainRefresh = decryptToken(link.refreshToken, this.tokenEncKey);
    const newTokens = await this.refreshOaToken(plainRefresh);

    const newExpiresAt = new Date(Date.now() + newTokens.expires_in * 1_000);
    const encAccess = encryptToken(newTokens.access_token, this.tokenEncKey);
    const encRefresh = encryptToken(newTokens.refresh_token, this.tokenEncKey);

    await this.prisma.zaloAccountLink.update({
      where: { id: link.id },
      data: { accessToken: encAccess, refreshToken: encRefresh, expiresAt: newExpiresAt },
    });

    this.logger.log(`Refreshed Zalo OA token for link ${link.id}`, 'ZaloOaService');

    return { accessToken: encAccess, refreshToken: encRefresh };
  }

  private async refreshOaToken(refreshToken: string): Promise<ZaloOaTokenResponse> {
    const body = new URLSearchParams({
      app_id: this.oaAppId,
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
    });

    const response = await fetch(OA_TOKEN_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        secret_key: this.oaSecret,
      },
      body: body.toString(),
    });

    const data = (await response.json()) as ZaloOaTokenResponse;

    if (data.error) {
      throw new Error(
        `Zalo OA token refresh failed (${data.error}): ${data.error_description ?? 'unknown'}`,
      );
    }

    if (!data.access_token) {
      throw new Error('Zalo OA token refresh: no access_token in response');
    }

    return data;
  }

  private async exchangeOaCode(code: string): Promise<ZaloOaTokenResponse> {
    const body = new URLSearchParams({
      app_id: this.oaAppId,
      code,
      grant_type: 'authorization_code',
    });

    const response = await fetch(OA_TOKEN_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        secret_key: this.oaSecret,
      },
      body: body.toString(),
    });

    const data = (await response.json()) as ZaloOaTokenResponse;

    if (data.error) {
      throw new Error(
        `Zalo OA code exchange failed (${data.error}): ${data.error_description ?? 'unknown'}`,
      );
    }

    if (!data.access_token) {
      throw new Error('Zalo OA code exchange: no access_token in response');
    }

    return data;
  }

  /**
   * Resolve the Zalo OA UID for the authenticated user by calling the OA Me endpoint.
   */
  private async resolveZaloUserId(oaAccessToken: string): Promise<string> {
    const response = await fetch('https://openapi.zalo.me/v2.0/oa/getprofile?data=%7B%7D', {
      headers: { access_token: oaAccessToken },
    });

    const data = (await response.json()) as {
      error?: number;
      message?: string;
      data?: { user_id_by_app?: string; user_id?: string };
    };

    if (data.error && data.error !== 0) {
      throw new Error(
        `Zalo OA user ID resolution failed (${data.error}): ${data.message ?? 'unknown'}`,
      );
    }

    const uid = data.data?.user_id_by_app ?? data.data?.user_id;
    if (!uid) {
      throw new Error('Zalo OA user ID resolution: no UID in response');
    }

    return uid;
  }

  private async sendWithRetry(
    dto: SendZaloOaDto & { accessToken: string },
  ): Promise<ZaloOaMessageResult> {
    if (!this.initialized && !this.oauthEnabled) {
      throw new Error('Zalo OA not initialized — ZALO_OA_ID / ZALO_OA_ACCESS_TOKEN not configured');
    }

    let lastError: Error | undefined;

    for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
      try {
        return await this.send(dto);
      } catch (error) {
        lastError = error instanceof Error ? error : new Error(String(error));

        if (attempt < MAX_RETRIES) {
          const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1);
          this.logger.warn(
            `Zalo OA attempt ${attempt}/${MAX_RETRIES} failed: ${lastError.message}. Retrying in ${delayMs}ms...`,
            'ZaloOaService',
          );
          await this.delay(delayMs);
        }
      }
    }

    this.logger.error(
      `Zalo OA message failed after ${MAX_RETRIES} attempts: ${lastError?.message}`,
      'ZaloOaService',
    );
    throw lastError;
  }

  private async send(
    dto: SendZaloOaDto & { accessToken: string },
  ): Promise<ZaloOaMessageResult> {
    const body = {
      phone: dto.toUid,
      template_id: dto.templateId,
      template_data: dto.templateData,
    };

    const response = await fetch(ZNS_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        access_token: dto.accessToken,
      },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      const errorText = await response.text().catch(() => 'Unknown error');
      throw new Error(`Zalo OA API error (${response.status}): ${errorText}`);
    }

    const data = (await response.json()) as {
      error?: number;
      message?: string;
      data?: { msg_id?: string };
    };

    // Zalo API returns error=0 on success
    if (data.error !== undefined && data.error !== 0) {
      throw new Error(
        `Zalo OA message rejected (code ${data.error}): ${data.message ?? 'Unknown reason'}`,
      );
    }

    const messageId = data.data?.msg_id ?? `zalo-oa-${Date.now()}`;

    this.logger.log(
      `Zalo OA message sent to ${dto.toUid.slice(0, 6)}***: ${messageId}`,
      'ZaloOaService',
    );

    return { messageId };
  }

  private delay(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}
