/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview
 * This file contains types and functions for parsing structured Google API errors.
 */

/**
 * Sanitize a JSON string before parsing to handle known SSE stream corruption.
 * SSE stream parsing can inject stray commas — the observed pattern is a comma
 * at the end of one line followed by a stray comma on the next line, e.g.:
 *   `"domain": "cloudcode-pa.googleapis.com",\n ,       "metadata": {`
 * This collapses duplicate commas (possibly separated by whitespace/newlines)
 * into a single comma, preserving the whitespace.
 */
function sanitizeJsonString(jsonStr: string): string {
  // Match a comma, optional whitespace/newlines, then another comma.
  // Replace with just a comma + the captured whitespace.
  // Loop to handle cases like `,,,` which would otherwise become `,,` on a single pass.
  let prev: string;
  do {
    prev = jsonStr;
    jsonStr = jsonStr.replace(/,(\s*),/g, ',$1');
  } while (jsonStr !== prev);
  return jsonStr;
}

/**
 * Based on google/rpc/error_details.proto
 */

export interface ErrorInfo {
  '@type': 'type.googleapis.com/google.rpc.ErrorInfo';
  reason: string;
  domain?: string;
  metadata?: { [key: string]: string };
}

export interface RetryInfo {
  '@type': 'type.googleapis.com/google.rpc.RetryInfo';
  retryDelay: string; // e.g. "51820.638305887s"
}

export interface DebugInfo {
  '@type': 'type.googleapis.com/google.rpc.DebugInfo';
  stackEntries: string[];
  detail: string;
}

export interface QuotaFailure {
  '@type': 'type.googleapis.com/google.rpc.QuotaFailure';
  violations: Array<{
    subject?: string;
    description?: string;
    apiService?: string;
    quotaMetric?: string;
    quotaId?: string;
    quotaDimensions?: { [key: string]: string };
    quotaValue?: string | number;
    futureQuotaValue?: number;
  }>;
}

export interface PreconditionFailure {
  '@type': 'type.googleapis.com/google.rpc.PreconditionFailure';
  violations: Array<{
    type: string;
    subject: string;
    description: string;
  }>;
}

export interface LocalizedMessage {
  '@type': 'type.googleapis.com/google.rpc.LocalizedMessage';
  locale: string;
  message: string;
}

export interface BadRequest {
  '@type': 'type.googleapis.com/google.rpc.BadRequest';
  fieldViolations: Array<{
    field: string;
    description: string;
    reason?: string;
    localizedMessage?: LocalizedMessage;
  }>;
}

export interface RequestInfo {
  '@type': 'type.googleapis.com/google.rpc.RequestInfo';
  requestId: string;
  servingData: string;
}

export interface ResourceInfo {
  '@type': 'type.googleapis.com/google.rpc.ResourceInfo';
  resourceType: string;
  resourceName: string;
  owner: string;
  description: string;
}

export interface Help {
  '@type': 'type.googleapis.com/google.rpc.Help';
  links: Array<{
    description: string;
    url: string;
  }>;
}

export type GoogleApiErrorDetail =
  | ErrorInfo
  | RetryInfo
  | DebugInfo
  | QuotaFailure
  | PreconditionFailure
  | BadRequest
  | RequestInfo
  | ResourceInfo
  | Help
  | LocalizedMessage;

export interface GoogleApiError {
  code: number;
  message: string;
  details: GoogleApiErrorDetail[];
}

type ErrorShape = {
  message?: string;
  details?: unknown[];
  code?: number;
};

/**
 * Parses an error object to check if it's a structured Google API error
 * and extracts all details.
 *
 * This function can handle two formats:
 * 1. Standard Google API errors where `details` is a top-level field.
 * 2. Errors where the entire structured error object is stringified inside
 *    the `message` field of a wrapper error.
 *
 * @param error The error object to inspect.
 * @returns A GoogleApiError object if the error matches, otherwise null.
 */
export function parseGoogleApiError(error: unknown): GoogleApiError | null {
  if (!error) {
    return null;
  }

  let errorObj: unknown = error;

  // If error is a string, try to parse it.
  if (typeof errorObj === 'string') {
    try {
      errorObj = JSON.parse(sanitizeJsonString(errorObj));
    } catch {
      // Not a JSON string, can't parse.
      return null;
    }
  }

  if (Array.isArray(errorObj) && errorObj.length > 0) {
    errorObj = errorObj[0];
  }

  if (typeof errorObj !== 'object' || errorObj === null) {
    return null;
  }

  let currentError: ErrorShape | undefined =
    fromGaxiosError(errorObj) ?? fromApiError(errorObj);

  let depth = 0;
  const maxDepth = 10;
  // Handle cases where the actual error object is stringified inside the message
  // by drilling down until we find an error that doesn't have a stringified message.
  while (
    currentError &&
    typeof currentError.message === 'string' &&
    depth < maxDepth
  ) {
    try {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const parsedMessage = JSON.parse(
        sanitizeJsonString(
          currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '),
        ),
      );
      if (parsedMessage.error) {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        currentError = parsedMessage.error;
        depth++;
      } else {
        // The message is a JSON string, but not a nested error object.
        break;
      }
    } catch {
      // It wasn't a JSON string, so we've drilled down as far as we can.
      break;
    }
  }

  if (!currentError) {
    return null;
  }

  const code = currentError.code;
  const message = currentError.message;
  const errorDetails = currentError.details;

  if (code && message) {
    const details: GoogleApiErrorDetail[] = [];
    if (Array.isArray(errorDetails)) {
      for (const detail of errorDetails) {
        if (detail && typeof detail === 'object') {
          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          const detailObj = detail as Record<string, unknown>;
          const typeKey = Object.keys(detailObj).find(
            (key) => key.trim() === '@type',
          );
          if (typeKey) {
            if (typeKey !== '@type') {
              detailObj['@type'] = detailObj[typeKey];
              delete detailObj[typeKey];
            }
            // Basic structural check before casting.
            // Since the proto definitions are loose, we primarily rely on @type presence.
            // eslint-disable-next-line no-restricted-syntax
            if (typeof detailObj['@type'] === 'string') {
              // We can just cast it; the consumer will have to switch on @type
              // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
              details.push(detailObj as unknown as GoogleApiErrorDetail);
            }
          }
        }
      }
    }

    return {
      code,
      message,
      details,
    };
  }

  return null;
}

function isErrorShape(obj: unknown): obj is ErrorShape {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    (('message' in obj &&
      typeof (obj as { message: unknown }).message === 'string') ||
      ('code' in obj && typeof (obj as { code: unknown }).code === 'number'))
  );
}

function fromGaxiosError(errorObj: object): ErrorShape | undefined {
  const gaxiosError = errorObj as {
    response?: {
      status?: number;
      data?:
        | {
            error?: ErrorShape;
          }
        | string;
    };
    error?: ErrorShape;
    code?: number;
  };

  let outerError: ErrorShape | undefined;
  if (gaxiosError.response?.data) {
    let data = gaxiosError.response.data;

    if (typeof data === 'string') {
      try {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        data = JSON.parse(sanitizeJsonString(data));
      } catch {
        // Not a JSON string, can't parse.
      }
    }

    if (Array.isArray(data) && data.length > 0) {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      data = data[0];
    }

    if (typeof data === 'object' && data !== null) {
      if ('error' in data) {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
        const potentialError = (data as { error: unknown }).error;
        if (isErrorShape(potentialError)) {
          outerError = potentialError;
        }
      }
    }
  }

  if (!outerError) {
    // If the gaxios structure isn't there, check for a top-level `error` property.
    if (gaxiosError.error) {
      outerError = gaxiosError.error;
    } else {
      return undefined;
    }
  }
  return outerError;
}

function fromApiError(errorObj: object): ErrorShape | undefined {
  const apiError = errorObj as {
    message?:
      | {
          error?: ErrorShape;
        }
      | string;
    code?: number;
  };

  let outerError: ErrorShape | undefined;
  if (apiError.message) {
    let data = apiError.message;

    if (typeof data === 'string') {
      try {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        data = JSON.parse(sanitizeJsonString(data));
      } catch {
        // Not a JSON string, can't parse.
        // Try one more fallback: look for the first '{' and last '}'
        if (typeof data === 'string') {
          const firstBrace = data.indexOf('{');
          const lastBrace = data.lastIndexOf('}');
          if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
            try {
              // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
              data = JSON.parse(
                sanitizeJsonString(data.substring(firstBrace, lastBrace + 1)),
              );
            } catch {
              // Still failed
            }
          }
        }
      }
    }

    if (Array.isArray(data) && data.length > 0) {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      data = data[0];
    }

    if (typeof data === 'object' && data !== null) {
      if ('error' in data) {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
        const potentialError = (data as { error: unknown }).error;
        if (isErrorShape(potentialError)) {
          outerError = potentialError;
        }
      }
    }
  }
  return outerError;
}
