import crypto from "crypto";
import fs from "fs";
import prisma from "../db.server";
import { encryptToken, decryptToken } from "../lib/security/encryption";
import { logger } from "../lib/security/logger";

/**
 * Result returned from a token acquisition or refresh operation.
 */
export interface TokenExchangeResult {
  accessToken: string;
  refreshToken?: string | null;
  expiresIn?: number;
  scope?: string;
  tokenExpiresAt?: Date;
  refreshTokenExpiresAt?: Date;
}

/**
 * Concurrency Mutex map: In-flight refresh promises indexed by normalized shop domain.
 * Prevents multiple parallel API calls or background jobs from attempting concurrent
 * refresh operations on the same shop, preventing single-use refresh token invalidation.
 */
const inFlightRefreshes = new Map<string, Promise<string>>();

/**
 * Redacts a sensitive token for safe logging without exposing credentials.
 */
export function redactToken(token?: string | null): string {
  if (!token) return "[EMPTY]";
  const clean = token.trim();
  if (clean.length <= 8) return "[REDACTED]";
  return `${clean.slice(0, 6)}...${clean.slice(-4)}`;
}

/**
 * Encodes string or Buffer to standard base64url format.
 */
function base64UrlEncode(input: string | Buffer): string {
  const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
  return buf.toString("base64url");
}

/**
 * Resolves the private key for signing client assertions.
 * Reads from SHOPIFY_PRIVATE_KEY environment variable (PEM string)
 * or SHOPIFY_PRIVATE_KEY_PATH (file on disk).
 */
export function getShopifyPrivateKey(): string | null {
  const directKey = process.env.SHOPIFY_PRIVATE_KEY;
  if (directKey && directKey.includes("PRIVATE KEY")) {
    return directKey.replace(/\\n/g, "\n").trim();
  }

  const keyPath = process.env.SHOPIFY_PRIVATE_KEY_PATH;
  if (keyPath && fs.existsSync(keyPath)) {
    try {
      return fs.readFileSync(keyPath, "utf8").trim();
    } catch (e: any) {
      logger.warn(`Failed to read private key from path ${keyPath}: ${e.message}`);
    }
  }

  return null;
}

/**
 * Generates an RS256-signed JWT client assertion for Shopify Client Credentials Grant
 * according to RFC 7523 and Shopify specifications.
 */
export function generateClientAssertion(
  clientId: string,
  tokenEndpoint: string,
  privateKeyPem: string,
  kid?: string
): string {
  const header: Record<string, string> = { alg: "RS256", typ: "JWT" };
  if (kid) header.kid = kid;

  const now = Math.floor(Date.now() / 1000);
  const payload = {
    iss: clientId,
    sub: clientId,
    aud: tokenEndpoint,
    jti: crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString("hex"),
    exp: now + 60, // 60 seconds lifetime
    nbf: now - 5,  // 5 seconds clock drift allowance
    iat: now,
  };

  const headerB64 = base64UrlEncode(JSON.stringify(header));
  const payloadB64 = base64UrlEncode(JSON.stringify(payload));
  const signingInput = `${headerB64}.${payloadB64}`;

  const signer = crypto.createSign("RSA-SHA256");
  signer.update(signingInput);
  const signatureB64 = signer.sign(privateKeyPem, "base64url");

  return `${signingInput}.${signatureB64}`;
}

/**
 * Recovers or obtains an access token via Shopify Client Credentials Grant.
 * Uses private-key JWT client assertion if configured, or falls back to client_secret.
 */
export async function recoverAccessTokenViaClientCredentials(
  shopDomain: string,
  scopes?: string
): Promise<string | null> {
  const normalizedDomain = shopDomain.includes(".")
    ? shopDomain.toLowerCase().trim()
    : `${shopDomain.toLowerCase().trim()}.myshopify.com`;

  const apiKey = process.env.SHOPIFY_API_KEY || "ff484160a48beda29e79144ee4e9a819";
  const apiSecret = process.env.SHOPIFY_API_SECRET || "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";
  const tokenEndpoint = `https://${normalizedDomain}/admin/oauth/access_token`;
  const privateKey = getShopifyPrivateKey();
  const kid = process.env.SHOPIFY_PRIVATE_KEY_KID;

  logger.info(`[TokenRecovery] Attempting client credentials recovery for: ${normalizedDomain}`);

  try {
    const params = new URLSearchParams();
    params.append("grant_type", "client_credentials");
    params.append("client_id", apiKey);

    if (privateKey) {
      const assertion = generateClientAssertion(apiKey, tokenEndpoint, privateKey, kid);
      params.append("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
      params.append("client_assertion", assertion);
      logger.info(`[TokenRecovery] Using RS256 private key client assertion for ${normalizedDomain}`);
    } else {
      params.append("client_secret", apiSecret);
      logger.info(`[TokenRecovery] Using standard client credentials for ${normalizedDomain}`);
    }

    const res = await fetch(tokenEndpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept: "application/json",
      },
      body: params.toString(),
    });

    if (res.ok) {
      const data = await res.json();
      if (data.access_token) {
        const expiresIn = Number(data.expires_in || 3600);
        const tokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
        const now = new Date();

        await prisma.shop.updateMany({
          where: { shopifyDomain: normalizedDomain },
          data: {
            accessToken: encryptToken(data.access_token),
            tokenExpiresAt,
            tokenUpdatedAt: now,
            scopes: data.scope || scopes || undefined,
          },
        });

        logger.info(
          `[TokenRecovery] Successfully acquired client credentials token for ${normalizedDomain} (expires in ${expiresIn}s)`
        );
        return data.access_token;
      }
    } else {
      const errText = await res.text();
      logger.warn(`[TokenRecovery] Client credentials failed HTTP ${res.status}: ${errText.slice(0, 160)}`);
    }
  } catch (err: any) {
    logger.error(`[TokenRecovery] Error during client credentials recovery for ${normalizedDomain}: ${err.message}`);
  }

  return null;
}

/**
 * Refreshes an expiring offline access token using the single-use refresh token.
 * Automatically saves the newly rotated refresh token and expiration timestamps.
 */
export async function refreshOfflineAccessToken(
  shopDomain: string,
  currentRefreshToken: string,
  existingScopes?: string
): Promise<string | null> {
  const normalizedDomain = shopDomain.includes(".")
    ? shopDomain.toLowerCase().trim()
    : `${shopDomain.toLowerCase().trim()}.myshopify.com`;

  const apiKey = process.env.SHOPIFY_API_KEY || "ff484160a48beda29e79144ee4e9a819";
  const apiSecret = process.env.SHOPIFY_API_SECRET || "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";

  logger.info(`[TokenManager] Performing single-use refresh token rotation for: ${normalizedDomain}`);

  try {
    const refreshParams = new URLSearchParams({
      client_id: apiKey,
      client_secret: apiSecret,
      grant_type: "refresh_token",
      refresh_token: currentRefreshToken,
    });

    const res = await fetch(`https://${normalizedDomain}/admin/oauth/access_token`, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept: "application/json",
      },
      body: refreshParams.toString(),
    });

    if (res.ok) {
      const data = await res.json();
      if (data.access_token) {
        const newAccessToken = data.access_token;
        const newRefreshToken = data.refresh_token || currentRefreshToken;
        const expiresIn = Number(data.expires_in || 3600);
        const tokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
        // Shopify refresh tokens are valid for 90 days from issuance
        const refreshTokenExpiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
        const now = new Date();

        await prisma.shop.updateMany({
          where: { shopifyDomain: normalizedDomain },
          data: {
            accessToken: encryptToken(newAccessToken),
            refreshToken: encryptToken(newRefreshToken),
            tokenExpiresAt,
            refreshTokenExpiresAt,
            tokenUpdatedAt: now,
            scopes: data.scope || existingScopes || undefined,
          },
        });

        logger.info(
          `[TokenManager] Successfully rotated tokens for ${normalizedDomain}. New token expires at ${tokenExpiresAt.toISOString()}`
        );
        return newAccessToken;
      }
    } else {
      const errText = await res.text();
      logger.warn(
        `[TokenManager] Refresh token call failed HTTP ${res.status} for ${normalizedDomain}: ${errText.slice(0, 160)}`
      );

      // If refresh token is invalid or expired (HTTP 400 invalid_grant), attempt Client Credentials recovery
      if (res.status === 400 && (errText.includes("invalid_grant") || errText.includes("expired"))) {
        logger.info(`[TokenManager] Refresh token revoked/expired for ${normalizedDomain}. Initiating recovery flow...`);
        return await recoverAccessTokenViaClientCredentials(normalizedDomain, existingScopes);
      }
    }
  } catch (err: any) {
    logger.error(`[TokenManager] Exception during token refresh for ${normalizedDomain}: ${err.message}`);
  }

  return null;
}

/**
 * Ensures an active, non-expired offline access token is returned for Shopify Admin API calls.
 * Implements in-flight concurrency mutex, proactive refresh buffer (5 mins), and automatic recovery.
 */
export async function getValidAdminAccessToken(
  shopDomainOrRecord: any,
  forceRefresh = false
): Promise<string> {
  const domain = typeof shopDomainOrRecord === "string" ? shopDomainOrRecord : shopDomainOrRecord?.shopifyDomain;
  if (!domain) return "";

  const normalizedDomain = domain.includes(".")
    ? domain.toLowerCase().trim()
    : `${domain.toLowerCase().trim()}.myshopify.com`;

  // 1. Concurrency deduplication: If a refresh is already in flight for this shop, await it
  if (inFlightRefreshes.has(normalizedDomain)) {
    try {
      return await inFlightRefreshes.get(normalizedDomain)!;
    } catch {
      // In-flight refresh failed, fall through to re-attempt
    }
  }

  const shop = await prisma.shop.findUnique({ where: { shopifyDomain: normalizedDomain } });
  if (!shop) return "";

  let currentAccessToken = "";
  if (shop.accessToken) {
    try {
      const dec = decryptToken(shop.accessToken.trim());
      if (dec && !dec.startsWith("shpss_sample_token")) {
        currentAccessToken = dec;
      }
    } catch {
      if (shop.accessToken.startsWith("shpat_") || shop.accessToken.startsWith("shpca_") || shop.accessToken.startsWith("shpua_")) {
        currentAccessToken = shop.accessToken.trim();
      }
    }
  }

  let currentRefreshToken = "";
  if (shop.refreshToken) {
    try {
      const dec = decryptToken(shop.refreshToken.trim());
      if (dec) currentRefreshToken = dec;
    } catch {
      currentRefreshToken = shop.refreshToken.trim();
    }
  }

  const tokenExpiresAt = shop.tokenExpiresAt ? new Date(shop.tokenExpiresAt) : null;
  const now = new Date();
  const PROACTIVE_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiration

  const isTokenStillValid =
    Boolean(currentAccessToken) &&
    !forceRefresh &&
    (!tokenExpiresAt || tokenExpiresAt.getTime() > now.getTime() + PROACTIVE_BUFFER_MS);

  // If token is still valid and not expiring in the next 5 minutes, return it immediately
  if (isTokenStillValid) {
    return currentAccessToken;
  }

  // 2. Token needs refresh. Execute inside the concurrency mutex lock
  const refreshPromise = (async (): Promise<string> => {
    try {
      // Step A: Refresh via refresh token if available
      if (currentRefreshToken) {
        const refreshedToken = await refreshOfflineAccessToken(normalizedDomain, currentRefreshToken, shop.scopes);
        if (refreshedToken) {
          return refreshedToken;
        }
      }

      // Step B: If no refresh token or refresh failed, attempt Client Credentials recovery
      const recoveredToken = await recoverAccessTokenViaClientCredentials(normalizedDomain, shop.scopes);
      if (recoveredToken) {
        return recoveredToken;
      }

      // Step C: Fall back to existing token if still before absolute expiration
      if (currentAccessToken && tokenExpiresAt && tokenExpiresAt.getTime() > now.getTime()) {
        logger.warn(`[TokenManager] Refresh failed for ${normalizedDomain}, returning existing unexpired token`);
        return currentAccessToken;
      }

      return currentAccessToken;
    } finally {
      inFlightRefreshes.delete(normalizedDomain);
    }
  })();

  inFlightRefreshes.set(normalizedDomain, refreshPromise);
  return await refreshPromise;
}

/**
 * Exchanges an OAuth Authorization Code for an expiring offline access token with refresh token.
 */
export async function exchangeAuthorizationCode(
  shopDomain: string,
  code: string,
  scopes?: string
): Promise<TokenExchangeResult | null> {
  const normalizedDomain = shopDomain.includes(".")
    ? shopDomain.toLowerCase().trim()
    : `${shopDomain.toLowerCase().trim()}.myshopify.com`;

  const apiKey = process.env.SHOPIFY_API_KEY || "ff484160a48beda29e79144ee4e9a819";
  const apiSecret = process.env.SHOPIFY_API_SECRET || "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";

  try {
    const params = new URLSearchParams({
      client_id: apiKey,
      client_secret: apiSecret,
      code,
      expiring: "1", // Request expiring offline access token
    });

    const res = await fetch(`https://${normalizedDomain}/admin/oauth/access_token`, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept: "application/json",
      },
      body: params.toString(),
    });

    if (res.ok) {
      const data = await res.json();
      if (data.access_token) {
        const expiresIn = Number(data.expires_in || 3600);
        const tokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
        const refreshTokenExpiresAt = data.refresh_token ? new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) : undefined;

        logger.info(
          `[TokenExchange] Successfully generated expiring offline token for ${normalizedDomain} (expires in ${expiresIn}s)`
        );

        return {
          accessToken: data.access_token,
          refreshToken: data.refresh_token || null,
          expiresIn,
          scope: data.scope || scopes,
          tokenExpiresAt,
          refreshTokenExpiresAt,
        };
      }
    } else {
      const err = await res.text();
      logger.warn(`[TokenExchange] Code exchange failed HTTP ${res.status}: ${err.slice(0, 160)}`);
    }
  } catch (err: any) {
    logger.error(`[TokenExchange] Exception exchanging authorization code for ${normalizedDomain}: ${err.message}`);
  }

  return null;
}
