import { Tokenizer, type Token } from "./tokenizer"
import { ParseError } from "./errors"

export class Command {
  shape: CommandShape

  constructor(shape: CommandShape) {
    this.shape = shape
  }

  getSuggestions(input: string): { suggestions: ArgShape[]; token?: Token } {
    const tokens = Tokenizer.tokens(input)
    if (tokens[0]?.value !== this.shape.command) {
      return { suggestions: [] }
    }

    const argTokens = tokens.slice(1)
    const hasTrailingSpace = input.endsWith(" ")
    const lastToken = argTokens.at(-1)
    const isCompletingPartialArg = !hasTrailingSpace && lastToken

    const completedTokens = isCompletingPartialArg ? argTokens.slice(0, -1) : argTokens

    const usedArgs = new Set<string>()
    completedTokens.forEach((token, index) => {
      const parsedArg = this.#convertTokenToArg(token, index, completedTokens)
      if (usedArgs.has(parsedArg.name)) {
        const message = `Argument '${parsedArg.name}' was provided more than once.`
        throw new ParseError(message, token.start, token.end)
      }
      usedArgs.add(parsedArg.name)
    })

    const availableArgs = this.shape.args.filter((arg) => !usedArgs.has(arg.name))

    if (isCompletingPartialArg) {
      const positionalShapes = this.shape.args.filter((a) => !a.named)
      const parsingPositionalArgs = completedTokens.length < positionalShapes.length
      if (parsingPositionalArgs)
        return { suggestions: [positionalShapes[completedTokens.length]!], token: lastToken }

      const prefix = lastToken.type === "named" ? lastToken.name : lastToken.value
      const suggestions = availableArgs.filter((arg) => arg.named && arg.name.startsWith(prefix))
      if (suggestions.length > 0) {
        return { suggestions, token: lastToken }
      } else {
        throw new ParseError(`No matches for '${prefix}'`, lastToken.start, lastToken.end)
      }
    }

    // If we still need positional args, only suggest the next positional arg
    const positionalShapes = this.shape.args.filter((a) => !a.named)
    const positionalCount = completedTokens.filter((t) => t.type === "positional").length
    if (positionalCount < positionalShapes.length) {
      const nextPositionalArg = positionalShapes[positionalCount]!
      return { suggestions: [nextPositionalArg], token: lastToken }
    }

    // Otherwise suggest available named args
    return { suggestions: availableArgs.filter((arg) => arg.named), token: lastToken }
  }

  parse(input: string): ParsedArg[] {
    const parsedArgs: ParsedArg[] = []
    const tokens = Tokenizer.tokens(input)
    const [command, ...argTokens] = tokens

    if (!command) {
      throw new ParseError(`Expected command "${this.shape.command}" but got nothing.`, 0, 0)
    } else if (command.value !== this.shape.command) {
      const message = `Expected command "${this.shape.command}" but got "${command.value}."`
      throw new ParseError(message, command.start, command.end)
    }

    // Convert each token to a parsed arg
    const usedArgs = new Set<string>()
    argTokens.forEach((token, index) => {
      const parsedArg = this.#convertTokenToArg(token, index, argTokens)
      if (usedArgs.has(parsedArg.name)) {
        const message = `Argument '${parsedArg.name}' was provided more than once.`
        throw new ParseError(message, token.start, token.end)
      }
      usedArgs.add(parsedArg.name)
      parsedArgs.push(parsedArg)
    })

    // Check that we have the right number of positional args
    const positionalShapes = this.shape.args.filter((a) => !a.named)
    const positionalCount = argTokens.filter((t) => t.type === "positional").length
    if (positionalCount !== positionalShapes.length) {
      const message = `Expected ${positionalShapes.length} positional argument(s) but got ${positionalCount}.`
      const lastToken = argTokens.at(-1)
      const end = lastToken ? lastToken.end : command.end
      throw new ParseError(message, end, end)
    }

    return parsedArgs
  }

  #convertTokenToArg(token: Token, tokenIndex: number, allTokens: Token[]): ParsedArg {
    if (token.type === "positional") {
      const positionalShapes = this.shape.args.filter((a) => !a.named)

      // If we're past the positional args, this is out of order
      const shape = positionalShapes[tokenIndex]
      if (!shape) {
        const message = `Positional arguments must come before named arguments`
        throw new ParseError(message, token.start, token.end)
      }

      return { name: shape.name, value: this.#castArgValue(token, shape.type), token }
    } else if (token.type === "named") {
      const namedShapes = this.shape.args.filter((a) => a.named)
      const shape = namedShapes.find((a) => a.name === token.name)
      if (!shape) {
        throw new ParseError(`Unknown named argument '${token.name}'`, token.start, token.end)
      }

      return { name: token.name, value: this.#castArgValue(token, shape.type), token }
    }

    throw new ParseError(`Unknown token type`, token.start, token.end)
  }

  #castArgValue<T extends ArgShape["type"]>(token: Token, type: T): ArgTypeMap[T] {
    if (type === "string") {
      return token.value as ArgTypeMap[T]
    } else if (type === "number") {
      const number = Number(token.value)
      if (isNaN(number))
        throw new ParseError(`Expected a number but got '${token.value}'.`, token.start, token.end)

      return number as ArgTypeMap[T]
    } else if (type === "boolean") {
      if (token.value.match(/^(true|1|t)$/i)) return true as ArgTypeMap[T]
      if (token.value.match(/^(false|0|f)$/i)) return false as ArgTypeMap[T]
      throw new ParseError(`Expected a boolean but got '${token.value}'.`, token.start, token.end)
    }

    throw new ParseError(`Unknown arg type: ${type}.`, token.start, token.end)
  }
}

type ArgTypeMap = {
  string: string
  number: number
  boolean: boolean
}

type ArgShape<T extends keyof ArgTypeMap = keyof ArgTypeMap> =
  | {
      name: string
      type: T
      description?: string
      named?: false
    }
  | {
      name: string
      type: T
      description?: string
      named: true
      default: ArgTypeMap[T]
    }

export type CommandShape = {
  command: string
  description: string
  args: ArgShape[]
}

type ParsedArg = { name: string; value: ArgTypeMap[keyof ArgTypeMap]; token: Token }
