import { promises as fsPromises } from "fs"
import path from "path"
import { getShadcnRegistryIndex } from "@/src/registry/api"
import { SHADCN_URL } from "@/src/registry/constants"
import { rawConfigSchema } from "@/src/schema"
import { Framework, FRAMEWORKS } from "@/src/utils/frameworks"
import { Config, getConfig, resolveConfigPaths } from "@/src/utils/get-config"
import { getPackageInfo } from "@/src/utils/get-package-info"
import {
  getPackageImportAliases,
  getPackageImportPrefix,
} from "@/src/utils/package-imports"
import fg from "fast-glob"
import fs from "fs-extra"
import { loadConfig } from "tsconfig-paths"
import { z } from "zod"

export type TailwindVersion = "v3" | "v4" | null

export type ProjectInfo = {
  framework: Framework
  isSrcDir: boolean
  isRSC: boolean
  isTsx: boolean
  tailwindConfigFile: string | null
  tailwindCssFile: string | null
  tailwindVersion: TailwindVersion
  frameworkVersion: string | null
  aliasPrefix: string | null
}

const PROJECT_SHARED_IGNORE = [
  "**/node_modules/**",
  ".next",
  "public",
  "dist",
  "build",
]

const TS_CONFIG_SCHEMA = z.object({
  compilerOptions: z.object({
    paths: z.record(z.string().or(z.array(z.string()))),
  }),
})

export async function getProjectInfo(
  cwd: string,
  opts?: { configCssFile?: string }
): Promise<ProjectInfo | null> {
  const [
    configFiles,
    isSrcDir,
    isTsx,
    tailwindConfigFile,
    tailwindCssFile,
    tailwindVersion,
    aliasPrefixInfo,
    packageJson,
  ] = await Promise.all([
    fg.glob(
      "**/{next,vite,astro,app}.config.*|gatsby-config.*|composer.json|react-router.config.*",
      {
        cwd,
        deep: 3,
        ignore: PROJECT_SHARED_IGNORE,
      }
    ),
    fs.pathExists(path.resolve(cwd, "src")),
    isTypeScriptProject(cwd),
    getTailwindConfigFile(cwd),
    getTailwindCssFile(cwd, opts?.configCssFile),
    getTailwindVersion(cwd),
    getProjectAliasInfo(cwd),
    getPackageInfo(cwd, false),
  ])

  const isUsingAppDir = await fs.pathExists(
    path.resolve(cwd, `${isSrcDir ? "src/" : ""}app`)
  )

  const type: ProjectInfo = {
    framework: FRAMEWORKS["manual"],
    isSrcDir,
    isRSC: false,
    isTsx,
    tailwindConfigFile,
    tailwindCssFile,
    tailwindVersion,
    frameworkVersion: null,
    aliasPrefix: aliasPrefixInfo.prefix,
  }

  // Next.js.
  if (configFiles.find((file) => file.startsWith("next.config."))?.length) {
    type.framework = isUsingAppDir
      ? FRAMEWORKS["next-app"]
      : FRAMEWORKS["next-pages"]
    type.isRSC = isUsingAppDir
    type.frameworkVersion = await getFrameworkVersion(
      type.framework,
      packageJson
    )
    return type
  }

  // Astro.
  if (configFiles.find((file) => file.startsWith("astro.config."))?.length) {
    type.framework = FRAMEWORKS["astro"]
    return type
  }

  // Gatsby.
  if (configFiles.find((file) => file.startsWith("gatsby-config."))?.length) {
    type.framework = FRAMEWORKS["gatsby"]
    return type
  }

  // Laravel.
  if (configFiles.find((file) => file.startsWith("composer.json"))?.length) {
    type.framework = FRAMEWORKS["laravel"]
    return type
  }

  // Remix.
  if (
    Object.keys(packageJson?.dependencies ?? {}).find((dep) =>
      dep.startsWith("@remix-run/")
    )
  ) {
    type.framework = FRAMEWORKS["remix"]
    return type
  }

  // TanStack Start.
  if (
    [
      ...Object.keys(packageJson?.dependencies ?? {}),
      ...Object.keys(packageJson?.devDependencies ?? {}),
    ].find((dep) => dep.startsWith("@tanstack/react-start"))
  ) {
    type.framework = FRAMEWORKS["tanstack-start"]
    return type
  }

  // React Router.
  if (
    configFiles.find((file) => file.startsWith("react-router.config."))?.length
  ) {
    type.framework = FRAMEWORKS["react-router"]
    return type
  }

  // Vite.
  // Some Remix templates also have a vite.config.* file.
  // We'll assume that it got caught by the Remix check above.
  if (configFiles.find((file) => file.startsWith("vite.config."))?.length) {
    type.framework = FRAMEWORKS["vite"]
    return type
  }

  // Vinxi-based (such as @tanstack/start and @solidjs/solid-start)
  // They are vite-based, and the same configurations used for Vite should work flawlessly
  const appConfig = configFiles.find((file) => file.startsWith("app.config"))
  if (appConfig?.length) {
    const appConfigContents = await fs.readFile(
      path.resolve(cwd, appConfig),
      "utf8"
    )
    if (appConfigContents.includes("defineConfig")) {
      type.framework = FRAMEWORKS["vite"]
      return type
    }
  }

  // Expo.
  if (packageJson?.dependencies?.expo) {
    type.framework = FRAMEWORKS["expo"]
    return type
  }

  return type
}

export async function getFrameworkVersion(
  framework: Framework,
  packageJson: ReturnType<typeof getPackageInfo>
) {
  if (!packageJson) {
    return null
  }

  // Only detect Next.js version for now.
  if (!["next-app", "next-pages"].includes(framework.name)) {
    return null
  }

  const version =
    packageJson.dependencies?.next || packageJson.devDependencies?.next

  if (!version) {
    return null
  }

  // Extract full semver (major.minor.patch), handling ^, ~, etc.
  const versionMatch = version.match(/^[\^~]?(\d+\.\d+\.\d+)/)
  if (versionMatch) {
    return versionMatch[1] // e.g., "16.0.0"
  }

  // For ranges like ">=15.0.0 <16.0.0", extract the first version.
  const rangeMatch = version.match(/(\d+\.\d+\.\d+)/)
  if (rangeMatch) {
    return rangeMatch[1]
  }

  // For "latest", "canary", "rc", etc., return the tag as-is.
  return version
}

export async function getTailwindVersion(
  cwd: string
): Promise<ProjectInfo["tailwindVersion"]> {
  const [packageInfo, config] = await Promise.all([
    getPackageInfo(cwd, false),
    getConfig(cwd),
  ])

  // If the config file is empty, we can assume that it's a v4 project.
  if (config?.tailwind?.config === "") {
    return "v4"
  }

  if (
    !packageInfo?.dependencies?.tailwindcss &&
    !packageInfo?.devDependencies?.tailwindcss
  ) {
    return null
  }

  if (
    /^(?:\^|~)?3(?:\.\d+)*(?:-.*)?$/.test(
      packageInfo?.dependencies?.tailwindcss ||
        packageInfo?.devDependencies?.tailwindcss ||
        ""
    )
  ) {
    return "v3"
  }

  return "v4"
}

export async function getTailwindCssFile(cwd: string, configCssFile?: string) {
  // If the existing config has a known CSS file, check it first.
  if (configCssFile) {
    const resolvedPath = path.resolve(cwd, configCssFile)
    if (await fs.pathExists(resolvedPath)) {
      return configCssFile
    }
  }

  const [files, tailwindVersion] = await Promise.all([
    fg.glob(["**/*.css", "**/*.scss"], {
      cwd,
      deep: 5,
      ignore: PROJECT_SHARED_IGNORE,
    }),
    getTailwindVersion(cwd),
  ])

  if (!files.length) {
    return null
  }

  const needle =
    tailwindVersion === "v4" ? `@import "tailwindcss"` : "@tailwind base"
  for (const file of files) {
    const contents = await fs.readFile(path.resolve(cwd, file), "utf8")
    if (
      contents.includes(`@import "tailwindcss"`) ||
      contents.includes(`@import 'tailwindcss'`) ||
      contents.includes(`@tailwind base`)
    ) {
      return file
    }
  }

  return null
}

export async function getTailwindConfigFile(cwd: string) {
  const files = await fg.glob("tailwind.config.*", {
    cwd,
    deep: 3,
    ignore: PROJECT_SHARED_IGNORE,
  })

  if (!files.length) {
    return null
  }

  return files[0]
}

export async function getTsConfigAliasPrefix(cwd: string) {
  const tsConfig = await loadConfig(cwd)
  const paths =
    tsConfig?.resultType === "success" && Object.entries(tsConfig.paths).length
      ? tsConfig.paths
      : (await getTsConfig(cwd))?.compilerOptions.paths

  if (!paths || !Object.entries(paths).length) {
    return null
  }

  // This assume that the first alias is the prefix.
  for (const [alias, targets] of Object.entries(paths)) {
    const values = Array.isArray(targets) ? targets : [targets]

    if (
      values.includes("./*") ||
      values.includes("./src/*") ||
      values.includes("./app/*") ||
      values.includes("./resources/js/*") // Laravel.
    ) {
      return alias.replace(/\/\*$/, "") ?? null
    }
  }

  // Use the first alias as the prefix.
  return Object.keys(paths)?.[0].replace(/\/\*$/, "") ?? null
}

export async function getProjectAliasInfo(cwd: string) {
  const tsConfigAliasPrefix = await getTsConfigAliasPrefix(cwd)
  const packageImportPrefix = getPackageImportPrefix(cwd)

  if (packageImportPrefix && tsConfigAliasPrefix?.startsWith("#")) {
    return {
      prefix: packageImportPrefix,
      source: "package_imports" as const,
    }
  }

  if (tsConfigAliasPrefix) {
    return {
      prefix: tsConfigAliasPrefix,
      source: "tsconfig_paths" as const,
    }
  }

  if (packageImportPrefix) {
    return {
      prefix: packageImportPrefix,
      source: "package_imports" as const,
    }
  }

  return {
    prefix: null,
    source: null,
  }
}

export async function isTypeScriptProject(cwd: string) {
  const files = await fg.glob("tsconfig.*", {
    cwd,
    deep: 1,
    ignore: PROJECT_SHARED_IGNORE,
  })

  return files.length > 0
}

export async function getTsConfig(cwd: string) {
  for (const fallback of [
    "tsconfig.json",
    "tsconfig.web.json",
    "tsconfig.app.json",
  ]) {
    const filePath = path.resolve(cwd, fallback)
    if (!(await fs.pathExists(filePath))) {
      continue
    }

    const contents = await fs.readFile(filePath, "utf8")
    let parsed

    try {
      parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(contents))
    } catch {
      continue
    }

    const result = TS_CONFIG_SCHEMA.safeParse(parsed)

    if (result.error) {
      continue
    }

    return result.data
  }

  return null
}

function stripJsonCommentsAndTrailingCommas(value: string) {
  let result = ""
  let inString = false
  let escaped = false

  for (let index = 0; index < value.length; index++) {
    const current = value[index]
    const next = value[index + 1]

    if (inString) {
      result += current

      if (escaped) {
        escaped = false
        continue
      }

      if (current === "\\") {
        escaped = true
        continue
      }

      if (current === '"') {
        inString = false
      }

      continue
    }

    if (current === '"') {
      inString = true
      result += current
      continue
    }

    if (current === "/" && next === "/") {
      while (index < value.length && value[index] !== "\n") {
        index++
      }

      if (index < value.length) {
        result += value[index]
      }

      continue
    }

    if (current === "/" && next === "*") {
      index += 2

      while (index < value.length) {
        if (value[index] === "*" && value[index + 1] === "/") {
          index++
          break
        }

        if (value[index] === "\n") {
          result += "\n"
        }

        index++
      }

      continue
    }

    if (current === ",") {
      let nextIndex = index + 1

      while (nextIndex < value.length) {
        while (/\s/.test(value[nextIndex] ?? "")) {
          nextIndex++
        }

        if (value[nextIndex] === "/" && value[nextIndex + 1] === "/") {
          nextIndex += 2

          while (
            nextIndex < value.length &&
            value[nextIndex] !== "\n" &&
            value[nextIndex] !== "\r"
          ) {
            nextIndex++
          }

          continue
        }

        if (value[nextIndex] === "/" && value[nextIndex + 1] === "*") {
          nextIndex += 2

          while (
            nextIndex < value.length &&
            !(value[nextIndex] === "*" && value[nextIndex + 1] === "/")
          ) {
            nextIndex++
          }

          nextIndex += 2
          continue
        }

        break
      }

      if (value[nextIndex] === "}" || value[nextIndex] === "]") {
        continue
      }
    }

    result += current
  }

  return result
}

export async function getProjectConfig(
  cwd: string,
  defaultProjectInfo: ProjectInfo | null = null
): Promise<Config | null> {
  // Check for existing component config.
  const [existingConfig, projectInfo, aliasInfo] = await Promise.all([
    getConfig(cwd),
    !defaultProjectInfo
      ? getProjectInfo(cwd)
      : Promise.resolve(defaultProjectInfo),
    getProjectAliasInfo(cwd),
  ])

  if (existingConfig) {
    return existingConfig
  }

  if (
    !projectInfo ||
    !projectInfo.tailwindCssFile ||
    (projectInfo.tailwindVersion === "v3" && !projectInfo.tailwindConfigFile)
  ) {
    return null
  }

  const packageImportAliases =
    aliasInfo.source === "package_imports" ? getPackageImportAliases(cwd) : null

  if (!projectInfo.aliasPrefix) {
    return null
  }

  const fallbackAliases = getAliasDefaultsFromPrefix(
    projectInfo.aliasPrefix,
    aliasInfo.source === "package_imports"
  )

  const aliases =
    aliasInfo.source === "package_imports" && packageImportAliases
      ? derivePackageImportAliases({
          ...fallbackAliases,
          components:
            packageImportAliases.components ?? fallbackAliases.components,
          ui: packageImportAliases.ui ?? fallbackAliases.ui,
          hooks: packageImportAliases.hooks ?? fallbackAliases.hooks,
          lib: packageImportAliases.lib ?? fallbackAliases.lib,
          utils: packageImportAliases.utils ?? fallbackAliases.utils,
        })
      : fallbackAliases

  if (!aliases.components || !aliases.utils) {
    return null
  }

  const config: z.infer<typeof rawConfigSchema> = {
    $schema: "https://ui.shadcn.com/schema.json",
    rsc: projectInfo.isRSC,
    tsx: projectInfo.isTsx,
    style: "new-york",
    tailwind: {
      config: projectInfo.tailwindConfigFile ?? "",
      baseColor: "zinc",
      css: projectInfo.tailwindCssFile,
      cssVariables: true,
      prefix: "",
    },
    iconLibrary: "lucide",
    aliases,
  }

  return await resolveConfigPaths(cwd, config)
}

function getAliasDefaultsFromPrefix(
  aliasPrefix: string,
  isPackageImport: boolean = false
) {
  if (isPackageImport && aliasPrefix === "#") {
    return {
      components: "",
      ui: undefined,
      hooks: undefined,
      lib: undefined,
      utils: "",
    }
  }

  return {
    components: `${aliasPrefix}/components`,
    ui: `${aliasPrefix}/components/ui`,
    hooks: `${aliasPrefix}/hooks`,
    lib: `${aliasPrefix}/lib`,
    utils: `${aliasPrefix}/lib/utils`,
  }
}

function derivePackageImportAliases(aliases: {
  components: string
  ui?: string
  hooks?: string
  lib?: string
  utils: string
}) {
  const derivedAliases = { ...aliases }

  if (!derivedAliases.ui && derivedAliases.components) {
    derivedAliases.ui = `${derivedAliases.components}/ui`
  }

  if (!derivedAliases.lib && derivedAliases.utils.endsWith("/utils")) {
    derivedAliases.lib = derivedAliases.utils.slice(0, -"/utils".length)
  }

  if (!derivedAliases.utils && derivedAliases.lib) {
    derivedAliases.utils = `${derivedAliases.lib}/utils`
  }

  return derivedAliases
}

export async function getProjectTailwindVersionFromConfig(config: {
  resolvedPaths: Pick<Config["resolvedPaths"], "cwd">
}): Promise<TailwindVersion> {
  if (!config.resolvedPaths?.cwd) {
    return "v3"
  }

  const projectInfo = await getProjectInfo(config.resolvedPaths.cwd)

  if (!projectInfo?.tailwindVersion) {
    return null
  }

  return projectInfo.tailwindVersion
}

export async function getProjectComponents(cwd: string) {
  const existingConfig = await getConfig(cwd)
  if (!existingConfig) {
    return []
  }

  const resolvedConfig = await resolveConfigPaths(cwd, existingConfig)
  const uiDir = resolvedConfig.resolvedPaths.ui
  if (!fs.existsSync(uiDir)) {
    return []
  }

  const registryIndex = await getShadcnRegistryIndex()
  const registryNames = new Set(registryIndex?.map((item) => item.name) ?? [])

  const files = await fsPromises.readdir(uiDir)
  return files
    .filter((f) => /\.(tsx|jsx)$/.test(f))
    .map((f) => path.basename(f, path.extname(f)))
    .filter((name) => registryNames.has(name))
}
