import {createHash} from 'node:crypto'
import {mkdir, writeFile} from 'node:fs/promises'
import {dirname, join, resolve} from 'node:path'
import {Worker} from 'node:worker_threads'

import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli'
import chalk from 'chalk'
import {minutesToMilliseconds} from 'date-fns'
import readPkgUp from 'read-pkg-up'

import {
  type CreateManifest,
  type CreateWorkspaceManifest,
  type ManifestWorkspaceFile,
} from '../../../manifest/manifestTypes'
import {type ExtractManifestWorkerData} from '../../threads/extractManifest'
import {getTimer} from '../../util/timing'
import {SCHEMA_STORE_FEATURE_ENABLED} from '../schema/schemaStoreConstants'

export const MANIFEST_FILENAME = 'create-manifest.json'
const SCHEMA_FILENAME_SUFFIX = '.create-schema.json'
const TOOLS_FILENAME_SUFFIX = '.create-tools.json'

/** Escape-hatch env flags to change action behavior */
const FEATURE_ENABLED_ENV_NAME = 'SANITY_CLI_EXTRACT_MANIFEST_ENABLED'
const EXTRACT_MANIFEST_ENABLED = process.env[FEATURE_ENABLED_ENV_NAME] !== 'false'
const EXTRACT_MANIFEST_LOG_ERRORS = process.env.SANITY_CLI_EXTRACT_MANIFEST_LOG_ERRORS === 'true'

const CREATE_TIMER = 'create-manifest'

const EXTRACT_TASK_TIMEOUT_MS = minutesToMilliseconds(2)

const EXTRACT_FAILURE_MESSAGE =
  "↳ Couldn't extract manifest file. Sanity Create will not be available for the studio.\n" +
  `  Disable this message with ${FEATURE_ENABLED_ENV_NAME}=false`

export interface ExtractManifestFlags {
  path?: string
}

/**
 * This function will never throw.
 * @returns `undefined` if extract succeeded - caught error if it failed
 */
export async function extractManifestSafe(
  args: CliCommandArguments<ExtractManifestFlags>,
  context: CliCommandContext,
): Promise<Error | undefined> {
  if (!EXTRACT_MANIFEST_ENABLED) {
    return undefined
  }

  try {
    await extractManifest(args, context)
    return undefined
  } catch (err) {
    if (!SCHEMA_STORE_FEATURE_ENABLED) {
      // preserves current behavior while schema store is disabled
      context.output.print(
        chalk.gray(
          "↳ Couldn't extract manifest file. Sanity Create will not be available for the studio.\n" +
            `  Disable this message with ${FEATURE_ENABLED_ENV_NAME}=false`,
        ),
      )
    }
    if (EXTRACT_MANIFEST_LOG_ERRORS) {
      context.output.error(err)
    }
    return err
  }
}

async function extractManifest(
  args: CliCommandArguments<ExtractManifestFlags>,
  context: CliCommandContext,
): Promise<void> {
  const {output, workDir} = context

  const flags = args.extOptions
  const defaultOutputDir = resolve(join(workDir, 'dist'))

  const outputDir = resolve(defaultOutputDir)
  const defaultStaticPath = join(outputDir, 'static')

  const staticPath = flags.path ?? defaultStaticPath

  const path = join(staticPath, MANIFEST_FILENAME)

  const rootPkgPath = readPkgUp.sync({cwd: __dirname})?.path
  if (!rootPkgPath) {
    throw new Error('Could not find root directory for `sanity` package')
  }

  const timer = getTimer()
  timer.start(CREATE_TIMER)
  const spinner = output.spinner({}).start('Extracting manifest')

  try {
    const workspaceManifests = await getWorkspaceManifests({rootPkgPath, workDir})
    await mkdir(staticPath, {recursive: true})

    const workspaceFiles = await writeWorkspaceFiles(workspaceManifests, staticPath)

    const manifest: CreateManifest = {
      /**
       * Version history:
       * 1: Initial release.
       * 2: Added tools file.
       */
      version: 2,
      createdAt: new Date().toISOString(),
      workspaces: workspaceFiles,
    }

    await writeFile(path, JSON.stringify(manifest, null, 2))
    const manifestDuration = timer.end(CREATE_TIMER)

    spinner.succeed(`Extracted manifest (${manifestDuration.toFixed()}ms)`)
  } catch (err) {
    spinner.fail(err.message)
    throw err
  }
}

async function getWorkspaceManifests({
  rootPkgPath,
  workDir,
}: {
  rootPkgPath: string
  workDir: string
}): Promise<CreateWorkspaceManifest[]> {
  const workerPath = join(
    dirname(rootPkgPath),
    'lib',
    '_internal',
    'cli',
    'threads',
    'extractManifest.js',
  )

  const worker = new Worker(workerPath, {
    workerData: {workDir} satisfies ExtractManifestWorkerData,
    // eslint-disable-next-line no-process-env
    env: process.env,
  })

  let timeout = false
  const timeoutId = setTimeout(() => {
    timeout = true
    worker.terminate()
  }, EXTRACT_TASK_TIMEOUT_MS)

  try {
    return await new Promise<CreateWorkspaceManifest[]>((resolveWorkspaces, reject) => {
      const buffer: CreateWorkspaceManifest[] = []
      worker.addListener('message', (message) => buffer.push(message))
      worker.addListener('exit', (exitCode) => {
        if (exitCode === 0) {
          resolveWorkspaces(buffer)
        } else if (timeout) {
          reject(new Error(`Extract manifest was aborted after ${EXTRACT_TASK_TIMEOUT_MS}ms`))
        }
      })
      worker.addListener('error', reject)
    })
  } finally {
    clearTimeout(timeoutId)
  }
}

function writeWorkspaceFiles(
  manifestWorkspaces: CreateWorkspaceManifest[],
  staticPath: string,
): Promise<ManifestWorkspaceFile[]> {
  const output = manifestWorkspaces.reduce<Promise<ManifestWorkspaceFile>[]>(
    (workspaces, workspace) => {
      return [...workspaces, writeWorkspaceFile(workspace, staticPath)]
    },
    [],
  )
  return Promise.all(output)
}

async function writeWorkspaceFile(
  workspace: CreateWorkspaceManifest,
  staticPath: string,
): Promise<ManifestWorkspaceFile> {
  const [schemaFilename, toolsFilename] = await Promise.all([
    createFile(staticPath, workspace.schema, SCHEMA_FILENAME_SUFFIX),
    createFile(staticPath, workspace.tools, TOOLS_FILENAME_SUFFIX),
  ])

  return {
    ...workspace,
    schema: schemaFilename,
    tools: toolsFilename,
  }
}

const createFile = async (path: string, content: any, filenameSuffix: string) => {
  const stringifiedContent = JSON.stringify(content, null, 2)
  const hash = createHash('sha1').update(stringifiedContent).digest('hex')
  const filename = `${hash.slice(0, 8)}${filenameSuffix}`

  // workspaces with identical data will overwrite each others file. This is ok, since they are identical and can be shared
  await writeFile(join(path, filename), stringifiedContent)

  return filename
}
