import JSON5 from 'json5';
import { isEmpty } from 'lodash';

import { ClientAuthMethod } from '../../plugins/index.js';
import { OAuthErrorResponse, OAuthTokenResponse } from '../auth/index.js';
import { Property } from '../common/property.js';

export const OAUTH_CALLBACK_PATH = 'oauth/callback';
export const LS_OAUTH_DATASOURCE_KEY = 'oauth-datasource';
export const LS_OAUTH_ENV_KEY = 'oauth-env';
export const LS_OAUTH_ERROR_KEY = 'oauth-error';
export const LS_OAUTH_ONE_TIME_CODE = 'oauth-one-time-code';
export const LS_OAUTH_REDIRECT_FINISHED = 'oauth-redirect-finished';
export const LS_FULL_STATE = 'oauth-full-state';

export type AuthId = string;

// getAuthId returns the key that is used to identify which token should be used
// for a specific auth type.
// A suffix may be appended for each value that we're caching (eg "-token",
// "-refresh", etc)
export function getAuthId(
  authType: AuthType | undefined,
  authConfig: AuthConfig | undefined,
  integrationId?: string,
  integrationConfigurationId?: string
): AuthId {
  return `${authType}.${getClientId(authType, authConfig, integrationId, integrationConfigurationId)}`;
}

function getClientId(
  authType: AuthType | undefined,
  config: AuthConfig | undefined,
  integrationId?: string,
  integrationConfigurationId?: string
): string {
  const unknownClient = 'unknown-client';
  if (!authType || !config) {
    return unknownClient;
  }

  const clientIdForAuthType = (): string | undefined => {
    switch (authType) {
      case IntegrationAuthType.BASIC:
        return integrationId ?? '';
      case IntegrationAuthType.FIREBASE: {
        const firebaseConfig = config as FirebaseAuthConfig;
        if (!firebaseConfig.apiKey) {
          return undefined;
        }
        try {
          const parsed = JSON5.parse(firebaseConfig.apiKey);
          return parsed.projectId;
        } catch {
          return undefined;
        }
      }
      case IntegrationAuthType.OAUTH2_PASSWORD: {
        return (config as OAuthConfig).clientId;
      }
      case IntegrationAuthType.OAUTH2_CLIENT_CREDS:
      case IntegrationAuthType.OAUTH2_CODE:
      case IntegrationAuthType.OAUTH2_IMPLICIT:
      case GoogleSheetsAuthType.OAUTH2_CODE: {
        const oauthConfig = config as OAuthConfig;
        let apiId;
        if (normalizeTokenScope(oauthConfig.tokenScope) === TokenScope.DATASOURCE) {
          apiId = integrationConfigurationId;
        } else {
          apiId = config.clientId;
        }
        // in case there are muliple scopes, separated by a whitespace - sort them before hashing
        const scopeHash = 'scope' in oauthConfig && oauthConfig.scope ? insecureHash(oauthConfig.scope?.split(' ').sort().join(' ')) : null;
        if (!scopeHash || !isEmpty(scopeHash)) {
          // OAuth scopes are optional according to the OAuth spec.
          apiId += `-${scopeHash}`;
        }
        return apiId;
      }
      case IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE: {
        const tokenExchangeConfig = config as OAuthTokenExchangeConfig;
        const fieldsToHash = [tokenExchangeConfig.scope?.split(' ').sort().join(' '), tokenExchangeConfig.audience];
        if (tokenExchangeConfig.subjectTokenSource === 'SUBJECT_TOKEN_SOURCE_STATIC_TOKEN') {
          fieldsToHash.push(tokenExchangeConfig.subjectTokenSourceStaticToken);
        }
        const hashedFields = fieldsToHash.filter(Boolean).map(insecureHash).join('-');

        // clientId is optional for token exchange, so use integrationConfigurationId as fallback
        const identifier = config.clientId || integrationConfigurationId;
        return `${identifier}-${hashedFields}`;
      }
      default:
        return undefined;
    }
  };

  return clientIdForAuthType() ?? unknownClient;
}

// This enum is persisted and used in UI <> agent communication, so take care
// when modifying existing values.
export enum IntegrationAuthType {
  NONE = 'None',
  BASIC = 'basic',
  OAUTH2_CODE = 'oauth-code',
  OAUTH2_CLIENT_CREDS = 'oauth-client-cred',
  OAUTH2_IMPLICIT = 'oauth-implicit',
  OAUTH2_PASSWORD = 'oauth-pword',
  OAUTH2_TOKEN_EXCHANGE = 'oauth-token-exchange',
  // since proto does not support hyphens
  OAUTH2_TOKEN_EXCHANGE_PROTO = 'oauthTokenExchange',
  /** Passes the user's SSO IdP access token through as {{ oauth.token }} — no OAuth client config needed. */
  OAUTH2_IDP_TOKEN_PASSTHROUGH = 'oauth-idp-token-passthrough',
  FIREBASE = 'Firebase',
  BEARER = 'bearer',
  API_KEY = 'api-key',
  TOKEN_PREFIXED = 'token-prefixed',
  API_KEY_FORM = 'api-key-form'
}

// IntegrationAuthType are old auth types defined in typescript types
// NewAuth Type is defined by proto Auth message
export enum NewAuthType {
  OAUTH2_PASSWORD_GRANT_FLOW = 'passwordGrantFlow',
  OAUTH2_AUTH_CODE_FLOW = 'authorizationCodeFlow',
  OAUTH2_CLIENT_CREDS_FLOW = 'clientCredentialsFlow',
  OAUTH2_TOKEN_EXCHANGE_FLOW = 'tokenExchangeFlow'
}

export function isRedirectRequired(authType: AuthType | undefined): boolean {
  switch (authType) {
    case IntegrationAuthType.OAUTH2_CODE:
    case IntegrationAuthType.OAUTH2_IMPLICIT:
      return true;
    default:
      return false;
  }
}

// This enum is used in agent API execution, and API step headers display, so take care
// when modifying existing values.
export const IntegrationAuthHeaderPrefixMap = {
  [IntegrationAuthType.BASIC]: 'Basic ',
  [IntegrationAuthType.BEARER]: 'Bearer ',
  [IntegrationAuthType.OAUTH2_CODE]: 'Bearer ',
  // this is the default prefix for this auth type, but is overridable
  // using the prefix field in the corresponding authConfig
  [IntegrationAuthType.TOKEN_PREFIXED]: 'Bearer '
};

// This enum enumerates the different ways the API Key authentication method can
// propagate the provided key-value pair.
export enum ApiKeyMethod {
  HEADER = 'header',
  QUERY_PARAM = 'query-param'
}

export enum GoogleSheetsAuthType {
  // this is used in GoogleSheetsPlugin.preCreateValidate
  OAUTH2_CODE = 'oauth-code',
  SERVICE_ACCOUNT = 'service-account'
}

export enum AWSAuthType {
  ACCESS_KEY = 'access-key',
  TOKEN_FILE = 'token-file',
  EC2_INSTANCE_METADATA = 'ec2-instance-metadata'
}

const awsAuthTypeDisplayName = new Map([
  [AWSAuthType.ACCESS_KEY, 'Access Key'],
  [AWSAuthType.TOKEN_FILE, 'Token File'],
  [AWSAuthType.EC2_INSTANCE_METADATA, 'EC2 Instance Metadata']
]);

export function getAWSAuthTypeDisplayName(authType: AWSAuthType): string {
  return awsAuthTypeDisplayName.get(authType) ?? '';
}

// generally used with authTypeField='connectionType'
enum DatabaseAuthType {
  URL = 'url',
  FIELDS = 'fields',
  OKTA = 'okta',
  KEY_PAIR = 'key-pair'
}

export type AuthType = IntegrationAuthType | GoogleSheetsAuthType | NewAuthType | DatabaseAuthType;

export function getDisplayName(authType: AuthType): string {
  switch (authType) {
    case IntegrationAuthType.BASIC:
      return 'Basic Authentication';
    case IntegrationAuthType.FIREBASE:
      return 'Firebase';
    case NewAuthType.OAUTH2_PASSWORD_GRANT_FLOW:
    case IntegrationAuthType.OAUTH2_PASSWORD:
      // APIs should be migrating away from this grant type. Add legacy to hint
      // to users this is probably not the grant type they want.
      return 'OAuth2 - Password Grant (Legacy)';
    case NewAuthType.OAUTH2_CLIENT_CREDS_FLOW:
    case IntegrationAuthType.OAUTH2_CLIENT_CREDS:
      return 'OAuth2 - Client Credentials Grant';
    case IntegrationAuthType.OAUTH2_IMPLICIT:
      return 'OAuth2 - Implicit Grant';
    case NewAuthType.OAUTH2_AUTH_CODE_FLOW:
    case IntegrationAuthType.OAUTH2_CODE:
      return 'OAuth2 - Authorization Code';
    case IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE:
      return 'OAuth2 - On-Behalf-Of Token Exchange';
    case IntegrationAuthType.OAUTH2_IDP_TOKEN_PASSTHROUGH:
      return 'IdP Token Passthrough';
    case IntegrationAuthType.BEARER:
      return 'Bearer Token';
    case IntegrationAuthType.TOKEN_PREFIXED:
      return 'Token';
    case IntegrationAuthType.API_KEY:
      return 'API Key';
    case IntegrationAuthType.API_KEY_FORM:
      return 'API Key';
    case DatabaseAuthType.URL:
      return 'Connection URL';
    case DatabaseAuthType.FIELDS:
      return 'Password-based authentication';
    case DatabaseAuthType.OKTA:
      return 'SSO';
    case DatabaseAuthType.KEY_PAIR:
      return 'Key Pair Authentication';
    case IntegrationAuthType.NONE:
    default:
      return 'None';
  }
}

type PublicFirebaseAuthConfig = {
  apiKey?: string;
  google?: boolean;
  email?: boolean;
};

type FirebaseAuthConfig = PublicFirebaseAuthConfig;

type PublicOAuthConfig = PublicOAuthPasswordConfig &
  PublicOAuthClientCredsConfig &
  PublicOAuthImplicitConfig &
  PublicOAuthCodeConfig &
  PublicOAuthTokenExchangeConfig;

type OAuthConfig = OAuthPasswordConfig &
  OAuthClientCredsConfig &
  OAuthImplicitConfig &
  OAuthCodeConfig &
  OAuthBringYourOwnClientConfig &
  OAuthTokenExchangeConfig;

type PublicOAuthPasswordConfig = {
  clientId?: string;
  tokenUrl?: string;
  useFixedPasswordCreds?: boolean;
};

type OAuthPasswordConfig = PublicOAuthPasswordConfig & {
  clientSecret?: string;
  // A fixed username and password can be optionally provided.
  username?: string;
  password?: string;
};

type PublicOAuthClientCredsConfig = {
  clientId?: string;
  tokenUrl?: string;
  scope?: string;
};

type OAuthClientCredsConfig = PublicOAuthClientCredsConfig & {
  clientSecret?: string;
  audience?: string;
};

type PublicOAuthImplicitConfig = {
  clientId?: string;
  authorizationUrl?: string;
  scope?: string;
};

type OAuthImplicitConfig = PublicOAuthImplicitConfig & {
  clientSecret?: string;
};

type PublicOAuthTokenExchangeConfig = {
  tokenUrl?: string;
  audience?: string;
  scope?: string;
  subjectTokenSource?: string;
  subjectTokenSourceStaticToken?: string;
  subjectTokenType?: string;
  clientId?: string;
};

export type OAuthTokenExchangeConfig = PublicOAuthTokenExchangeConfig & {
  clientSecret?: string;
};

type PublicOAuthCodeConfig = {
  clientId?: string;
  authorizationUrl?: string;
  authUrl?: string; // @deprecated orchestrator uses this
  userInfoUrl?: string;
  tokenUrl?: string;
  scope?: string;
  audience?: string;
  promptType?: string;
  refreshTokenFromServer?: boolean; //TODO(alex): maybe rename to something like bringYourOwnClient
  tokenScope?: TokenScope;
  revokeTokenUrl?: string;
  authToken?: string;
  hasToken?: boolean; // @deprecated not used anymore
  userEmail?: string; // Used by integrations that need user identity (e.g., Lakebase Token Federation)
  sendOAuthState?: boolean;
};

type OAuthCodeConfig = PublicOAuthCodeConfig & {
  clientSecret?: string;
};

export enum TokenScope {
  DATASOURCE = 'datasource',
  USER = 'user'
}

type OAuthBringYourOwnClientConfig = {
  tokenUrl?: string;
  authorizationUrl?: string;
  revokeTokenUrl?: string;
  clientSecret?: string;
  clientAuthMethod?: ClientAuthMethod;
};

export enum TokenType {
  REFRESH = 'refresh',
  USER = 'userId',
  // Access token is persisted as "token" for backwards compatibility.
  ACCESS = 'token',
  ID = 'id-token'
}

export type TokenMetadata = {
  email?: string;
};

type FakeBoolean = boolean | string;

type BasicAuthConfig = PublicBasicAuthConfig & {
  username?: string;
  password?: string;
};

type PublicBasicAuthConfig = {
  shareBasicAuthCreds?: FakeBoolean;
};

type BearerTokenAuthConfig = {
  bearerToken?: string;
};

type TokenPrefixedAuthConfig = {
  prefix?: string;
  token?: string;
};

type ApiKeyFormAuthConfig = {
  apiKeys?: Record<string, { header?: string; token?: string }>;
};

type ApiKeyAuthConfig = {
  key?: string;
  value?: string;
  method?: ApiKeyMethod;
};

// normalizeTokenScope coerces a boolean tokenScope value (which can end up in the
// DB when the UI checkbox's mapBooleansTo mapping doesn't fire) to the correct
// TokenScope string enum. This prevents the orchestrator's protojson.Unmarshal
// from rejecting a boolean for the proto string field token_scope.
function normalizeTokenScope(value: unknown): TokenScope | undefined {
  if (value === true || value === TokenScope.DATASOURCE) return TokenScope.DATASOURCE;
  if (value === false || value === TokenScope.USER) return TokenScope.USER;
  if (value === undefined || value === null) return undefined;
  if (typeof value === 'string' && Object.values(TokenScope).includes(value as TokenScope)) return value as TokenScope;
  return undefined;
}

export const extractPublic = (authType: AuthType | undefined, authConfig: AuthConfig | undefined): PublicAuthConfig => {
  switch (authType) {
    case IntegrationAuthType.BASIC:
      return {
        shareBasicAuthCreds: authConfig?.shareBasicAuthCreds
      };
    case IntegrationAuthType.OAUTH2_CLIENT_CREDS:
      return {
        clientId: authConfig?.clientId,
        tokenUrl: authConfig?.tokenUrl,
        scope: authConfig?.scope
      };
    case IntegrationAuthType.OAUTH2_CODE:
      return {
        clientId: authConfig?.clientId,
        authorizationUrl: authConfig?.authorizationUrl,
        tokenUrl: authConfig?.tokenUrl,
        scope: authConfig?.scope,
        audience: authConfig?.audience,
        refreshTokenFromServer: authConfig?.refreshTokenFromServer,
        hasToken: authConfig?.hasToken,
        tokenScope: normalizeTokenScope(authConfig?.tokenScope),
        promptType: authConfig?.promptType,
        sendOAuthState: authConfig?.sendOAuthState
      };
    case IntegrationAuthType.OAUTH2_IMPLICIT:
      return {
        clientId: authConfig?.clientId,
        authorizationUrl: authConfig?.authorizationUrl,
        scope: authConfig?.scope,
        audience: authConfig?.audience
      };
    case IntegrationAuthType.OAUTH2_PASSWORD:
      return {
        clientId: authConfig?.clientId,
        tokenUrl: authConfig?.tokenUrl,
        useFixedPasswordCreds: authConfig?.useFixedPasswordCreds,
        audience: authConfig?.audience
      };
    case IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE:
      return {
        clientId: authConfig?.clientId,
        tokenUrl: authConfig?.tokenUrl,
        audience: authConfig?.audience,
        scope: authConfig?.scope,
        subjectTokenSource: authConfig?.subjectTokenSource,
        subjectTokenSourceStaticToken: authConfig?.subjectTokenSourceStaticToken
      };
    case IntegrationAuthType.FIREBASE:
      return authConfig ?? {};
    case IntegrationAuthType.NONE:
      return authConfig ?? {};
    case GoogleSheetsAuthType.OAUTH2_CODE:
    case GoogleSheetsAuthType.SERVICE_ACCOUNT:
    case IntegrationAuthType.BEARER:
    case IntegrationAuthType.TOKEN_PREFIXED:
    case IntegrationAuthType.API_KEY:
    case IntegrationAuthType.API_KEY_FORM:
    case IntegrationAuthType.OAUTH2_IDP_TOKEN_PASSTHROUGH:
    case DatabaseAuthType.FIELDS:
    case DatabaseAuthType.OKTA:
    case DatabaseAuthType.KEY_PAIR:
    case DatabaseAuthType.URL:
      return {};
    default:
      throw new Error(`unknown auth type: ${authType}`);
  }
};

type ServiceAccountConfig = {
  googleServiceAccount?: Property;
};

type PublicRestAuthConfig = PublicBasicAuthConfig & PublicOAuthConfig & PublicFirebaseAuthConfig;
type PublicGoogleSheetsAuthConfig = PublicOAuthCodeConfig;
type PublicAuthConfig = PublicRestAuthConfig & PublicGoogleSheetsAuthConfig;

// TODO(aayush): Rename RestAuthConfig to something more generic as it is also
// used for GraphQL.
type RestAuthConfig = BasicAuthConfig &
  OAuthConfig &
  FirebaseAuthConfig &
  BearerTokenAuthConfig &
  TokenPrefixedAuthConfig &
  ApiKeyFormAuthConfig &
  ApiKeyAuthConfig;
type GoogleSheetsAuthConfig = OAuthCodeConfig & ServiceAccountConfig;
export type AuthConfig = RestAuthConfig & GoogleSheetsAuthConfig;

// TODO: this lives here for now to avoid a cyclic dependency. The utils
// directory transitively depends on this file.
// An insecure hash function used to condense a string.
const insecureHash = (s: string | undefined): string => {
  // We need the redundant `!s` check to satisfy the type checker that s is not
  // undefined.
  if (!s || isEmpty(s)) {
    return '';
  }
  let hash = 0;
  for (let i = 0; i < s.length; i++) {
    const char = s.charCodeAt(i);
    hash = (hash << 5) - hash + char;
    // Convert to 32 bits.
    hash = hash & hash;
  }
  return hash.toString();
};

export type ExchangeCodeRequest = {
  authId: AuthId;
  authType: AuthType;
  authConfig: AuthConfig;
  accessCode: string;
  pluginId: string;
  origin: string;
  grantedScope: string | null;
  integrationId: string | undefined; // used for embedded user only
  configurationId: string | undefined; // used for embedded user only
};

export type ExchangeCodeResponse = {
  successful: boolean;
  error?: string;
};

export function isExchangeCodeResponse(
  obj:
    | ExchangeCodeResponse
    | {
        success: boolean;
        result?: void;
        error?: string | undefined;
      }
): obj is ExchangeCodeResponse {
  return 'successful' in obj;
}

export type RequestTokenRequest = {
  datasourceId: string;
  username: string;
  password: string;
  environment?: string;
};

export type RequestTokenResponse = OAuthTokenResponse &
  OAuthErrorResponse & {
    expirationTimestamp?: number;
  };

export type DatasourceAuthState = DatasourceOneTimeState & {
  integrationId: string;
  configurationId: string;
  pluginId: string;
  authType: AuthType;
  authId: string;
  authConfig: {
    refreshTokenFromServer?: boolean;
    clientId: string;
    clientSecret: string;
    tokenUrl: string;
    authorizationUrl: string;
    userInfoUrl?: string;
    tokenScope: TokenScope;
    scope: string | undefined;
    clientAuthMethod?: ClientAuthMethod;
  };
  origin: string;
};

export function isDatasourceAuthState(aus: DatasourceAuthState | DatasourceOneTimeState): aus is DatasourceAuthState {
  return (
    'integrationId' in aus && 'configurationId' in aus && 'authType' in aus && 'authId' in aus && 'authConfig' in aus && 'origin' in aus
  );
}

export type DatasourceOneTimeState = {
  oneTimeCode: string;
  /**
   * Explicitly selects the ui-legacy localStorage callback handoff.
   * ui app and deployed-shell flows must leave this unset so the
   * dashboard callback uses postMessage only.
   */
  useLocalStorage?: boolean;
  integrationId: string;
  externalUser: boolean;
  // Optional disambiguator written by the deployed-embed shell so the
  // popup can pick the exact integration configuration without a Redux
  // profile lookup (the popup runs in the customer's logged-out browser
  // context and has no `organization.profiles` loaded).
  configurationId?: string;
  /** Profile key for agent-scoped auth when not supplied via postMessage. */
  profileKey?: string;
  /**
   * Origin of the window that opened the OAuth popup. When the opener
   * is cross-origin (e.g. a deployed app iframe on a CDN), the callback
   * page must postMessage with this targetOrigin instead of its own.
   */
  openerOrigin?: string;
};

export type DeleteDatasourceOnAgentResult = {
  message?: string;
  success: boolean;
};
