import {
  existsSync,
  mkdirSync,
  readFileSync,
  readdirSync,
  renameSync,
  unlinkSync,
  writeFileSync,
} from 'node:fs'
import path from 'node:path'
import { cwd } from 'node:process'
import { fileURLToPath } from 'node:url'
import { checkbox, input } from '@inquirer/prompts'
import { Command, Flags } from '@oclif/core'
import { type FastStoreConfig, loadConfig } from '@vtex/fsp-config'
import merge from 'deepmerge'
import Handlebars from 'handlebars'
import ora from 'ora'
import * as prettier from 'prettier'
import type { PackageJson, PartialDeep } from 'type-fest'

import { moduleCliMap } from '../modules.js'
import { copyFile } from '../utils/copy-file.js'
import { getFileInfo } from '../utils/get-file-info.js'
import { getPackageLatestVersion } from '../utils/npm-registry.js'

interface TemplateData {
  accountName: string
  package: {
    name: string
    devDependencies: Record<string, string>
  }
}

export class Init extends Command {
  static args = {}

  static description =
    'Initialize a new FastStore monorepo project from scratch.'

  static examples = ['<%= config.bin %> <%= command.id %>']

  static flags = {
    'from-discovery': Flags.boolean({
      required: false,
      description:
        'Migrates the current faststore discovery to the monorepo structure',
    }),
  }

  async run(): Promise<void> {
    const currentConfig = await loadConfig()

    if (currentConfig?.stores) {
      this.error('Already initialized')
    }

    const { flags } = await this.parse(Init)

    if (flags['from-discovery']) {
      await this.migrate()
    } else {
      this.freshStart()
    }
  }

  /**
   * Starts a store fresh
   */
  private async freshStart(): Promise<void> {
    const appName = await input({
      message: 'What is the application name?',
      default: 'faststore-app',
    })

    if (appName.length === 0) {
      this.error('App name is required')
    }

    await this.execTemplate({
      templateName: 'default',
      destination: path.join(process.cwd(), appName),
      aditionalDependencies: ['@biomejs/biome', 'turbo'],
      optionalTemplateData: {
        package: {
          name: appName,
        },
      },
    })
  }

  /**
   * Use a template from the available templates
   */
  private async execTemplate(props: {
    templateName: string
    destination: string
    aditionalDependencies?: string[]
    optionalTemplateData?: PartialDeep<TemplateData>
  }) {
    const {
      templateName,
      destination,
      aditionalDependencies = [],
      optionalTemplateData = {},
    } = props

    try {
      this.log('Copying template files')

      const devDependencies = await this.fetchDevDependencies(
        aditionalDependencies
      )

      const templateData = merge(
        {
          package: {
            name: 'faststore-monorepo',
            devDependencies,
          },
        },
        optionalTemplateData
      )

      Handlebars.registerHelper('json', (context) => {
        return JSON.stringify(context, undefined, 2)
      })

      // __dirname is not defined in ESM
      const dirname = path.dirname(fileURLToPath(import.meta.url))
      const templatePath = path.join(dirname, '../src/templates', templateName)
      const templateDirectory = readdirSync(templatePath)

      for (const file of templateDirectory) {
        const fileInfo = getFileInfo(file)
        const filePath = path.join(templatePath, file)

        // For non-template files, we just copy the file
        if (fileInfo.extension !== '.hbs') {
          copyFile(templatePath, destination, file)
          continue
        }

        const parserOptions: Record<string, prettier.BuiltInParserName> = {
          '.json': 'json',
          '.js': 'babel',
          '.ts': 'babel-ts',
        }

        const fileBuffer = readFileSync(filePath)
        const handlebarsTemplate = Handlebars.compile(fileBuffer.toString())
        let textContent = handlebarsTemplate(templateData)
        /**
         * After the removal of the .hbs extension from the file name
         * The new extension will be included on the fileInfo.name
         *
         * For example:
         * --> Before: package.json.hbs
         * --> After: package.json
         *
         * To select the correct parser, we get the new file extension from the name
         */
        const newFileExtension = path.extname(fileInfo.name)
        const parser = parserOptions[newFileExtension]

        /**
         * We format the content if a parser is available
         */
        if (parser) {
          try {
            textContent = await prettier.format(textContent, {
              semi: false,
              singleQuote: true,
              trailingComma: 'es5',
              parser,
            })
          } catch (e) {
            console.error(e)
          }
        }

        writeFileSync(path.join(destination, fileInfo.name), textContent)
      }

      this.log('All files copied')
    } catch (e) {
      this.log('Could not copy files')
    }
  }

  private async fetchDevDependencies(
    additionalDeps: string[] = []
  ): Promise<Record<string, string>> {
    const devDependencies: Record<string, string> = {}

    const spinner = ora('Fetching dependencies')

    try {
      spinner.start()

      const allClis = Object.values(moduleCliMap)
      const dependenciesToFetch = [
        '@vtex/fsp-cli',
        ...allClis,
        ...additionalDeps,
      ]

      /**
       * Promise.all is used to run the promises in parallel.
       * We assemble the devDependencies object using this array index.
       */
      const appsVersion = await Promise.all(
        dependenciesToFetch.map(getPackageLatestVersion)
      )

      /**
       * We should always pick the latest stable release for each package.
       * All the possible CLIs are included as dependencies.
       * This enhances the ease of use.
       */
      dependenciesToFetch.forEach((dependency, index) => {
        devDependencies[dependency] = appsVersion[index]
      })

      return devDependencies
    } catch {
      spinner.fail('Could not fetch dependencies')
      return devDependencies
    } finally {
      spinner.succeed('All dependencies fetched')
    }
  }

  /**
   * Migrates an existent store to the monorepo struture
   */
  private async migrate(): Promise<void> {
    this.log('⚡ Starting the migration of your store')

    const sourceDir = cwd()
    const destDir = path.join(cwd(), 'packages/discovery')

    const files = readdirSync(sourceDir)

    if (files.length === 0) {
      return
    }

    /**
     * Files that are ignored by the script.
     * They will remain on the source directory.
     */
    const filesToIgnore: Record<string, boolean> = {
      '.git': true,
      '.github': true,
      'yarn.lock': true,
    }

    /**
     * Files that are unable to be ignored.
     * Their movement to the folder is obligatory.
     */
    const mandatoryFiles = {
      src: true,
      'vtex.env': true,
      'vercel.json': true,
      packages: true,
      public: true,
      'next-env.d.ts': true,
      'package.json': true,
      'faststore.config.js': true,
      'discovery.config.js': true,
      '.gitignore': true,
    }

    type MandatoryFile = keyof typeof mandatoryFiles

    /**
     * Files that must be renamed on move.
     * It must be one of Mandatory files.
     */
    const fileRenames: Partial<Record<MandatoryFile, string>> = {
      'faststore.config.js': 'discovery.config.js',
    }

    /**
     * Files that must be deleted.
     * It must be one of Mandatory files.
     */
    const filesToDelete: Partial<Record<MandatoryFile, boolean>> = {
      'vercel.json': true,
    }

    /**
     * Its pretty hard to define all the files that must continue on the root folder since projects have diferent needs.
     * This function asks for the user, files that must remain untouched.
     * Its important to avoid the CLI breaking the existent project.
     */
    const optionalFilesToIgnore = await checkbox({
      message: 'Choose the files that should remain on the repository',
      choices: files
        /**
         * Mandatory and ignored files are omited from the user options.
         * Displaying them would confuse the user.
         */
        .filter(
          (file) =>
            !filesToIgnore[file] && !mandatoryFiles[file as MandatoryFile]
        )
        .map((file) => ({
          name: file,
          value: file,
        })),
    })

    /**
     * The optional files are aded on the ignored files.
     * After this line, we have the complete map of files that will remain on the root folder.
     */
    for (const file of optionalFilesToIgnore) {
      filesToIgnore[file] = true
    }

    /**
     * We will reuse the next loop to get the account name
     */
    let accountName = ''

    for (const file of files) {
      if (filesToIgnore[file]) {
        continue
      }

      if (filesToDelete[file as MandatoryFile]) {
        try {
          unlinkSync(path.join(sourceDir, file))
          this.log(`✅ ${file} deleted`)
        } catch {
          this.log(`❌ Could not delete ${file}`)
        }

        continue
      }

      if (file === 'package.json') {
        const { name = '' }: PackageJson = JSON.parse(
          readFileSync(file).toString()
        )

        /**
         * Its known that the store follows the pattern <account-name>.store
         */
        accountName = String(name).replace('.store', '')
      }

      this.moveFile(
        sourceDir,
        destDir,
        file,
        fileRenames[file as MandatoryFile]
      )
    }

    await this.execTemplate({
      templateName: 'from-discovery',
      destination: sourceDir,
      aditionalDependencies: ['turbo'],
      optionalTemplateData: {
        accountName,
      },
    })

    this.log('🦄 Store migrated. You can install your packages with yarn.')
  }

  /**
   * Move a file between two directories.
   * It handles the creation of the destDir case its needed.
   * @param sourceDir Path of the source directory
   * @param destDir Path of the destination directory
   * @param file Name of the file
   */
  private moveFile(
    sourceDir: string,
    destDir: string,
    file: string,
    rename?: string
  ): void {
    // Create the destDir directory if it not exists
    if (!existsSync(destDir)) {
      mkdirSync(destDir, { recursive: true })
    }

    const sourceFile = path.join(sourceDir, file)
    const destFile = path.join(destDir, rename ?? file)

    try {
      renameSync(sourceFile, destFile)
      this.log(`✅ Moved: ${file}`)
    } catch {
      this.log(`❌ Could not move the file: ${file}`)
    }
  }
}
