import { version } from '../package.json'
import { substituteAtApply } from './apply'
import { comment, decl, rule, toCss, walk, WalkAction, type Rule } from './ast'
import type { UserConfig } from './compat/config/types'
import { compileCandidates } from './compile'
import { substituteFunctions, THEME_FUNCTION_INVOCATION } from './css-functions'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { registerPlugins, type CssPluginOptions, type Plugin } from './plugin-api'
import { Theme } from './theme'
import { segment } from './utils/segment'

const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/

type CompileOptions = {
  loadPlugin?: (path: string) => Promise<Plugin>
  loadConfig?: (path: string) => Promise<UserConfig>
}

function throwOnPlugin(): never {
  throw new Error('No `loadPlugin` function provided to `compile`')
}

function throwOnConfig(): never {
  throw new Error('No `loadConfig` function provided to `compile`')
}

function parseThemeOptions(selector: string) {
  let isReference = false
  let isInline = false
  let isDefault = false

  for (let option of segment(selector.slice(6) /* '@theme'.length */, ' ')) {
    if (option === 'reference') {
      isReference = true
    } else if (option === 'inline') {
      isInline = true
    } else if (option === 'default') {
      isDefault = true
    }
  }

  return { isReference, isInline, isDefault }
}

async function parseCss(
  css: string,
  { loadPlugin = throwOnPlugin, loadConfig = throwOnConfig }: CompileOptions = {},
) {
  let ast = CSS.parse(css)

  // Find all `@theme` declarations
  let theme = new Theme()
  let pluginPaths: [string, CssPluginOptions | null][] = []
  let configPaths: string[] = []
  let customVariants: ((designSystem: DesignSystem) => void)[] = []
  let customUtilities: ((designSystem: DesignSystem) => void)[] = []
  let firstThemeRule: Rule | null = null
  let keyframesRules: Rule[] = []
  let globs: { origin?: string; pattern: string }[] = []

  walk(ast, (node, { parent, replaceWith }) => {
    if (node.kind !== 'rule') return

    // Collect paths from `@plugin` at-rules
    if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) {
      if (parent !== null) {
        throw new Error('`@plugin` cannot be nested.')
      }

      let pluginPath = node.selector.slice(9, -1)
      if (pluginPath.length === 0) {
        throw new Error('`@plugin` must have a path.')
      }

      let options: CssPluginOptions = {}

      for (let decl of node.nodes ?? []) {
        if (decl.kind !== 'declaration') {
          throw new Error(
            `Unexpected \`@plugin\` option:\n\n${toCss([decl])}\n\n\`@plugin\` options must be a flat list of declarations.`,
          )
        }

        if (decl.value === undefined) continue

        // Parse the declaration value as a primitive type
        // These are the same primitive values supported by JSON
        let value: CssPluginOptions[keyof CssPluginOptions] = decl.value

        let parts = segment(value, ',').map((part) => {
          part = part.trim()

          if (part === 'null') {
            return null
          } else if (part === 'true') {
            return true
          } else if (part === 'false') {
            return false
          } else if (!Number.isNaN(Number(part))) {
            return Number(part)
          } else if (
            (part[0] === '"' && part[part.length - 1] === '"') ||
            (part[0] === "'" && part[part.length - 1] === "'")
          ) {
            return part.slice(1, -1)
          } else if (part[0] === '{' && part[part.length - 1] === '}') {
            throw new Error(
              `Unexpected \`@plugin\` option: Value of declaration \`${toCss([decl]).trim()}\` is not supported.\n\nUsing an object as a plugin option is currently only supported in JavaScript configuration files.`,
            )
          }

          return part
        })

        options[decl.property] = parts.length === 1 ? parts[0] : parts
      }

      pluginPaths.push([pluginPath, Object.keys(options).length > 0 ? options : null])

      replaceWith([])
      return
    }

    // Collect paths from `@config` at-rules
    if (node.selector === '@config' || node.selector.startsWith('@config ')) {
      if (node.nodes.length > 0) {
        throw new Error('`@config` cannot have a body.')
      }

      if (parent !== null) {
        throw new Error('`@config` cannot be nested.')
      }

      configPaths.push(node.selector.slice(9, -1))
      replaceWith([])
      return
    }

    // Collect custom `@utility` at-rules
    if (node.selector.startsWith('@utility ')) {
      let name = node.selector.slice(9).trim()

      if (!IS_VALID_UTILITY_NAME.test(name)) {
        throw new Error(
          `\`@utility ${name}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`,
        )
      }

      if (node.nodes.length === 0) {
        throw new Error(
          `\`@utility ${name}\` is empty. Utilities should include at least one property.`,
        )
      }

      customUtilities.push((designSystem) => {
        designSystem.utilities.static(name, (candidate) => {
          if (candidate.negative) return
          return structuredClone(node.nodes)
        })
      })

      return
    }

    // Collect paths from `@source` at-rules
    if (node.selector.startsWith('@source ')) {
      if (node.nodes.length > 0) {
        throw new Error('`@source` cannot have a body.')
      }

      if (parent !== null) {
        throw new Error('`@source` cannot be nested.')
      }

      let path = node.selector.slice(8)
      if (
        (path[0] === '"' && path[path.length - 1] !== '"') ||
        (path[0] === "'" && path[path.length - 1] !== "'") ||
        (path[0] !== "'" && path[0] !== '"')
      ) {
        throw new Error('`@source` paths must be quoted.')
      }
      globs.push({ pattern: path.slice(1, -1) })
      replaceWith([])
      return
    }

    // Register custom variants from `@variant` at-rules
    if (node.selector.startsWith('@variant ')) {
      if (parent !== null) {
        throw new Error('`@variant` cannot be nested.')
      }

      // Remove `@variant` at-rule so it's not included in the compiled CSS
      replaceWith([])

      let [name, selector] = segment(node.selector.slice(9), ' ')

      if (node.nodes.length > 0 && selector) {
        throw new Error(`\`@variant ${name}\` cannot have both a selector and a body.`)
      }

      // Variants with a selector, but without a body, e.g.: `@variant hocus (&:hover, &:focus);`
      if (node.nodes.length === 0) {
        if (!selector) {
          throw new Error(`\`@variant ${name}\` has no selector or body.`)
        }

        let selectors = segment(selector.slice(1, -1), ',')

        customVariants.push((designSystem) => {
          designSystem.variants.static(name, (r) => {
            r.nodes = selectors.map((selector) => rule(selector, r.nodes))
          })
        })

        return
      }

      // Variants without a selector, but with a body:
      //
      // E.g.:
      //
      // ```css
      // @variant hocus {
      //   &:hover {
      //     @slot;
      //   }
      //
      //   &:focus {
      //     @slot;
      //   }
      // }
      // ```
      else {
        customVariants.push((designSystem) => {
          designSystem.variants.fromAst(name, node.nodes)
        })

        return
      }
    }

    // Drop instances of `@media theme(…)`
    //
    // We support `@import "tailwindcss/theme" theme(reference)` as a way to
    // import an external theme file as a reference, which becomes `@media
    // theme(reference) { … }` when the `@import` is processed.
    if (node.selector.startsWith('@media theme(')) {
      let themeParams = node.selector.slice(13, -1)

      walk(node.nodes, (child) => {
        if (child.kind !== 'rule') {
          throw new Error(
            'Files imported with `@import "…" theme(…)` must only contain `@theme` blocks.',
          )
        }
        if (child.selector === '@theme' || child.selector.startsWith('@theme ')) {
          child.selector += ' ' + themeParams
          return WalkAction.Skip
        }
      })
      replaceWith(node.nodes)
      return WalkAction.Skip
    }

    if (node.selector !== '@theme' && !node.selector.startsWith('@theme ')) return

    let { isReference, isInline, isDefault } = parseThemeOptions(node.selector)

    // Record all custom properties in the `@theme` declaration
    walk(node.nodes, (child, { replaceWith }) => {
      // Collect `@keyframes` rules to re-insert with theme variables later,
      // since the `@theme` rule itself will be removed.
      if (child.kind === 'rule' && child.selector.startsWith('@keyframes ')) {
        keyframesRules.push(child)
        replaceWith([])
        return WalkAction.Skip
      }

      if (child.kind === 'comment') return
      if (child.kind === 'declaration' && child.property.startsWith('--')) {
        theme.add(child.property, child.value ?? '', { isReference, isInline, isDefault })
        return
      }

      let snippet = toCss([rule(node.selector, [child])])
        .split('\n')
        .map((line, idx, all) => `${idx === 0 || idx >= all.length - 2 ? ' ' : '>'} ${line}`)
        .join('\n')

      throw new Error(
        `\`@theme\` blocks must only contain custom properties or \`@keyframes\`.\n\n${snippet}`,
      )
    })

    // Keep a reference to the first `@theme` rule to update with the full theme
    // later, and delete any other `@theme` rules.
    if (!firstThemeRule && !isReference) {
      firstThemeRule = node
    } else {
      replaceWith([])
    }
    return WalkAction.Skip
  })

  let designSystem = buildDesignSystem(theme)

  let configs = await Promise.all(
    configPaths.map(async (configPath) => ({
      path: configPath,
      config: await loadConfig(configPath),
    })),
  )

  let plugins = await Promise.all(
    pluginPaths.map(async ([pluginPath, pluginOptions]) => ({
      path: pluginPath,
      plugin: await loadPlugin(pluginPath),
      options: pluginOptions,
    })),
  )

  let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs)

  for (let customVariant of customVariants) {
    customVariant(designSystem)
  }

  for (let customUtility of customUtilities) {
    customUtility(designSystem)
  }

  // Output final set of theme variables at the position of the first `@theme`
  // rule.
  if (firstThemeRule) {
    firstThemeRule = firstThemeRule as Rule
    firstThemeRule.selector = ':root'

    let nodes = []

    for (let [key, value] of theme.entries()) {
      if (value.isReference) continue
      nodes.push(decl(key, value.value))
    }

    if (keyframesRules.length > 0) {
      let animationParts = [...theme.namespace('--animate').values()].flatMap((animation) =>
        animation.split(' '),
      )

      for (let keyframesRule of keyframesRules) {
        // Remove any keyframes that aren't used by an animation variable.
        let keyframesName = keyframesRule.selector.slice(11) // `@keyframes `.length
        if (!animationParts.includes(keyframesName)) {
          continue
        }

        // Wrap `@keyframes` in `@at-root` so they are hoisted out of `:root`
        // when printing.
        nodes.push(
          Object.assign(keyframesRule, {
            selector: '@at-root',
            nodes: [rule(keyframesRule.selector, keyframesRule.nodes)],
          }),
        )
      }
    }
    firstThemeRule.nodes = nodes
  }

  // Replace `@apply` rules with the actual utility classes.
  if (css.includes('@apply')) {
    substituteAtApply(ast, designSystem)
  }

  // Replace `theme()` function calls with the actual theme variables. Plugins
  // could register new rules that include functions, and JS config files could
  // also contain functions or plugins that use functions so we need to evaluate
  // functions if either of those are present.
  if (plugins.length > 0 || configs.length > 0 || css.includes(THEME_FUNCTION_INVOCATION)) {
    substituteFunctions(ast, pluginApi)
  }

  // Remove `@utility`, we couldn't replace it before yet because we had to
  // handle the nested `@apply` at-rules first.
  walk(ast, (node, { replaceWith }) => {
    if (node.kind !== 'rule') return

    if (node.selector[0] === '@' && node.selector.startsWith('@utility ')) {
      replaceWith([])
    }

    // The `@utility` has to be top-level, therefore we don't have to traverse
    // into nested trees.
    return WalkAction.Skip
  })

  for (let file of resolvedConfig.content.files) {
    if ('raw' in file) {
      throw new Error(
        `Error in the config file/plugin/preset. The \`content\` key contains a \`raw\` entry:\n\n${JSON.stringify(file, null, 2)}\n\nThis feature is not currently supported.`,
      )
    }

    globs.push({ origin: file.base, pattern: file.pattern })
  }

  return {
    designSystem,
    pluginApi,
    ast,
    globs,
  }
}

export async function compile(
  css: string,
  opts: CompileOptions = {},
): Promise<{
  globs: { origin?: string; pattern: string }[]
  build(candidates: string[]): string
}> {
  let { designSystem, ast, globs, pluginApi } = await parseCss(css, opts)

  let tailwindUtilitiesNode: Rule | null = null

  // Find `@tailwind utilities` so that we can later replace it with the actual
  // generated utility class CSS.
  walk(ast, (node) => {
    if (node.kind === 'rule' && node.selector === '@tailwind utilities') {
      tailwindUtilitiesNode = node

      // Stop walking after finding `@tailwind utilities` to avoid walking all
      // of the generated CSS. This means `@tailwind utilities` can only appear
      // once per file but that's the intended usage at this point in time.
      return WalkAction.Stop
    }
  })

  if (process.env.NODE_ENV !== 'test') {
    ast.unshift(comment(`! tailwindcss v${getVersion()} | MIT License | https://tailwindcss.com `))
  }

  // Track all invalid candidates
  let invalidCandidates = new Set<string>()
  function onInvalidCandidate(candidate: string) {
    invalidCandidates.add(candidate)
  }

  // Track all valid candidates, these are the incoming `rawCandidate` that
  // resulted in a generated AST Node. All the other `rawCandidates` are invalid
  // and should be ignored.
  let allValidCandidates = new Set<string>()
  let compiledCss = toCss(ast)
  let previousAstNodeCount = 0

  return {
    globs,
    build(newRawCandidates: string[]) {
      let didChange = false

      // Add all new candidates unless we know that they are invalid.
      let prevSize = allValidCandidates.size
      for (let candidate of newRawCandidates) {
        if (!invalidCandidates.has(candidate)) {
          allValidCandidates.add(candidate)
          didChange ||= allValidCandidates.size !== prevSize
        }
      }

      // If no new candidates were added, we can return the original CSS. This
      // currently assumes that we only add new candidates and never remove any.
      if (!didChange) {
        return compiledCss
      }

      if (tailwindUtilitiesNode) {
        let newNodes = compileCandidates(allValidCandidates, designSystem, {
          onInvalidCandidate,
        }).astNodes

        // If no new ast nodes were generated, then we can return the original
        // CSS. This currently assumes that we only add new ast nodes and never
        // remove any.
        if (previousAstNodeCount === newNodes.length) {
          return compiledCss
        }

        // Arbitrary values (`text-[theme(--color-red-500)]`) and arbitrary
        // properties (`[--my-var:theme(--color-red-500)]`) can contain function
        // calls so we need evaluate any functions we find there that weren't in
        // the source CSS.
        substituteFunctions(newNodes, pluginApi)

        previousAstNodeCount = newNodes.length

        tailwindUtilitiesNode.nodes = newNodes
        compiledCss = toCss(ast)
      }

      return compiledCss
    },
  }
}

export async function __unstable__loadDesignSystem(css: string, opts: CompileOptions = {}) {
  let result = await parseCss(css, opts)
  return result.designSystem
}

function getVersion() {
  if (process.env.VERSION) {
    return process.env.VERSION
  } else {
    return version
  }
}