/**
 * Integration Credentials Service
 * ----------------------------------
 * Handles encrypting, storing, and retrieving integration credentials
 * (OAuth tokens, API keys, refresh tokens).
 *
 * How encryption works:
 *   1. Credentials are serialized to JSON
 *   2. Encrypted with AES-256-GCM using a key from INTEGRATION_ENCRYPTION_KEY env var
 *   3. Stored as a single encrypted string in BrandIntegration.credentialsEnc
 *   4. Decrypted on read — plain text never touches the database
 *
 * The encryption key should be a 32-byte hex string in .env.local:
 *   INTEGRATION_ENCRYPTION_KEY="a1b2c3d4e5f6..."  (64 hex chars = 32 bytes)
 */

import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { log } from "@/lib/logger";

// ─── Types ───────────────────────────────────────────────────────────────────

export interface IntegrationCredentials {
  accessToken?: string;
  refreshToken?: string;
  apiKey?: string;
  accountId?: string;
  scopes?: string[];
  expiresAt?: string;        // ISO date
  [key: string]: unknown;    // allow extra fields per integration
}

export interface ConnectionHealth {
  status: "connected" | "expired" | "error" | "idle";
  message: string;
  tokenExpired: boolean;
  retryCount: number;
  nextRetryAt: string | null;
  lastSynced: string | null;
}

// ─── Encryption ──────────────────────────────────────────────────────────────

const ALGORITHM = "aes-256-gcm";

function getEncryptionKey(): Buffer {
  const key = process.env.INTEGRATION_ENCRYPTION_KEY;
  if (key && key.length === 64) {
    return Buffer.from(key, "hex");
  }
  // Fallback for development — NOT secure for production
  if (process.env.NODE_ENV !== "production") {
    return Buffer.from("0".repeat(64), "hex");
  }
  throw new Error("INTEGRATION_ENCRYPTION_KEY must be a 64-char hex string in production");
}

/**
 * Encrypt a credentials object to a single string.
 * Format: iv:authTag:ciphertext (all hex-encoded)
 */
export function encryptCredentials(creds: IntegrationCredentials): string {
  const key = getEncryptionKey();
  const iv = randomBytes(12);
  const cipher = createCipheriv(ALGORITHM, key, iv);

  const plaintext = JSON.stringify(creds);
  let encrypted = cipher.update(plaintext, "utf8", "hex");
  encrypted += cipher.final("hex");
  const authTag = cipher.getAuthTag().toString("hex");

  return `${iv.toString("hex")}:${authTag}:${encrypted}`;
}

/**
 * Decrypt a credentials string back to an object.
 * Returns null if decryption fails (key changed, data corrupted).
 */
export function decryptCredentials(encryptedStr: string): IntegrationCredentials | null {
  // Handle plain JSON fallback (stored when encryption key was unavailable)
  if (encryptedStr.startsWith("plain:")) {
    try {
      return JSON.parse(encryptedStr.slice(6));
    } catch {
      return null;
    }
  }

  try {
    const key = getEncryptionKey();
    const [ivHex, authTagHex, ciphertext] = encryptedStr.split(":");
    if (!ivHex || !authTagHex || !ciphertext) return null;

    const iv = Buffer.from(ivHex, "hex");
    const authTag = Buffer.from(authTagHex, "hex");
    const decipher = createDecipheriv(ALGORITHM, key, iv);
    decipher.setAuthTag(authTag);

    let decrypted = decipher.update(ciphertext, "hex", "utf8");
    decrypted += decipher.final("utf8");
    return JSON.parse(decrypted);
  } catch {
    return null;
  }
}

// ─── Store credentials ───────────────────────────────────────────────────────

export async function saveCredentials(
  brandId: string,
  integrationId: string,
  creds: IntegrationCredentials
): Promise<void> {
  // Store credentials — use encryption if key is available, plain JSON otherwise
  let encrypted: string;
  try {
    encrypted = encryptCredentials(creds);
  } catch {
    // Fallback: store as plain JSON if encryption fails
    encrypted = `plain:${JSON.stringify(creds)}`;
  }

  const meta: Record<string, unknown> = {};
  if (creds.scopes) meta.scopes = creds.scopes;
  if (creds.accountId) meta.accountId = creds.accountId;

  const tokenExpiresAt = creds.expiresAt ? new Date(creds.expiresAt) : null;

  await prisma.brandIntegration.update({
    where: { brandId_integrationId: { brandId, integrationId } },
    data: {
      credentialsEnc: encrypted,
      credentialsMeta: JSON.parse(JSON.stringify(meta)),
      tokenExpiresAt,
    },
  });
}

// ─── Read credentials ────────────────────────────────────────────────────────

export async function getCredentials(
  brandId: string,
  integrationId: string,
  options?: { autoRefresh?: boolean },
): Promise<IntegrationCredentials | null> {
  const integration = await prisma.brandIntegration.findUnique({
    where: { brandId_integrationId: { brandId, integrationId } },
  });

  if (!integration?.credentialsEnc) return null;

  const creds = decryptCredentials(integration.credentialsEnc);
  if (!creds) {
    log.error("integration", `Failed to decrypt credentials for ${integrationId}`, {
      metadata: { brandId, integrationId },
    });
    return null;
  }

  // Opt-in auto-refresh: when callers pass { autoRefresh: true } we proactively
  // refresh any Google access_token that's expired or expires within the next
  // 60 seconds. Default-off because most callers (UI status checks, displays
  // of accountId, etc.) don't actually call any API and would waste a refresh.
  // For real API call sites use either getValidAccessToken (returns the bare
  // token) or pass { autoRefresh: true } here when you need the full creds
  // object — e.g., GA4's call site needs propertyId AND a fresh accessToken.
  if (options?.autoRefresh && creds.refreshToken && creds.accessToken) {
    const expiresAtMs = creds.expiresAt ? new Date(creds.expiresAt).getTime() : 0;
    const expiresSoon = !expiresAtMs || Date.now() >= expiresAtMs - 60_000;
    if (expiresSoon) {
      try {
        const fresh = await refreshGoogleAccessToken(brandId, integrationId, creds);
        // refreshGoogleAccessToken persists via saveCredentials; return an
        // in-memory copy with the new token rather than re-querying.
        return {
          ...creds,
          accessToken: fresh,
          // Approximate the new expiry; the persisted record has the exact
          // value but callers usually only care that the token is fresh.
          expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
        };
      } catch (err) {
        // Refresh failed — fall through and return the existing (likely
        // stale) creds. The caller's API request will 401 and either retry
        // or surface a "reconnect" prompt; we should not pretend the token
        // is missing because some metadata (propertyId, siteUrl, etc.)
        // remains useful for showing a meaningful error in the UI.
        const msg = err instanceof Error ? err.message : "unknown";
        console.info(`[credentials] ${integrationId} auto-refresh failed: ${msg}`);
      }
    }
  }

  return creds;
}

// ─── Remove credentials ──────────────────────────────────────────────────────

/** Google OAuth token revocation — tells Google to release the grant
 *  server-side so the next OAuth attempt prompts the user to pick an
 *  account + approve scopes again (instead of silently reusing the
 *  cached grant). Best-effort: a failure here does not block the DB
 *  wipe that follows, because from the user's perspective "disconnect"
 *  must always succeed locally. Bounded to 3s to stop a stuck revoke
 *  call from hanging the disconnect button.
 *
 *  Accepts either an access_token or refresh_token per Google's spec
 *  — revoking a refresh_token invalidates every access_token derived
 *  from it, so we prefer the refresh_token when present.
 */
async function revokeGoogleToken(token: string): Promise<void> {
  if (!token) return;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 3000);
  try {
    await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      signal: controller.signal,
    });
  } catch {
    // Swallow: revoke is best-effort. Network error, timeout, or a
    // 400 (token already invalid) all mean we can stop trying.
  } finally {
    clearTimeout(timer);
  }
}

export async function removeCredentials(
  brandId: string,
  integrationId: string
): Promise<void> {
  // Read the current credentials first so we can revoke at Google's
  // endpoint before wiping locally. Without this, the stored grant
  // lingers in the user's Google account "Apps with access" list and
  // reconnects silently pick it back up — breaking the ability to
  // switch between Google accounts on a brand. Non-Google integrations
  // and rows with no creds skip the revoke (the call would 400).
  const existing = await prisma.brandIntegration.findUnique({
    where: { brandId_integrationId: { brandId, integrationId } },
    select: { credentialsEnc: true },
  }).catch(() => null);

  if (existing?.credentialsEnc) {
    try {
      const creds = decryptCredentials(existing.credentialsEnc);
      // Refresh_token invalidates all derived access_tokens; prefer it.
      const tokenToRevoke = creds?.refreshToken || creds?.accessToken;
      if (tokenToRevoke) await revokeGoogleToken(tokenToRevoke);
    } catch {
      // Decrypt failed or revoke threw — move on to the DB wipe anyway.
    }
  }

  await prisma.brandIntegration.update({
    where: { brandId_integrationId: { brandId, integrationId } },
    data: {
      credentialsEnc: null,
      credentialsMeta: {},
      tokenExpiresAt: null,
    },
  });
}

// ─── Google OAuth token refresh ──────────────────────────────────────────────

/**
 * Refresh a Google OAuth access token using the stored refresh_token.
 * Updates stored credentials with the new access_token and expiresAt.
 * If the refresh fails (refresh_token revoked, network error, etc.), marks
 * the integration as auth_expired so the UI can prompt the user to reconnect.
 *
 * @throws if refresh_token is missing or Google returns an error
 */
async function refreshGoogleAccessToken(
  brandId: string,
  integrationId: string,
  creds: IntegrationCredentials,
): Promise<string> {
  if (!creds.refreshToken) {
    throw new Error("No refresh_token stored — user must reconnect");
  }

  const clientId = process.env.GOOGLE_CLIENT_ID;
  const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
  if (!clientId || !clientSecret) {
    throw new Error("GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET not configured");
  }

  // Circuit breaker: if the integration is already confirmed expired (status="expired"
  // and refreshFailCount >= 5), skip the network call entirely. The invalid_grant
  // error was already recorded; subsequent callers get a silent throw so dashboard
  // loads don't hammer the OAuth endpoint or generate ERROR log spam for a
  // known-broken state.
  const existing = await prisma.brandIntegration.findUnique({
    where: { brandId_integrationId: { brandId, integrationId } },
    select: { status: true, refreshFailCount: true },
  }).catch(() => null);

  if (existing?.status === "expired" && (existing.refreshFailCount ?? 0) >= 5) {
    throw new Error(`[integration:expired] ${integrationId} is already expired — user must reconnect`);
  }

  const response = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: clientId,
      client_secret: clientSecret,
      refresh_token: creds.refreshToken,
      grant_type: "refresh_token",
    }),
  });

  const tokens = await response.json() as {
    access_token?: string;
    expires_in?: number;
    refresh_token?: string;
    token_type?: string;
    scope?: string;
    error?: string;
    error_description?: string;
  };

  if (!response.ok || tokens.error || !tokens.access_token) {
    // 3-strike tolerance: don't mark the integration as "expired" (forcing a
    // re-auth) on the first transient failure. A single Google 5xx or
    // network blip shouldn't kick the user out. Google's documented error
    // codes for truly revoked refresh tokens are invalid_grant /
    // invalid_client — promote those to "expired" immediately since no
    // retry will recover. Everything else increments refreshFailCount.
    const errCode = tokens.error ?? "unknown";
    const isAuthHard = errCode === "invalid_grant" || errCode === "invalid_client";

    const nextFailCount = (existing?.refreshFailCount ?? 0) + 1;
    const shouldExpire = isAuthHard || nextFailCount >= 3;

    await prisma.brandIntegration.update({
      where: { brandId_integrationId: { brandId, integrationId } },
      data: {
        refreshFailCount: nextFailCount,
        syncError: `Token refresh failed: ${tokens.error_description ?? errCode}`,
        ...(shouldExpire ? { status: "expired" } : {}),
      },
    }).catch(() => { /* ignore secondary failure */ });

    if (shouldExpire) {
      log.warn("integration", `[integration:expired] Token permanently failed for ${integrationId}: ${errCode} (failCount=${nextFailCount})`, {
        metadata: { brandId, integrationId, error: errCode, failCount: nextFailCount, markedExpired: true },
      });
    } else {
      log.warn("integration", `Token refresh failed for ${integrationId}: ${errCode} (attempt ${nextFailCount})`, {
        metadata: { brandId, integrationId, error: errCode, failCount: nextFailCount, markedExpired: false },
      });
    }
    throw new Error(tokens.error_description ?? errCode ?? "Token refresh failed");
  }

  // Save the fresh access_token + new expiresAt. Google sometimes rotates the
  // refresh_token — persist it if returned, otherwise keep the existing one.
  const expiresInSec = tokens.expires_in ?? 3600;
  const newExpiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
  const updated: IntegrationCredentials = {
    ...creds,
    accessToken: tokens.access_token,
    expiresAt: newExpiresAt,
    ...(tokens.refresh_token && { refreshToken: tokens.refresh_token }),
    ...(tokens.token_type && { tokenType: tokens.token_type }),
    ...(tokens.scope && { scope: tokens.scope }),
  };

  await saveCredentials(brandId, integrationId, updated);

  // Reset the consecutive-failure counter and, if the integration was
  // previously parked in "expired" due to refresh failures, recover it to
  // "connected" now that we've proven Google is accepting our refresh
  // token. Keeps syncError separate — a sync can still be broken even
  // when auth is fine.
  await prisma.brandIntegration.update({
    where: { brandId_integrationId: { brandId, integrationId } },
    data: { refreshFailCount: 0 },
  }).catch(() => { /* best-effort */ });
  await prisma.brandIntegration.updateMany({
    where: { brandId, integrationId, status: "expired" },
    data: { status: "connected", syncError: null },
  }).catch(() => { /* best-effort */ });

  log.info("integration", `Refreshed ${integrationId} access_token (expires in ${expiresInSec}s)`, {
    metadata: { brandId, integrationId, expiresAt: newExpiresAt },
  });

  return tokens.access_token;
}

/**
 * Get a valid Google access_token for a brand integration, refreshing it if
 * it is expired or expiring within the next 5 minutes.
 *
 * Use this in place of `creds.accessToken` anywhere you call a Google API.
 * Throws if the refresh fails — callers should catch and surface a
 * "reconnect" prompt to the user.
 */
export async function getValidAccessToken(
  brandId: string,
  integrationId: string,
): Promise<string> {
  const creds = await getCredentials(brandId, integrationId);
  if (!creds?.accessToken) {
    throw new Error("No stored credentials — user must connect integration");
  }

  // 5-minute buffer so long-running requests don't expire mid-flight
  const REFRESH_BUFFER_MS = 5 * 60 * 1000;
  const expiresAtMs = creds.expiresAt ? new Date(creds.expiresAt).getTime() : 0;
  const needsRefresh = !expiresAtMs || Date.now() >= expiresAtMs - REFRESH_BUFFER_MS;

  if (needsRefresh) {
    return refreshGoogleAccessToken(brandId, integrationId, creds);
  }

  return creds.accessToken;
}

/**
 * Force a Google access_token refresh regardless of the current expiry
 * timestamp. Used as a one-shot recovery when an API returns 401 despite
 * the stored token looking valid — e.g., Google revoked the token early
 * because the user changed their password, clock skew, or intermittent
 * revocation propagation. Throws if the refresh_token itself is missing
 * or revoked, at which point the UI should prompt for reconnect.
 */
export async function forceRefreshAccessToken(
  brandId: string,
  integrationId: string,
): Promise<string> {
  const creds = await getCredentials(brandId, integrationId);
  if (!creds) throw new Error("No stored credentials — user must reconnect");
  return refreshGoogleAccessToken(brandId, integrationId, creds);
}

// ─── Connection health check ─────────────────────────────────────────────────

export async function checkConnectionHealth(
  brandId: string,
  integrationId: string
): Promise<ConnectionHealth> {
  const integration = await prisma.brandIntegration.findUnique({
    where: { brandId_integrationId: { brandId, integrationId } },
  });

  if (!integration || !integration.connected) {
    return { status: "idle", message: "Not connected", tokenExpired: false, retryCount: 0, nextRetryAt: null, lastSynced: null };
  }

  // Check token expiry — but do NOT mark as expired here.
  // getValidAccessToken() handles auto-refresh. The tokenExpiresAt field
  // only tracks the access_token lifetime (1 hour), not the refresh_token.
  // Marking as expired here would break integrations that have valid
  // refresh tokens and would auto-refresh on next API call.
  const tokenExpired = integration.tokenExpiresAt
    ? integration.tokenExpiresAt.getTime() < Date.now()
    : false;

  // Only treat as expired if the DB status is already "expired"
  // (set by a failed refresh attempt in refreshGoogleAccessToken).
  // Attempt auto-recovery: try refreshing the token before giving up.
  if (integration.status === "expired") {
    // Try to recover by refreshing
    const creds = integration.credentialsEnc ? decryptCredentials(integration.credentialsEnc) : null;
    if (creds?.refreshToken) {
      try {
        await refreshGoogleAccessToken(brandId, integrationId, creds);
        // Refresh succeeded — mark as connected
        await prisma.brandIntegration.update({
          where: { id: integration.id },
          data: { status: "connected", syncError: null, syncRetryCount: 0 },
        });
        return {
          status: "connected",
          message: "Connected and working (token refreshed)",
          tokenExpired: false,
          retryCount: 0,
          nextRetryAt: null,
          lastSynced: integration.lastSynced?.toISOString() ?? null,
        };
      } catch {
        // Refresh truly failed — remain expired
      }
    }
    return {
      status: "expired",
      message: "Access token refresh failed — reconnect to fix",
      tokenExpired: true,
      retryCount: integration.syncRetryCount,
      nextRetryAt: integration.nextRetryAt?.toISOString() ?? null,
      lastSynced: integration.lastSynced?.toISOString() ?? null,
    };
  }

  if (integration.status === "error") {
    return {
      status: "error",
      message: integration.syncError ?? "Last sync failed",
      tokenExpired: false,
      retryCount: integration.syncRetryCount,
      nextRetryAt: integration.nextRetryAt?.toISOString() ?? null,
      lastSynced: integration.lastSynced?.toISOString() ?? null,
    };
  }

  return {
    status: "connected",
    message: "Connected and working",
    tokenExpired: false,
    retryCount: 0,
    nextRetryAt: null,
    lastSynced: integration.lastSynced?.toISOString() ?? null,
  };
}

// ─── Record sync failure with retry backoff ──────────────────────────────────

const RETRY_DELAYS = [5, 15, 60, 240, 1440]; // minutes: 5m, 15m, 1h, 4h, 24h

export async function recordSyncFailure(
  brandId: string,
  integrationId: string,
  errorMessage: string
): Promise<void> {
  const integration = await prisma.brandIntegration.findUnique({
    where: { brandId_integrationId: { brandId, integrationId } },
  });
  if (!integration) return;

  const retryCount = integration.syncRetryCount + 1;
  const delayIndex = Math.min(retryCount - 1, RETRY_DELAYS.length - 1);
  const delayMinutes = RETRY_DELAYS[delayIndex];
  const nextRetryAt = new Date(Date.now() + delayMinutes * 60000);

  // Preserve "expired" status set by refreshGoogleAccessToken. If the refresh
  // failed, the integration is explicitly in a re-auth-required state and
  // should NOT be downgraded to the generic "error" status — that confuses
  // the UI (which treats "expired" differently from "error").
  const nextStatus: "error" | "expired" = integration.status === "expired" ? "expired" : "error";

  await prisma.brandIntegration.update({
    where: { id: integration.id },
    data: {
      status: nextStatus,
      syncError: errorMessage,
      syncRetryCount: retryCount,
      nextRetryAt,
    },
  });

  log.error("integration", `${integrationId} sync failed (attempt ${retryCount}): ${errorMessage}`, {
    metadata: { brandId, integrationId, retryCount, nextRetryMinutes: delayMinutes, status: nextStatus },
  });
}

// ─── Record sync success (reset retry counter) ──────────────────────────────

export async function recordSyncSuccess(
  brandId: string,
  integrationId: string
): Promise<void> {
  await prisma.brandIntegration.update({
    where: { brandId_integrationId: { brandId, integrationId } },
    data: {
      status: "connected",
      syncError: null,
      syncRetryCount: 0,
      nextRetryAt: null,
      lastSynced: new Date(),
    },
  });

  log.info("integration", `${integrationId} sync succeeded`, {
    metadata: { brandId, integrationId },
  });
}
