/* eslint-disable no-process-env, no-process-exit, max-statements */
import {type CliCommandContext, type CliOutputter, type CliPrompter} from '@sanity/cli'
import {type SanityClient} from '@sanity/client'
import {get} from 'lodash'
import oneline from 'oneline'
import {hideBin} from 'yargs/helpers'
import yargs from 'yargs/yargs'

import {debug} from '../../debug'
import {getClientUrl} from '../../util/getClientUrl'
import {getUrlHeaders} from '../../util/getUrlHeaders'
import {extractFromSanitySchema} from './extractFromSanitySchema'
import gen1 from './gen1'
import gen2 from './gen2'
import gen3 from './gen3'
import {getGraphQLAPIs} from './getGraphQLAPIs'
import {SchemaError} from './SchemaError'
import {type DeployResponse, type GeneratedApiSpecification, type ValidationResponse} from './types'

const latestGeneration = 'gen3'
const generations = {
  gen1,
  gen2,
  gen3,
}

const apiIdRegex = /^[a-z0-9_-]+$/
const isInteractive = process.stdout.isTTY && process.env.TERM !== 'dumb' && !('CI' in process.env)

const ignoredWarnings: string[] = ['OPTIONAL_INPUT_FIELD_ADDED']
const ignoredBreaking: string[] = []

interface DeployTask {
  dataset: string
  projectId: string
  tag: string
  enablePlayground: boolean
  schema: GeneratedApiSpecification
}

// eslint-disable-next-line complexity
export default async function deployGraphQLApiAction(
  args: {argv?: string[]},
  context: CliCommandContext,
): Promise<void> {
  // Reparsing CLI flags for better control of binary flags
  const flags = await parseCliFlags(args)
  const {
    force,
    dryRun,
    api: onlyApis,
    dataset: datasetFlag,
    tag: tagFlag,
    playground: playgroundFlag,
    generation: generationFlag,
    'non-null-document-fields': nonNullDocumentFieldsFlag,
    withUnionCache,
  } = flags

  const {apiClient, output, prompt} = context

  let spinner

  const client = apiClient({
    requireUser: true,
    // Don't throw if we do not have a project ID defined, as we will infer it from the
    // source/ workspace of each configured API later
    requireProject: false,
  }).config({apiVersion: '2023-08-01'})

  const apiDefs = await getGraphQLAPIs(context)
  const hasMultipleApis = apiDefs.length > 1 || (flags.api && flags.api.length > 1)
  const usedFlags = [
    datasetFlag && '--dataset',
    tagFlag && '--tag',
    typeof playgroundFlag !== 'undefined' && '--playground',
    typeof generationFlag !== 'undefined' && '--generation',
    typeof nonNullDocumentFieldsFlag !== 'undefined' && '--non-null-document-fields',
  ].filter(Boolean)

  if (hasMultipleApis && usedFlags.length > 0) {
    output.warn(`WARN: More than one API defined, and ${usedFlags.join('/')} is specified`)
    output.warn(`WARN: This will use the specified flag(s) for ALL APIs, overriding config!`)

    if (flags.force) {
      output.warn(`WARN: --force specified, continuing...`)
    } else if (
      !(await prompt.single({
        type: 'confirm',
        message: 'Continue with flag overrides for all APIs?',
        default: false,
      }))
    ) {
      process.exit(1)
    }
  }

  const deployTasks: DeployTask[] = []

  const apiNames = new Set<string>()
  const apiIds = new Set<string>()
  for (const apiDef of apiDefs) {
    const dataset = datasetFlag || apiDef.dataset
    const tag = tagFlag || apiDef.tag || 'default'
    const apiName = [dataset, tag].join('/')
    if (apiNames.has(apiName)) {
      throw new Error(`Multiple GraphQL APIs with the same dataset and tag found (${apiName})`)
    }

    if (apiDef.id) {
      if (typeof apiDef.id !== 'string' || !apiIdRegex.test(apiDef.id)) {
        throw new Error(
          `Invalid GraphQL API id "${apiDef.id}" - only a-z, 0-9, underscore and dashes are allowed`,
        )
      }

      if (apiIds.has(apiDef.id)) {
        throw new Error(`Multiple GraphQL APIs with the same ID found (${apiDef.id})`)
      }

      apiIds.add(apiDef.id)
    }

    apiNames.add(apiName)
  }

  for (const apiId of onlyApis || []) {
    if (!apiDefs.some((apiDef) => apiDef.id === apiId)) {
      throw new Error(`GraphQL API with id "${apiId}" not found`)
    }
  }

  if (onlyApis) {
    output.warn(`Deploying only specified APIs: ${onlyApis.join(', ')}`)
  }

  let index = -1
  for (const apiDef of apiDefs) {
    if (onlyApis && (!apiDef.id || !onlyApis.includes(apiDef.id))) {
      continue
    }

    index++

    const dataset = datasetFlag || apiDef.dataset
    const tag = tagFlag || apiDef.tag || 'default'
    const {projectId, playground, nonNullDocumentFields, schema} = apiDef
    const apiName = [dataset, tag].join('/')
    spinner = output.spinner(`Generating GraphQL API: ${apiName}`).start()

    if (!dataset) {
      throw new Error(`No dataset specified for API at index ${index}`)
    }

    const projectClient = client.clone().config({projectId, useProjectHostname: true})
    const {currentGeneration, playgroundEnabled} = await getCurrentSchemaProps(
      projectClient,
      dataset,
      tag,
    )

    // CLI flag trumps configuration
    const specifiedGeneration =
      typeof generationFlag === 'undefined' ? apiDef.generation : generationFlag

    const generation = await resolveApiGeneration({
      currentGeneration,
      specifiedGeneration,
      index,
      force,
      output,
      prompt,
    })

    if (!generation) {
      // User cancelled
      spinner.fail()
      continue
    }

    if (!isRecognizedApiGeneration(generation)) {
      throw new Error(`Unknown API generation "${generation}" for API at index ${index}`)
    }

    const enablePlayground = await shouldEnablePlayground({
      dryRun,
      spinner,
      playgroundCliFlag: playgroundFlag,
      playgroundConfiguration: playground,
      playgroundCurrentlyEnabled: playgroundEnabled,
      prompt,
    })

    let apiSpec: GeneratedApiSpecification
    try {
      const generateSchema = generations[generation]
      const extracted = extractFromSanitySchema(schema, {
        // Allow CLI flag to override configured setting
        nonNullDocumentFields:
          typeof nonNullDocumentFieldsFlag === 'undefined'
            ? nonNullDocumentFields
            : nonNullDocumentFieldsFlag,
        withUnionCache,
      })

      apiSpec = generateSchema(extracted, {filterSuffix: apiDef.filterSuffix})
    } catch (err) {
      spinner.fail()

      if (err instanceof SchemaError) {
        err.print(output)
        process.exit(1) // eslint-disable-line no-process-exit
      }

      throw err
    }

    let valid: ValidationResponse | undefined
    try {
      valid = await projectClient.request<ValidationResponse>({
        url: `/apis/graphql/${dataset}/${tag}/validate`,
        method: 'POST',
        body: {enablePlayground, schema: apiSpec},
        maxRedirects: 0,
      })
    } catch (err) {
      const validationError = get(err, 'response.body.validationError')
      spinner.fail()
      throw validationError ? new Error(validationError) : err
    }

    // when the result is not valid and there are breaking changes afoot!
    if (!isResultValid(valid, {spinner, force})) {
      // not valid and a dry run? then it can exit with a error
      if (dryRun) {
        spinner.fail()
        renderBreakingChanges(valid, output)
        process.exit(1)
      }

      if (!isInteractive) {
        spinner.fail()
        renderBreakingChanges(valid, output)
        throw new Error(
          'Dangerous changes found - falling back. Re-run the command with the `--force` flag to force deployment.',
        )
      }

      spinner.stop()
      renderBreakingChanges(valid, output)
      const shouldDeploy = await prompt.single({
        type: 'confirm',
        message: 'Do you want to deploy a new API despite the dangerous changes?',
        default: false,
      })

      if (!shouldDeploy) {
        spinner.fail()
        continue
      }

      spinner.succeed()
    } else if (dryRun) {
      spinner.succeed()
      output.print('GraphQL API is valid and has no breaking changes')
      process.exit(0)
    }

    deployTasks.push({
      projectId,
      dataset,
      tag,
      enablePlayground,
      schema: apiSpec,
    })
  }

  // Give some space for deployment tasks
  output.print('')

  for (const task of deployTasks) {
    const {dataset, tag, schema, projectId, enablePlayground} = task

    output.print(`Project: ${projectId}`)
    output.print(`Dataset: ${dataset}`)
    output.print(`Tag:     ${tag}`)

    spinner = output.spinner('Deploying GraphQL API').start()

    try {
      const projectClient = client.clone().config({projectId, useProjectHostname: true})
      const response = await projectClient.request<DeployResponse>({
        url: `/apis/graphql/${dataset}/${tag}`,
        method: 'PUT',
        body: {enablePlayground, schema},
        maxRedirects: 0,
      })

      spinner.stop()
      const apiUrl = getClientUrl(
        projectClient,
        response.location.replace(/^\/(v1|v\d{4}-\d{2}-\d{2})\//, '/'),
      )
      output.print(`URL:     ${apiUrl}`)
      spinner.start('Deployed!').succeed()
      output.print('')
    } catch (err) {
      spinner.fail()
      throw err
    }
  }

  // Because of side effects when loading the schema, we can end up in situations where
  // the API has been successfully deployed, but some timer or other handle is keeping
  // the process from naturally exiting.
  process.exit(0)
}

async function shouldEnablePlayground({
  dryRun,
  spinner,
  playgroundCliFlag,
  playgroundConfiguration,
  playgroundCurrentlyEnabled,
  prompt,
}: {
  dryRun: boolean
  spinner: ReturnType<CliCommandContext['output']['spinner']>
  playgroundCliFlag?: boolean
  playgroundConfiguration?: boolean
  playgroundCurrentlyEnabled?: boolean
  prompt: CliCommandContext['prompt']
}): Promise<boolean> {
  // On a dry run, it doesn't matter, return true 🤷‍♂️
  if (dryRun) {
    return true
  }

  // Prioritize CLI flag if set
  if (typeof playgroundCliFlag !== 'undefined') {
    return playgroundCliFlag
  }

  // If explicitly set true/false in configuration, use that
  if (typeof playgroundConfiguration !== 'undefined') {
    return playgroundConfiguration
  }

  // If API is already deployed, use the current state
  if (typeof playgroundCurrentlyEnabled !== 'undefined') {
    return playgroundCurrentlyEnabled
  }

  // If no API is deployed, default to true if non-interactive
  if (!isInteractive) {
    return true
  }

  // Interactive environment, so prompt the user
  const prevText = spinner.text
  spinner.warn()
  const shouldDeploy = await prompt.single<boolean>({
    type: 'confirm',
    message: 'Do you want to enable a GraphQL playground?',
    default: true,
  })
  spinner.clear().start(prevText)

  return shouldDeploy
}

async function getCurrentSchemaProps(
  client: SanityClient,
  dataset: string,
  tag: string,
): Promise<{
  currentGeneration?: string
  playgroundEnabled?: boolean
}> {
  try {
    const apiUrl = getClientUrl(client, `/apis/graphql/${dataset}/${tag}`)
    const res = await getUrlHeaders(apiUrl, {
      Authorization: `Bearer ${client.config().token}`,
    })

    return {
      currentGeneration: res['x-sanity-graphql-generation'],
      playgroundEnabled: res['x-sanity-graphql-playground'] === 'true',
    }
  } catch (err) {
    if (err.statusCode === 404) {
      return {}
    }

    throw err
  }
}

function parseCliFlags(args: {argv?: string[]}) {
  return yargs(hideBin(args.argv || process.argv).slice(2))
    .option('tag', {type: 'string'})
    .option('dataset', {type: 'string'})
    .option('api', {type: 'string', array: true})
    .option('dry-run', {type: 'boolean', default: false})
    .option('generation', {type: 'string'})
    .option('non-null-document-fields', {type: 'boolean'})
    .option('playground', {type: 'boolean'})
    .option('with-union-cache', {type: 'boolean'})
    .option('force', {type: 'boolean'}).argv
}

function isResultValid(
  valid: ValidationResponse,
  {spinner, force}: {spinner: any; force?: boolean},
) {
  const {validationError, breakingChanges: breaking, dangerousChanges: dangerous} = valid
  if (validationError) {
    spinner.fail()
    throw new Error(`GraphQL schema is not valid:\n\n${validationError}`)
  }

  const breakingChanges = breaking.filter((change) => !ignoredBreaking.includes(change.type))
  const dangerousChanges = dangerous.filter((change) => !ignoredWarnings.includes(change.type))

  const hasProblematicChanges = breakingChanges.length > 0 || dangerousChanges.length > 0
  if (force && hasProblematicChanges) {
    spinner.text = 'Validating GraphQL API: Dangerous changes. Forced with `--force`.'
    spinner.warn()
    return true
  } else if (force || !hasProblematicChanges) {
    spinner.succeed()
    return true
  }

  spinner.warn()
  return false
}

function renderBreakingChanges(valid: ValidationResponse, output: CliOutputter) {
  const {breakingChanges: breaking, dangerousChanges: dangerous} = valid

  const breakingChanges = breaking.filter((change) => !ignoredBreaking.includes(change.type))
  const dangerousChanges = dangerous.filter((change) => !ignoredWarnings.includes(change.type))

  if (dangerousChanges.length > 0) {
    output.print('\nFound potentially dangerous changes from previous schema:')
    dangerousChanges.forEach((change) => output.print(` - ${change.description}`))
  }

  if (breakingChanges.length > 0) {
    output.print('\nFound BREAKING changes from previous schema:')
    breakingChanges.forEach((change) => output.print(` - ${change.description}`))
  }

  output.print('')
}

async function resolveApiGeneration({
  currentGeneration,
  specifiedGeneration,
  index,
  force,
  output,
  prompt,
}: {
  index: number
  currentGeneration?: string
  specifiedGeneration?: string
  force?: boolean
  output: CliOutputter
  prompt: CliPrompter
}): Promise<string | undefined> {
  // a) If no API is currently deployed:
  //    use the specificed one from config, or use whichever generation is the latest
  // b) If an API generation is specified explicitly:
  //    use the given one, but _prompt_ if it differs from the current one
  // c) If no API generation is specified explicitly:
  //    use whichever is already deployed, but warn if differs from latest
  if (!currentGeneration) {
    const generation = specifiedGeneration || latestGeneration
    debug(
      'There is no current generation deployed, using %s (%s)',
      generation,
      specifiedGeneration ? 'specified' : 'default',
    )
    return generation
  }

  if (specifiedGeneration && specifiedGeneration !== currentGeneration) {
    if (!force && !isInteractive) {
      throw new Error(oneline`
        Specified generation (${specifiedGeneration}) for API at index ${index} differs from the one currently deployed (${currentGeneration}).
        Re-run the command with \`--force\` to force deployment.
      `)
    }

    output.warn(
      `Specified generation (${specifiedGeneration}) for API at index ${index} differs from the one currently deployed (${currentGeneration}).`,
    )

    const confirmDeploy =
      force ||
      (await prompt.single({
        type: 'confirm',
        message: 'Are you sure you want to deploy?',
        default: false,
      }))

    return confirmDeploy ? specifiedGeneration : undefined
  }

  if (specifiedGeneration) {
    debug('Using specified (%s) generation', specifiedGeneration)
    return specifiedGeneration
  }

  debug('Using the currently deployed version (%s)', currentGeneration)
  return currentGeneration
}

function isRecognizedApiGeneration(generation: string): generation is 'gen1' | 'gen2' | 'gen3' {
  return generations.hasOwnProperty(generation)
}
