#!/usr/bin/env -S node -r esbin
import "dotenv/config"
import { promises as fs } from "fs"
import { resolve } from "path"
import { assign, camelCase } from "lodash"
import yaml from "js-yaml"
import { writeFormatted } from "./utils"

// type Outputs = "partial input" | "input" | "object" | "inputAndObject" | "args" | "zod";
type Output =
  | ["input", { partial?: boolean; name?: string }] //
  | ["object"]
  | ["args"]
  | ["zod"]

interface Model {
  as: Output[]
  extends?: string
  fields: string
  extra?: Record<string, string>
}

const FIELD_REGEX =
  /^(?<name>\w+)(?<optional>\?)?(:\s*(?<type>[\w\s[\]|]+))?(\s*=\s*(?<defaultValue>.+))?$/

function parseField(field: string) {
  const groups = field.match(FIELD_REGEX)?.groups
  if (!groups) {
    throw new Error(`Invalid field: ${field}`)
  }

  if (!groups.type && groups.defaultValue) {
    const v = groups.defaultValue
    if (v === "true" || v === "false") {
      groups.type = "boolean"
    }
  }

  return groups as {
    name: string
    optional?: "?"
    type: string
    defaultValue: string
  }
}

function parseFields(value: any) {
  if (typeof value === "object") {
    value = Object.entries(value)
      .map(([name, type]) => `${name}: ${type}`)
      .join("\n")
  }

  return (value as string).trim().split("\n").map(parseField)
}

const getInputName = (name: string) => (name.startsWith("Input") ? name : `Input${name}`)

type Generate = Generator<string | false | undefined, void, unknown>

async function main() {
  const folder = resolve(__dirname, "../src/shared/models")
  const source = await fs.readFile(resolve(folder, "models.yml"), "utf8")
  const json = yaml.load(source) as {
    enums: Record<string, string[]>
    imports?: string
    models: Record<string, Model>
  }

  const models = Object.entries(json.models).map(
    ([name, { as, extends: _, fields, extra }]) => ({
      name,
      as: as.map(t =>
        Array.isArray(t) ? [t[0], (assign as any)(...t.slice(1))] : [t, {}],
      ) as Output[],
      extends: _,
      fields: parseFields(fields),
      extra,
    }),
  )

  for (const model of models) {
    if (model.extends) {
      const parent = models.find(m => m.name === model.extends)
      if (!parent) {
        throw new Error(`Could not find parent ${model.extends}`)
      }
      model.fields = parent.fields.concat(model.fields)
    }
  }

  const importedEnums = new Set<string>()
  const enumNames = Object.keys(json.enums)

  function getReifiedType(type: string): string {
    if (type.endsWith("| null")) {
      type = type.slice(0, -6).trim()
    }
    if (type.endsWith("[]")) {
      return `[${getReifiedType(type.slice(0, -2))}]`
    }
    switch (type) {
      case "string":
      case "String":
        return "String"
      case "int":
      case "Int":
        return "Int"
      case "boolean":
      case "Boolean":
        return "Boolean"
      default:
        if (enumNames.includes(type)) {
          importedEnums.add(type)
        }
        if (/^\d+$/.test(type)) {
          return "Int"
        }
        return type
    }
  }

  function getVirtualType(type: string): string {
    if (type.endsWith("[]")) {
      return `${getVirtualType(type.slice(0, -2))}[]`
    } else if (type.endsWith("| null")) {
      return `${getVirtualType(type.slice(0, -6).trim())} | null`
    }
    switch (type) {
      case "int":
      case "Int":
        return "number"
      case "ID":
      case "String":
        return "string"
      case "Boolean":
        return "boolean"
      default:
        if (enumNames.includes(type)) {
          importedEnums.add(type)
        }
        return type
    }
  }

  function getZodType(type: string): string {
    if (type.endsWith("[]")) {
      return `z.array(${getZodType(type.slice(0, -2))})`
    } else if (type.endsWith("| null")) {
      return `${getZodType(type.slice(0, -6).trim())}.optional()`
    }
    switch (type) {
      case "int":
      case "Int":
        return "z.number()"
      case "string":
        return "z.string()"
      case "boolean":
        return "z.boolean()"
      default:
        if (/^[\d.]+$/.test(type)) {
          return `z.literal(${type})`
        }

        if (enumNames.includes(type)) {
          return `z.nativeEnum(${type})`
        }
        return `z.instanceof(${type})`
    }
  }

  const banners: string[] = [
    `import { ArgsType, Field, ID, InputType, Int, ObjectType } from "@aet/gql-tools/macro";`,
    // `import { z } from "zod";`,
  ]

  /* eslint-disable @typescript-eslint/consistent-type-imports, @typescript-eslint/no-unused-vars, @typescript-eslint/no-inferrable-types */
  const getModels = function* (): Generate {
    for (const model of models) {
      let attachInputToObject = false
      for (const type of model.as) {
        let { name } = model
        let isInput = false
        let isPartialInput = false

        switch (type[0]) {
          case "input":
            isInput = true
            isPartialInput = type[1].partial ?? false

            if (
              Object.keys(type[1]).length === 0 &&
              model.as.some(t => t[0] === "object")
            ) {
              attachInputToObject = true
              continue
            } else {
              name = type[1].name ?? getInputName(name)
              yield `@InputType("${name}")\n`
            }
            break

          case "object":
            if (attachInputToObject) {
              yield `/** Input and object type for ${name} */\n`
              yield `@InputType("${getInputName(name)}")\n`
            }
            yield `@ObjectType("${name}")\n`
            break

          case "args":
            yield `@ArgsType()\n`
            break

          case "zod":
            continue
        }

        yield `export class ${name} {`
        for (const field of model.fields) {
          const optional = field.optional || isPartialInput
          yield "@Field("

          yield `() => ${getReifiedType(field.type)},`
          if (optional) {
            yield "{ nullable: true },"
          }
          yield ")\n"
          if (!field.defaultValue) {
            yield "declare "
          }
          yield `${field.name}`
          if (optional) {
            yield "?"
          }
          yield ":"
          yield getVirtualType(field.type)
          if (isInput && field.defaultValue) {
            yield ` = ${field.defaultValue}`
          }
          yield ";\n\n"
        }

        let constructorType = name
        if (model.extra) {
          const omits: string[] = []
          for (const [name, type] of Object.entries(model.extra)) {
            if (type.includes("=")) {
              omits.push(name.replace(/\?$/, ""))
            }
            yield `${name}: ${type}\n`
          }
          constructorType = `Omit<${name}, ${omits.map(o => `"${o}"`).join(" | ")}>`
        }

        yield /* js */ `
          /**
           * Create an instance of ${name} from a plain object.
           */
          constructor(fields: ${constructorType}) {
            Object.assign(this, fields);
          }
        `

        // if (1 === 3) {
        //   yield "\n";
        //   yield `/** Zod schema for ${name} */\n`;
        //   yield "static zod = z.object({\n";
        //   for (const field of model.fields) {
        //     yield `${field.name}: ${getZodType(field.type)},\n`;
        //   }
        //   yield `}).strict();`;
        // }

        yield "}\n\n"
      }
    }
  }

  function* getEnums() {
    for (const [name, values] of Object.entries(json.enums)) {
      yield `export const ${name} = {`
      for (const value of values) {
        yield `${value}: "${value}",\n`
      }
      yield "} as const;\n"
      yield
      yield `export type ${name} = typeof ${name}[keyof typeof ${name}];\n`
      yield
      yield `export const ${camelCase(name)}s = [${values
        .map(v => `"${v}"`)
        .join(", ")}] as ${name}[];\n`
      yield
    }
  }

  await writeTS(resolve(folder, "models.generated.ts"), getModels, () =>
    banners.concat(
      importedEnums.size
        ? `import { ${Array.from(importedEnums)
            .sort()
            .join(", ")} } from "./enums.generated";`
        : [],
    ),
  )
  await writeTS(resolve(folder, "enums.generated.ts"), getEnums, () => [])
}

async function writeTS(path: string, generator: () => Generate, banner: () => string[]) {
  const code = Array.from(generator(), x => x ?? "\n").join("")
  await writeFormatted(path, banner().join("\n") + "\n\n" + code)
}

if (require.main === module) {
  Promise.resolve()
    .then(() => main())
    .catch(e => {
      console.error(e)
      process.exit(1)
    })
}
