import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import nodeModule from 'node:module'
import path from 'node:path'
import colors from 'picocolors'
import { getRouterRootFromOneOptions } from '../utils/getRouterRootFromOneOptions'
import type { One } from '../vite/types'

/**
 * Marker that identifies a bundler config as One-generated. If the file
 * still contains this marker we can safely regenerate it; if the user
 * removed the marker we treat the file as customized and never overwrite.
 */
export const ONE_GENERATED_MARKER = '@one/generated bundler-config'

export type OneBundlerConfigOptions = {
  routerRoot?: string
  ignoredRouteFiles?: Array<`**/*${string}`>
  linking?: NonNullable<One.PluginOptions['router']>['linking']
  setupFile?: One.PluginOptions['setupFile']
}

function buildBabelConfigContent({
  eject,
  options,
}: {
  eject: boolean
  options: OneBundlerConfigOptions
}) {
  const header = eject
    ? `// you own this file. edit freely — \`one\` will not regenerate it.
// delegates to one/babel-preset which holds the canonical plugin chain.
`
    : `// ${ONE_GENERATED_MARKER}
//
// auto-generated by \`one patch\` on ci/eas workers when expo-updates is
// in deps. delegates to one/babel-preset so expo export / eas update
// use the same router/setup options as \`one dev\` and \`one build\`.
//
// to customize, delete this header and edit freely — re-runs will then
// leave this file alone.
`

  return `${header}
const oneBabelPreset = require('one/babel-preset')
const preset = oneBabelPreset.default || oneBabelPreset
const oneBundlerOptions = ${serializeBundlerConfigOptions(options)}

module.exports = function (api) {
  return preset(api, oneBundlerOptions)
}
`
}

function buildMetroConfigContent({
  eject,
  options,
}: {
  eject: boolean
  options: OneBundlerConfigOptions
}) {
  const header = eject
    ? `// you own this file. edit freely — \`one\` will not regenerate it.
// withOne() invokes the same Metro pipeline One uses for production bundles.
`
    : `// ${ONE_GENERATED_MARKER}
//
// auto-generated by \`one patch\` on ci/eas workers when expo-updates is
// in deps. delegates to one/metro-config which invokes the exact same
// metro pipeline one uses for production native bundles with your
// router/setup options — no separate expo/metro-config setup needed.
//
// to customize, delete this header and edit freely — re-runs will then
// leave this file alone.
`

  return `${header}
const { withOne } = require('one/metro-config')
const oneBundlerOptions = ${serializeBundlerConfigOptions(options)}

module.exports = withOne(__dirname, oneBundlerOptions)
`
}

type FileSpec = {
  name: string
  getContent: (args: {
    eject: boolean
    options: OneBundlerConfigOptions
  }) => string
  conflicting: readonly string[]
}

const FILES: readonly FileSpec[] = [
  {
    name: 'babel.config.cjs',
    getContent: buildBabelConfigContent,
    conflicting: ['babel.config.js', 'babel.config.mjs', '.babelrc', '.babelrc.js'],
  },
  {
    name: 'metro.config.cjs',
    getContent: buildMetroConfigContent,
    conflicting: ['metro.config.js', 'metro.config.mjs'],
  },
] as const

function stripUndefined(value: unknown): unknown {
  if (Array.isArray(value)) {
    return value.map(stripUndefined)
  }

  if (value && typeof value === 'object') {
    return Object.fromEntries(
      Object.entries(value)
        .filter(([, entry]) => entry !== undefined)
        .map(([key, entry]) => [key, stripUndefined(entry)])
    )
  }

  return value
}

function assertSerializable(value: unknown, keyPath = 'one bundler options') {
  if (
    typeof value === 'function' ||
    typeof value === 'symbol' ||
    typeof value === 'bigint'
  ) {
    throw new Error(
      `[one] ${keyPath} must be JSON-serializable to generate Babel/Metro config files. Move function-valued native linking/customization into an ejected config.`
    )
  }

  if (Array.isArray(value)) {
    value.forEach((entry, index) => assertSerializable(entry, `${keyPath}[${index}]`))
    return
  }

  if (value && typeof value === 'object') {
    for (const [key, entry] of Object.entries(value)) {
      assertSerializable(entry, `${keyPath}.${key}`)
    }
  }
}

function serializeBundlerConfigOptions(options: OneBundlerConfigOptions): string {
  const clean = stripUndefined(options) as OneBundlerConfigOptions
  assertSerializable(clean)
  return JSON.stringify(clean, null, 2)
}

export function getBundlerConfigOptionsFromOneOptions(
  oneOptions: One.PluginOptions = {}
): OneBundlerConfigOptions {
  return stripUndefined({
    routerRoot: getRouterRootFromOneOptions(oneOptions),
    ignoredRouteFiles: oneOptions.router?.ignoredRouteFiles,
    linking: oneOptions.router?.linking,
    setupFile: oneOptions.setupFile,
  }) as OneBundlerConfigOptions
}

export type GenerateBundlerConfigArgs = {
  /** Project root. Defaults to `process.cwd()`. */
  cwd?: string
  /** loaded one plugin options from vite.config. */
  oneOptions?: One.PluginOptions
  /** Overwrite even when the file has been customized (marker removed). */
  force?: boolean
  /** Just verify state without writing — exits non-zero when out of sync. */
  check?: boolean
  /** Suppress logging. */
  quiet?: boolean
  /**
   * Write files WITHOUT the `@one/generated` marker. The user owns the file
   * after this; subsequent CI auto-gen runs will treat it as customized and
   * skip it. used by `one metro-eject`.
   */
  eject?: boolean
}

export type FileResult = {
  filePath: string
  action: 'wrote' | 'kept' | 'skipped-customized' | 'skipped-other-format' | 'would-write' | 'would-overwrite'
  reason?: string
}

export function generateBundlerConfig(args: GenerateBundlerConfigArgs = {}): {
  results: FileResult[]
  ok: boolean
} {
  const cwd = path.resolve(args.cwd ?? process.cwd())
  const force = !!args.force
  const check = !!args.check
  const quiet = !!args.quiet

  const log = (msg: string) => {
    if (!quiet) console.info(msg)
  }
  const warn = (msg: string) => {
    if (!quiet) console.warn(msg)
  }

  const results: FileResult[] = []

  const eject = !!args.eject
  const bundlerOptions = getBundlerConfigOptionsFromOneOptions(args.oneOptions)

  for (const file of FILES) {
    const filePath = path.join(cwd, file.name)
    const targetContent = file.getContent({ eject, options: bundlerOptions })

    // detect conflicting other-extension variants the user might be using
    const conflict = file.conflicting.find((alt) => existsSync(path.join(cwd, alt)))
    if (conflict && !existsSync(filePath)) {
      results.push({
        filePath: path.join(cwd, conflict),
        action: 'skipped-other-format',
        reason: `Found ${conflict}; not creating ${file.name}. To switch, delete ${conflict} and re-run with --force.`,
      })
      warn(
        colors.yellow(
          `[one] found ${conflict} — leaving it alone. Delete it and re-run with --force to switch to ${file.name}.`
        )
      )
      continue
    }

    if (!existsSync(filePath)) {
      if (check) {
        results.push({ filePath, action: 'would-write' })
        log(colors.yellow(`[one] missing: ${file.name}`))
        continue
      }
      writeFileSync(filePath, targetContent)
      results.push({ filePath, action: 'wrote' })
      log(colors.green(`[one] wrote ${file.name}`))
      continue
    }

    const existing = readFileSync(filePath, 'utf8')

    if (existing === targetContent) {
      results.push({ filePath, action: 'kept' })
      log(colors.dim(`[one] up to date: ${file.name}`))
      continue
    }

    const hasMarker = existing.includes(ONE_GENERATED_MARKER)

    if (!hasMarker && !force) {
      results.push({
        filePath,
        action: 'skipped-customized',
        reason: `${file.name} has been customized (no @one marker). Re-add the marker comment or pass --force to overwrite.`,
      })
      warn(
        colors.yellow(
          `[one] ${file.name} appears customized — skipping. Pass --force to overwrite.`
        )
      )
      continue
    }

    if (check) {
      results.push({ filePath, action: 'would-overwrite' })
      log(colors.yellow(`[one] out of date: ${file.name}`))
      continue
    }

    writeFileSync(filePath, targetContent)
    results.push({ filePath, action: 'wrote' })
    log(colors.green(`[one] updated ${file.name}`))
  }

  // "ok" means the on-disk state is something we can live with — either we
  // wrote what we wanted, the existing file is up to date, or the user has
  // explicitly customized (their intent, not our problem).
  // check mode is stricter: missing/stale files mean a regen is needed.
  const acceptableAlways = new Set<FileResult['action']>([
    'wrote',
    'kept',
    'skipped-other-format',
    'skipped-customized',
  ])
  const acceptableInCheck = new Set<FileResult['action']>([
    'kept',
    'skipped-other-format',
    'skipped-customized',
  ])
  const ok = (check ? acceptableInCheck : acceptableAlways).size
    ? results.every((r) =>
        (check ? acceptableInCheck : acceptableAlways).has(r.action)
      )
    : false

  return { results, ok }
}

/**
 * True when running on a CI/EAS worker. We only auto-generate bundler-config
 * files in CI so they never appear in a developer's local working tree.
 *
 * Accepts any truthy value for `CI` / `EAS_BUILD` since providers vary:
 * GitHub Actions sets `CI=true`, others use `CI=1`, EAS sets `EAS_BUILD=true`.
 *
 * Set `CI=1` (or `EAS_BUILD=true`) ahead of `eas update` if you need to
 * publish from a local machine.
 */
export function isCiEnvironment(): boolean {
  const truthy = (v: string | undefined) =>
    !!v && v !== 'false' && v !== '0'
  return truthy(process.env.EAS_BUILD) || truthy(process.env.CI)
}

/**
 * Postinstall hook: when expo-updates is in deps AND we're running on
 * a CI/EAS worker, ensure the bundler-config files exist so the
 * subsequent `expo export` / EXUpdates Metro pass succeeds.
 *
 * No-op locally so the files never show up in a developer's working tree.
 */
export function maybeGenerateBundlerConfigOnInstall(
  cwd: string = process.cwd(),
  oneOptions?: One.PluginOptions
): void {
  if (!isCiEnvironment()) return

  // detect expo-updates via the project's own resolver — same check used
  // by the vxrn expo-plugin and one prebuild
  try {
    nodeModule
      .createRequire(cwd + '/')
      .resolve('expo-updates/package.json')
  } catch {
    return
  }

  generateBundlerConfig({ cwd, quiet: false, oneOptions })
}
