// @ts-check

import path from 'path'
import fs from 'fs'
import postcssrc from 'postcss-load-config'
import { lilconfig } from 'lilconfig'
import loadPlugins from 'postcss-load-config/src/plugins' // Little bit scary, looking at private/internal API
import loadOptions from 'postcss-load-config/src/options' // Little bit scary, looking at private/internal API

import tailwind from '../../processTailwindFeatures'
import { loadAutoprefixer, loadCssNano, loadPostcss, loadPostcssImport } from './deps'
import { formatNodes, drainStdin, outputFile } from './utils'
import { env } from '../../lib/sharedState'
import resolveConfig from '../../../resolveConfig.js'
import { createBroadPatternCheck, parseCandidateFiles } from '../../lib/content.js'
import { createWatcher } from './watching.js'
import fastGlob from 'fast-glob'
import { findAtConfigPath } from '../../lib/findAtConfigPath.js'
import log from '../../util/log'
import { loadConfig } from '../../lib/load-config'
import getModuleDependencies from '../../lib/getModuleDependencies'

/**
 *
 * @param {string} [customPostCssPath ]
 * @returns
 */
async function loadPostCssPlugins(customPostCssPath) {
  let config = customPostCssPath
    ? await (async () => {
        let file = path.resolve(customPostCssPath)

        // Implementation, see: https://unpkg.com/browse/postcss-load-config@3.1.0/src/index.js
        // @ts-ignore
        let { config = {} } = await lilconfig('postcss').load(file)
        if (typeof config === 'function') {
          config = config()
        } else {
          config = Object.assign({}, config)
        }

        if (!config.plugins) {
          config.plugins = []
        }

        // We have to await these because in v5 and v6 of postcss-load-config
        // these functions return promises while they don't in v4. Awaiting a
        // non-promise is basically a no-op so this is safe to do.
        return {
          file,
          plugins: await loadPlugins(config, file),
          options: await loadOptions(config, file),
        }
      })()
    : await postcssrc()

  let configPlugins = config.plugins

  let configPluginTailwindIdx = configPlugins.findIndex((plugin) => {
    if (typeof plugin === 'function' && plugin.name === 'tailwindcss') {
      return true
    }

    if (typeof plugin === 'object' && plugin !== null && plugin.postcssPlugin === 'tailwindcss') {
      return true
    }

    return false
  })

  let beforePlugins =
    configPluginTailwindIdx === -1 ? [] : configPlugins.slice(0, configPluginTailwindIdx)
  let afterPlugins =
    configPluginTailwindIdx === -1
      ? configPlugins
      : configPlugins.slice(configPluginTailwindIdx + 1)

  return [beforePlugins, afterPlugins, config.options]
}

function loadBuiltinPostcssPlugins() {
  let postcss = loadPostcss()
  let IMPORT_COMMENT = '__TAILWIND_RESTORE_IMPORT__: '
  return [
    [
      (root) => {
        root.walkAtRules('import', (rule) => {
          if (rule.params.slice(1).startsWith('tailwindcss/')) {
            rule.after(postcss.comment({ text: IMPORT_COMMENT + rule.params }))
            rule.remove()
          }
        })
      },
      loadPostcssImport(),
      (root) => {
        root.walkComments((rule) => {
          if (rule.text.startsWith(IMPORT_COMMENT)) {
            rule.after(
              postcss.atRule({
                name: 'import',
                params: rule.text.replace(IMPORT_COMMENT, ''),
              })
            )
            rule.remove()
          }
        })
      },
    ],
    [],
    {},
  ]
}

let state = {
  /** @type {any} */
  context: null,

  /** @type {ReturnType<typeof createWatcher> | null} */
  watcher: null,

  /** @type {{content: string, extension: string}[]} */
  changedContent: [],

  /** @type {ReturnType<typeof load> | null} */
  configBag: null,

  contextDependencies: new Set(),

  /** @type {import('../../lib/content.js').ContentPath[]} */
  contentPaths: [],

  refreshContentPaths() {
    this.contentPaths = parseCandidateFiles(this.context, this.context?.tailwindConfig)
  },

  get config() {
    return this.context.tailwindConfig
  },

  get contentPatterns() {
    return {
      all: this.contentPaths.map((contentPath) => contentPath.pattern),
      dynamic: this.contentPaths
        .filter((contentPath) => contentPath.glob !== undefined)
        .map((contentPath) => contentPath.pattern),
    }
  },

  loadConfig(configPath, content) {
    if (this.watcher && configPath) {
      this.refreshConfigDependencies()
    }

    let config = loadConfig(configPath)
    let dependencies = getModuleDependencies(configPath)
    this.configBag = {
      config,
      dependencies,
      dispose() {
        for (let file of dependencies) {
          delete require.cache[require.resolve(file)]
        }
      },
    }

    // @ts-ignore
    this.configBag.config = resolveConfig(this.configBag.config, { content: { files: [] } })

    // Override content files if `--content` has been passed explicitly
    if (content?.length > 0) {
      this.configBag.config.content.files = content
    }

    return this.configBag.config
  },

  refreshConfigDependencies() {
    env.DEBUG && console.time('Module dependencies')
    this.configBag?.dispose()
    env.DEBUG && console.timeEnd('Module dependencies')
  },

  readContentPaths() {
    let content = []

    // Resolve globs from the content config
    // TODO: When we make the postcss plugin async-capable this can become async
    let files = fastGlob.sync(this.contentPatterns.all)

    let checkBroadPattern = createBroadPatternCheck(this.contentPatterns.all)

    for (let file of files) {
      checkBroadPattern(file)

      content.push({
        content: fs.readFileSync(path.resolve(file), 'utf8'),
        extension: path.extname(file).slice(1),
      })
    }

    // Resolve raw content in the tailwind config
    let rawContent = this.config.content.files.filter((file) => {
      return file !== null && typeof file === 'object'
    })

    for (let { raw: htmlContent, extension = 'html' } of rawContent) {
      content.push({ content: htmlContent, extension })
    }

    return content
  },

  getContext({ createContext, cliConfigPath, root, result, content }) {
    env.DEBUG && console.time('Searching for config')
    let configPath = findAtConfigPath(root, result) ?? cliConfigPath
    env.DEBUG && console.timeEnd('Searching for config')

    if (this.context) {
      this.context.changedContent = this.changedContent.splice(0)

      return this.context
    }

    env.DEBUG && console.time('Loading config')
    let config = this.loadConfig(configPath, content)
    env.DEBUG && console.timeEnd('Loading config')

    env.DEBUG && console.time('Creating context')
    this.context = createContext(config, [])
    Object.assign(this.context, {
      userConfigPath: configPath,
    })
    env.DEBUG && console.timeEnd('Creating context')

    env.DEBUG && console.time('Resolving content paths')
    this.refreshContentPaths()
    env.DEBUG && console.timeEnd('Resolving content paths')

    if (this.watcher) {
      env.DEBUG && console.time('Watch new files')
      this.watcher.refreshWatchedFiles()
      env.DEBUG && console.timeEnd('Watch new files')
    }

    for (let file of this.readContentPaths()) {
      this.context.changedContent.push(file)
    }

    return this.context
  },
}

export async function createProcessor(args, cliConfigPath) {
  let postcss = loadPostcss()

  let input = args['--input']
  let output = args['--output']
  let includePostCss = args['--postcss']
  let customPostCssPath = typeof args['--postcss'] === 'string' ? args['--postcss'] : undefined

  let [beforePlugins, afterPlugins, postcssOptions] = includePostCss
    ? await loadPostCssPlugins(customPostCssPath)
    : loadBuiltinPostcssPlugins()

  if (args['--purge']) {
    log.warn('purge-flag-deprecated', [
      'The `--purge` flag has been deprecated.',
      'Please use `--content` instead.',
    ])

    if (!args['--content']) {
      args['--content'] = args['--purge']
    }
  }

  let content = args['--content']?.split(/(?<!{[^}]+),/) ?? []

  let tailwindPlugin = () => {
    return {
      postcssPlugin: 'tailwindcss',
      async Once(root, { result }) {
        env.DEBUG && console.time('Compiling CSS')
        await tailwind(({ createContext }) => {
          console.error()
          console.error('Rebuilding...')

          return () => {
            return state.getContext({
              createContext,
              cliConfigPath,
              root,
              result,
              content,
            })
          }
        })(root, result)
        env.DEBUG && console.timeEnd('Compiling CSS')
      },
    }
  }

  tailwindPlugin.postcss = true

  let plugins = [
    ...beforePlugins,
    tailwindPlugin,
    !args['--minify'] && formatNodes,
    ...afterPlugins,
    !args['--no-autoprefixer'] && loadAutoprefixer(),
    args['--minify'] && loadCssNano(),
  ].filter(Boolean)

  /** @type {import('postcss').Processor} */
  // @ts-ignore
  let processor = postcss(plugins)

  async function readInput() {
    // Piping in data, let's drain the stdin
    if (input === '-') {
      return drainStdin()
    }

    // Input file has been provided
    if (input) {
      return fs.promises.readFile(path.resolve(input), 'utf8')
    }

    // No input file provided, fallback to default at-rules
    return '@tailwind base; @tailwind components; @tailwind utilities'
  }

  async function build() {
    let start = process.hrtime.bigint()

    return readInput()
      .then((css) => processor.process(css, { ...postcssOptions, from: input, to: output }))
      .then((result) => {
        if (!state.watcher) {
          return result
        }

        env.DEBUG && console.time('Recording PostCSS dependencies')
        for (let message of result.messages) {
          if (message.type === 'dependency') {
            state.contextDependencies.add(message.file)
          }
        }
        env.DEBUG && console.timeEnd('Recording PostCSS dependencies')

        // TODO: This needs to be in a different spot
        env.DEBUG && console.time('Watch new files')
        state.watcher.refreshWatchedFiles()
        env.DEBUG && console.timeEnd('Watch new files')

        return result
      })
      .then((result) => {
        if (!output) {
          process.stdout.write(result.css)
          return
        }

        return Promise.all([
          outputFile(result.opts.to, result.css),
          result.map && outputFile(result.opts.to + '.map', result.map.toString()),
        ])
      })
      .then(() => {
        let end = process.hrtime.bigint()
        console.error()
        console.error('Done in', (end - start) / BigInt(1e6) + 'ms.')
      })
      .then(
        () => {},
        (err) => {
          // TODO: If an initial build fails we can't easily pick up any PostCSS dependencies
          // that were collected before the error occurred
          // The result is not stored on the error so we have to store it externally
          // and pull the messages off of it here somehow

          // This results in a less than ideal DX because the watcher will not pick up
          // changes to imported CSS if one of them caused an error during the initial build
          // If you fix it and then save the main CSS file so there's no error
          // The watcher will start watching the imported CSS files and will be
          // resilient to future errors.

          if (state.watcher) {
            console.error(err)
          } else {
            return Promise.reject(err)
          }
        }
      )
  }

  /**
   * @param {{file: string, content(): Promise<string>, extension: string}[]} changes
   */
  async function parseChanges(changes) {
    return Promise.all(
      changes.map(async (change) => ({
        content: await change.content(),
        extension: change.extension,
      }))
    )
  }

  if (input !== undefined && input !== '-') {
    state.contextDependencies.add(path.resolve(input))
  }

  return {
    build,
    watch: async () => {
      state.watcher = createWatcher(args, {
        state,

        /**
         * @param {{file: string, content(): Promise<string>, extension: string}[]} changes
         */
        async rebuild(changes) {
          let needsNewContext = changes.some((change) => {
            return (
              state.configBag?.dependencies.has(change.file) ||
              state.contextDependencies.has(change.file)
            )
          })

          if (needsNewContext) {
            state.context = null
          } else {
            for (let change of await parseChanges(changes)) {
              state.changedContent.push(change)
            }
          }

          return build()
        },
      })

      await build()
    },
  }
}
