import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import fs from 'node:fs/promises'
import path from 'node:path'
import { createInterface } from 'node:readline'
import type { Readable, Writable } from 'node:stream'
import { compare } from '../../../../tailwindcss/src/utils/compare'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { args, type Arg } from '../../utils/args'
import { help } from '../help'

const css = String.raw
export type OutputFormat = 'text' | 'json' | 'jsonl'

export interface CandidateGroupResult {
  input: string
  output: string
  changed: boolean
}

export interface RunCommandLineOptions {
  argv?: string[]
  cwd?: string
  stdin?: string | null
  stdinIsTTY?: boolean
  stdoutIsTTY?: boolean
}

export interface RunCommandLineResult {
  exitCode: number
  stdout: string
  stderr: string
}

export function usage() {
  return 'tailwindcss canonicalize [classes...]'
}

function usageWithCss() {
  return 'tailwindcss canonicalize --css input.css [classes...]'
}

function usageWithStream() {
  return 'tailwindcss canonicalize --stream [--css input.css]'
}

export function options() {
  return {
    '--css': {
      type: 'string',
      description:
        'CSS entry file used to load the Tailwind design system (defaults to `@import "tailwindcss";`)',
    },
    '--format': {
      type: 'string',
      description: 'Output format',
      default: 'text',
      values: ['text', 'json', 'jsonl'],
    },
    '--stream': {
      type: 'boolean',
      description: 'Read candidate groups from stdin line by line and write results to stdout',
      default: false,
    },
  } satisfies Arg
}

const sharedOptions = {
  '--help': {
    type: 'boolean',
    description: 'Display usage information',
    alias: '-h',
    default: false,
  },
} satisfies Arg

export async function runCommandLine({
  argv = process.argv.slice(2),
  cwd = process.cwd(),
  stdin = null,
  stdinIsTTY = process.stdin.isTTY,
  stdoutIsTTY = process.stdout.isTTY,
}: RunCommandLineOptions = {}): Promise<RunCommandLineResult> {
  try {
    let flags = args(
      {
        ...options(),
        ...sharedOptions,
      },
      argv,
    )

    let format = parseFormat(flags['--format'] ?? 'text')

    if ((stdoutIsTTY && argv.length === 0) || flags['--help']) {
      return {
        exitCode: 0,
        stdout: helpMessage() ?? '',
        stderr: '',
      }
    }

    if (flags['--stream']) {
      await streamStdin({
        css: flags['--css'],
        cwd,
        format,
        input: process.stdin,
        output: process.stdout,
      })
      return { exitCode: 0, stdout: '', stderr: '' }
    }

    let inputs = flags._.length > 0 ? flags._ : await readCandidateGroups({ stdin, stdinIsTTY })

    if (inputs.length === 0) {
      return usageError('No candidate groups provided')
    }

    let results = await processCandidateGroups({
      css: flags['--css'],
      cwd,
      inputs,
    })

    return {
      exitCode: 0,
      stdout: formatCandidateResults(results, format),
      stderr: '',
    }
  } catch (error) {
    return {
      exitCode: 1,
      stdout: '',
      stderr: error instanceof Error ? error.message : String(error),
    }
  }
}

export async function streamStdin({
  css: cssFile,
  cwd,
  format,
  input,
  output,
}: {
  css: string | null
  cwd: string
  format: OutputFormat
  input: Readable
  output: Writable
}): Promise<void> {
  let designSystem = await loadDesignSystem(cssFile, cwd)
  let rl = createInterface({ input })
  let first = true

  if (format === 'json') {
    output.write('[')
  }

  for await (let line of rl) {
    let result = createCandidateGroupResult(designSystem, line)

    switch (format) {
      case 'text': {
        output.write(result.output + '\n')
        break
      }

      case 'jsonl': {
        output.write(JSON.stringify(result) + '\n')
        break
      }

      case 'json': {
        if (first) {
          output.write('\n')
        } else {
          output.write(',\n')
        }

        output.write(indent(JSON.stringify(result, null, 2), 2))
        first = false
        break
      }
    }
  }

  if (format === 'json') {
    output.write(first ? ']' : '\n]')
  }
}

export function readCandidateGroups({
  stdin,
  stdinIsTTY,
}: {
  stdin: string | null
  stdinIsTTY: boolean
}) {
  if (stdin !== null) {
    return Promise.resolve(splitCandidateGroups(stdin))
  }

  if (stdinIsTTY) {
    return Promise.resolve([])
  }

  return drainStdin().then(splitCandidateGroups)
}

export async function drainStdin() {
  return new Promise<string>((resolve, reject) => {
    let result = ''

    process.stdin.on('data', (chunk) => {
      result += chunk
    })

    process.stdin.on('end', () => resolve(result))
    process.stdin.on('error', (err) => reject(err))
  })
}

export async function processCandidateGroups({
  css,
  cwd = process.cwd(),
  inputs,
}: {
  css: string | null
  cwd?: string
  inputs: string[]
}): Promise<CandidateGroupResult[]> {
  let designSystem = await loadDesignSystem(css, cwd)

  return inputs.map((input) => createCandidateGroupResult(designSystem, input))
}

export function formatCandidateResults(results: CandidateGroupResult[], format: OutputFormat) {
  switch (format) {
    case 'json':
      return JSON.stringify(results, null, 2)
    case 'jsonl':
      return results.map((result) => JSON.stringify(result)).join('\n')
    case 'text':
      return results.map((result) => result.output).join('\n')
  }
}

function helpMessage() {
  return help({
    render: false,
    usage: [usage(), usageWithCss(), usageWithStream()],
    options: {
      ...options(),
      ...sharedOptions,
    },
  })
}

async function loadDesignSystem(cssFile: string | null, cwd: string) {
  if (cssFile === null) {
    return __unstable__loadDesignSystem(
      css`
        @import 'tailwindcss';
      `,
      { base: cwd },
    )
  }

  let resolvedCssFile = path.resolve(cwd, cssFile)
  let content = await fs.readFile(resolvedCssFile, 'utf8')
  return __unstable__loadDesignSystem(content, {
    base: path.dirname(resolvedCssFile),
  })
}

function splitCandidateGroups(input: string) {
  return input
    .split(/\r?\n/g)
    .map((line) => line.trim())
    .filter((line) => line.length > 0)
}

function parseFormat(input: string): OutputFormat {
  if (input === 'text' || input === 'json' || input === 'jsonl') {
    return input
  }

  throw new Error(`Invalid value for --format: ${input}`)
}

function usageError(message: string): RunCommandLineResult {
  return {
    exitCode: 1,
    stdout: helpMessage() ?? '',
    stderr: message,
  }
}

function splitCandidates(input: string) {
  let trimmedInput = input.trim()
  if (trimmedInput.length === 0) return []

  return segment(trimmedInput, ' ')
    .map((candidate) => candidate.trim())
    .filter((candidate) => candidate.length > 0)
}

function canonicalize(
  designSystem: Awaited<ReturnType<typeof __unstable__loadDesignSystem>>,
  input: string,
) {
  let candidates = splitCandidates(input)
  candidates = designSystem.canonicalizeCandidates(candidates, {
    collapse: true,
    logicalToPhysical: true,
  })
  return defaultSort(designSystem.getClassOrder(candidates)).join(' ')
}

function createCandidateGroupResult(
  designSystem: Awaited<ReturnType<typeof __unstable__loadDesignSystem>>,
  input: string,
): CandidateGroupResult {
  let output = canonicalize(designSystem, input)

  return {
    input,
    output,
    changed: output !== input,
  }
}

function indent(input: string, size: number) {
  let prefix = ' '.repeat(size)
  return input
    .split('\n')
    .map((line) => prefix + line)
    .join('\n')
}

function defaultSort(entries: [string, bigint | null][]) {
  return entries
    .slice()
    .sort(([candidateA, orderA], [candidateZ, orderZ]) => {
      if (orderA === orderZ) return compare(candidateA, candidateZ)
      if (orderA === null) return -1
      if (orderZ === null) return 1
      return bigSign(orderA - orderZ) || compare(candidateA, candidateZ)
    })
    .map(([candidate]) => candidate)
}

function bigSign(value: bigint) {
  if (value > 0n) {
    return 1
  } else if (value === 0n) {
    return 0
  } else {
    return -1
  }
}