const fs = require('fs')
const path = require('path')
const { createRequire } = require('module')
const t = require('@babel/types')
const parser = require('@babel/parser')

const { normalizeTSTypeCastExpressionsForGenerator } = require('./utils')
const { packageToNamespaceIdentifier, analyzeExistingCmsDepsFile } = require('../cms-deps-patcher')

function isRelative(spec) {
  return spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('/')
}

function fileExistsFirstMatch(basePath, candidates) {
  for (const c of candidates) {
    const p = path.join(basePath + c)
    if (fs.existsSync(p)) return p
  }
  return null
}

function resolveRelativeImport(fromFile, spec) {
  const base = path.resolve(path.dirname(fromFile), spec)
  // Try the literal path (for already-suffixed imports), then common extensions.
  const candidates = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js', '/index.jsx']
  return fileExistsFirstMatch(base, candidates)
}

function findNearestPackageJsonDir(startDir) {
  let dir = path.resolve(startDir)
  for (;;) {
    if (fs.existsSync(path.join(dir, 'package.json'))) return dir
    const parent = path.dirname(dir)
    if (parent === dir) return null
    dir = parent
  }
}

function stripExtForImport(p) {
  return p.replace(/\.(tsx?|jsx?)$/, '')
}

/**
 * @param {string} resolvedFile
 * @param {string} originalSpec
 * @param {string} barrelAbsPath
 */
function reexportFromClauseForBarrel(resolvedFile, originalSpec, barrelAbsPath) {
  if (isPathAliasImport(originalSpec)) {
    return originalSpec
  }
  const projectRoot = findNearestPackageJsonDir(path.dirname(resolvedFile))
  const srcRoot = projectRoot && path.join(projectRoot, 'src')
  if (srcRoot && (resolvedFile === srcRoot || resolvedFile.startsWith(srcRoot + path.sep))) {
    const rel = path.relative(srcRoot, resolvedFile)
    const noExt = stripExtForImport(rel)
    return '@/' + noExt.split(path.sep).join('/')
  }
  const fromDir = path.dirname(barrelAbsPath)
  let rel = path.relative(fromDir, resolvedFile).split(path.sep).join('/')
  rel = stripExtForImport(rel)
  if (!rel.startsWith('.')) {
    return './' + rel
  }
  return rel
}

/**
 * @param {import('@babel/types').ImportSpecifier[]|*[]} specifiers
 * @param {Array<{ kind: string, name: string, from: string }>} movedForDecl
 * @param {string} fromClause
 */
function buildBarrelReexportLine(specifiers, movedForDecl, fromClause) {
  const tmod = t
  const movedNames = new Set(
    movedForDecl.filter((m) => m.kind === 'named').map((m) => m.name),
  )
  const defaultMoved = movedForDecl.find((m) => m.kind === 'default')
  const fromQuot = fromClause.replace(/'/g, "\\'")
  const parts = []
  for (const s of specifiers) {
    if (tmod.isImportNamespaceSpecifier(s)) {
      return null
    }
    if (tmod.isImportDefaultSpecifier(s)) {
      if (defaultMoved && s.local.name === defaultMoved.name) {
        parts.push(`default as ${s.local.name}`)
      }
    } else if (tmod.isImportSpecifier(s)) {
      const imported = tmod.isIdentifier(s.imported) ? s.imported.name : s.imported.value
      if (!movedNames.has(imported)) {
        continue
      }
      if (tmod.isIdentifier(s.imported) && tmod.isIdentifier(s.local) && s.imported.name === s.local.name) {
        parts.push(imported)
      } else {
        parts.push(`${imported} as ${s.local.name}`)
      }
    }
  }
  if (parts.length === 0) {
    return null
  }
  return `export { ${parts.join(', ')} } from '${fromQuot}'`
}

/**
 * True for Next/TS path aliases like `@/components/foo` — not npm scoped packages (`@scope/pkg`).
 */
function isPathAliasImport(spec) {
  return typeof spec === 'string' && spec.startsWith('@/')
}

/**
 * True when this import brings no runtime values into the module (TypeScript erases it).
 * Covers `import type …`, `import typeof …`, and inline `import { type X }`.
 */
function importDeclarationIsTypescriptTypeOnly(node) {
  if (!node) return false
  if (node.importKind === 'type' || node.importKind === 'typeof') return true
  const specs = node.specifiers || []
  if (specs.length === 0) return false
  return specs.every((s) => s.importKind === 'type' || s.importKind === 'typeof')
}

/**
 * npm imports that resolve only to declaration files (e.g. `swiper/types`) cannot be
 * registered as `import * as …` + cmsDependencies modules — bundlers have no JS to load.
 */
function isTypesOnlyNpmRegistryPath(spec, fromFile) {
  if (typeof spec !== 'string' || isRelative(spec) || isPathAliasImport(spec)) return false
  const root = findNearestPackageJsonDir(path.dirname(fromFile))
  if (root) {
    try {
      const req = createRequire(path.join(root, 'package.json'))
      const resolved = req.resolve(spec)
      if (typeof resolved === 'string' && resolved.endsWith('.d.ts')) return true
    } catch {
      /* use heuristics below when the package is not installed or lacks an export */
    }
  }
  // Unscoped `pkg/types` subpaths are usually TS-only barrels (e.g. swiper/types). Scoped
  // `@scope/types` can be a real runtime package name — only treat as types-only when
  // resolve() above points at a .d.ts file.
  if (spec.startsWith('@')) return false
  return /\/types$/.test(spec)
}

/**
 * Map `@/x/y` to an on-disk file (tries `src/x/y` then `x/y` under the nearest package.json root).
 */
function resolvePathAliasImport(fromFile, spec) {
  if (!isPathAliasImport(spec)) return null
  const projectRoot = findNearestPackageJsonDir(path.dirname(fromFile))
  if (!projectRoot) return null
  const rest = spec.slice(2)
  if (!rest) return null
  const bases = [path.join(projectRoot, 'src', rest), path.join(projectRoot, rest)]
  const candidates = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js', '/index.jsx']
  for (const base of bases) {
    const resolved = fileExistsFirstMatch(base, candidates)
    if (resolved) return resolved
  }
  return null
}

/**
 * Parse the consumer `cmsDependencies` module and collect export names, plus
 * whether it has a default export or `export *` (affects name validation).
 */
function listCmsFileExportInfo(depsFilePath, traverse) {
  const names = new Set()
  const out = { names, hasDefault: false, hasExportAll: false, parseError: true }
  if (!depsFilePath || !fs.existsSync(depsFilePath)) {
    return out
  }
  let text
  try {
    text = fs.readFileSync(depsFilePath, 'utf8')
  } catch {
    return out
  }
  // Cheap regex pass still catches some patterns the AST can miss; union with parse.
  const re = /export\s+(?:const|function|class|let|var|type|interface|enum)\s+([A-Za-z_$][\w$]*)/g
  let m
  while ((m = re.exec(text)) !== null) names.add(m[1])

  let ast
  try {
    ast = parser.parse(text, { sourceType: 'module', plugins: ['typescript', 'jsx'], errorRecovery: true })
  } catch {
    out.parseError = true
    return out
  }

  traverse(ast, {
    ExportDefaultDeclaration() {
      out.hasDefault = true
    },
    ExportAllDeclaration() {
      out.hasExportAll = true
    },
    ExportNamedDeclaration(p) {
      const n = p.node
      if (n.declaration) {
        if (t.isFunctionDeclaration(n.declaration) && n.declaration.id) names.add(n.declaration.id.name)
        if (t.isClassDeclaration(n.declaration) && n.declaration.id) names.add(n.declaration.id.name)
        if (t.isVariableDeclaration(n.declaration) && Array.isArray(n.declaration.declarations)) {
          for (const d of n.declaration.declarations) {
            if (d && t.isIdentifier(d.id)) names.add(d.id.name)
          }
        }
        if (t.isTSTypeAliasDeclaration(n.declaration) && t.isIdentifier(n.declaration.id)) {
          names.add(n.declaration.id.name)
        }
        if (t.isTSInterfaceDeclaration(n.declaration) && t.isIdentifier(n.declaration.id)) {
          names.add(n.declaration.id.name)
        }
        if (t.isTSEnumDeclaration(n.declaration) && t.isIdentifier(n.declaration.id)) {
          names.add(n.declaration.id.name)
        }
      }
      for (const s of n.specifiers) {
        if (t.isExportSpecifier(s)) {
          const ex = t.isStringLiteral(s.exported) ? s.exported.value : s.exported.name
          names.add(ex)
        }
      }
    },
  })
  out.parseError = false
  return out
}

/**
 * Load the set of symbols currently exported from the deps file (union regex + AST).
 * Used to avoid duplicating definitions when inlining.
 */
function readDepsFileSymbols(depsFilePath, traverse) {
  if (!depsFilePath || !fs.existsSync(depsFilePath)) return new Set()
  return listCmsFileExportInfo(depsFilePath, traverse).names
}

/**
 * `import { x } from "@/cmsDependencies"` must match a real export in the consumer
 * `cmsDependencies` file. Reports warnings (does not change imports).
 */
function validateCmsAliasImportsMatchDepsFile({ ast, report, depsImportAlias, depsFilePath, traverse }) {
  if (!depsFilePath) {
    return
  }
  const base = path.basename(depsFilePath)
  if (!fs.existsSync(depsFilePath)) {
    report.warnings.push(
      `Cannot validate ${depsImportAlias} imports: ${base} not found (check sitePath, --deps-file, and path).`,
    )
    return
  }

  const { names, hasDefault, hasExportAll } = listCmsFileExportInfo(depsFilePath, traverse)

  traverse(ast, {
    ImportDeclaration(p) {
      if (p.node.source.value !== depsImportAlias) return
      if (p.node.importKind === 'type') return
      for (const s of p.node.specifiers) {
        if (t.isImportDefaultSpecifier(s)) {
          if (!hasDefault) {
            report.warnings.push(
              `Default import from ${depsImportAlias} but ${base} has no \`export default\`.`,
            )
          }
        } else if (t.isImportNamespaceSpecifier(s)) {
          report.warnings.push(
            `Namespace import from ${depsImportAlias} was not cross-checked; ensure ${base} re-exports everything you need.`,
          )
        } else if (t.isImportSpecifier(s)) {
          const required = t.isIdentifier(s.imported) ? s.imported.name : s.imported.value
          if (!names.has(required)) {
            if (hasExportAll) {
              report.warnings.push(
                `No static export named "${required}" in ${base} (imported from ${depsImportAlias}) — the file has \`export *\`; it may still exist at runtime if re-exported from another module.`,
              )
            } else {
              report.warnings.push(
                `Not exported from ${base}: "${required}" (imported from ${depsImportAlias}). Add a named (or re-)export in that file.`,
              )
            }
          }
        }
      }
    },
  })
}

/**
 * @param {string} depsFilePath
 * @param {Set<string>} externalSpecs
 * @param {{ namespaceImports: { spec: string, namespaceId: string }[], recordEntries: { spec: string, namespaceId: string }[] }} depsPatch
 */
function mergeNpmRegistryPatch(depsFilePath, externalSpecs, depsPatch) {
  if (!depsFilePath || !externalSpecs.size) {
    return
  }
  const { namespaces, recordKeys } = fs.existsSync(depsFilePath)
    ? analyzeExistingCmsDepsFile(depsFilePath)
    : { namespaces: new Map(), recordKeys: new Set() }
  const usedIds = new Set([...namespaces.values()])
  const sorted = [...externalSpecs].sort()

  for (const spec of sorted) {
    if (recordKeys.has(spec)) {
      continue
    }
    let base = packageToNamespaceIdentifier(spec)
    if (!/^[A-Za-z_$]/.test(base) || !/\w$/.test(base)) {
      base = 'Pkg'
    }
    let id = base
    let n = 2
    while (usedIds.has(id)) {
      id = `${base}${n}`
      n += 1
    }
    usedIds.add(id)
    depsPatch.namespaceImports.push({ spec, namespaceId: id })
    depsPatch.recordEntries.push({ spec, namespaceId: id })
  }
}

/**
 * Main entry: scan imports, decide keep/move, collect what to write to the deps
 * file, rewrite imports in the component, and return the collected source text
 * so the command can write the deps file (or show a dry-run diff).
 *
 * External npm imports (e.g. `lucide-react`, `@radix-ui/react-dialog`) keep `from "pkg"`
 * lines in the template; when `depsFilePath` is set, `depsPatch` lists namespace imports and
 * `cmsDependencies[spec]` record entries (same shape as the consumer registry).
 * Relative imports and `@/…` path aliases are re-exported through the Components barrel
 * and rewritten to `import { … }` from the barrel path (e.g. @/app/components/Components) so
 * cmsDependencies can keep `import * as Components` and `'@components': { module: Components }` only.
 */
function extractDependencies({
  ast,
  traverse,
  generate,
  filePath,
  depsImportAlias = '@/cmsDependencies',
  depsFilePath = null,
  logger = () => {},
  reexportLocalImportsViaBarrel = false,
  barrelFilePath = null,
  /** @type {string|null} e.g. @/app/components/Components — stored on report for the writer */
  componentsImportPathForBarrel = null,
}) {
  const existingSymbols = depsFilePath ? readDepsFileSymbols(depsFilePath, traverse) : new Set()
  const report = {
    kept: [],
    moved: [],
    conflicts: [],
    warnings: [],
    appendedSource: '',
    movedSymbols: [],
    depsPatch: { namespaceImports: [], recordEntries: [] },
    useBarrelReexports: false,
    barrelReexportLines: [],
    barrelFilePath: barrelFilePath || null,
    componentsImportPath: componentsImportPathForBarrel || null,
  }
  const reexportMode = Boolean(
    reexportLocalImportsViaBarrel && typeof barrelFilePath === 'string' && barrelFilePath.length > 0,
  )

  // Collect relocate candidates first so we don't mutate while traversing.
  const toRelocate = []
  /** @type {Set<string>} npm or scoped package specifiers (e.g. lucide-react, @org/pkg) to register in cms */
  const externalNpmSpecs = new Set()
  traverse(ast, {
    ImportDeclaration(p) {
      const spec = p.node.source.value
      const specifiers = p.node.specifiers
      // Already routed through the consumer cmsDependencies module — not something
      // to hoist into the record from the template file.
      if (spec === depsImportAlias) {
        report.kept.push(spec)
        return
      }
      if (componentsImportPathForBarrel && spec === componentsImportPathForBarrel) {
        report.kept.push(spec)
        return
      }
      // `@/…` is a project path alias (e.g. tsconfig paths), not an npm scoped package.
      if (isPathAliasImport(spec)) {
        const resolved = resolvePathAliasImport(filePath, spec)
        if (resolved) {
          toRelocate.push({ path: p, spec, relative: true, resolvedPath: resolved })
          return
        }
        report.warnings.push(
          `Could not resolve path alias import to a file: ${spec} (looked under src/ and project root near ${path.basename(filePath)})`,
        )
        return
      }
      if (!isRelative(spec)) {
        // External package: keep direct `from "..."` in template; registry rows go in depsPatch.
        if (specifiers.length) {
          const typeOnly = importDeclarationIsTypescriptTypeOnly(p.node)
          const typesOnlyTarget = isTypesOnlyNpmRegistryPath(spec, filePath)
          if (!typeOnly && !typesOnlyTarget) {
            externalNpmSpecs.add(spec)
          } else if (!typeOnly && typesOnlyTarget) {
            report.warnings.push(
              `Skipping cmsDependencies registry for types-only path "${spec}" (no runtime module to bundle).`,
            )
          }
        } else {
          report.warnings.push(`Side-effect import kept in template: ${spec}`)
        }
        report.kept.push(spec)
        return
      }
      toRelocate.push({ path: p, spec, relative: true })
    },
  })

  if (depsFilePath) {
    mergeNpmRegistryPatch(depsFilePath, externalNpmSpecs, report.depsPatch)
    validateCmsAliasImportsMatchDepsFile({
      ast,
      report,
      depsImportAlias,
      depsFilePath,
      traverse,
    })
  }

  if (!toRelocate.length) {
    logger(report)
    return report
  }

  if (!depsFilePath) {
    throw new Error(
      'BILD: local or @/ imports need a cmsDependencies file. Set sitePath and cmsDependenciesFile in config, or use --deps-file.',
    )
  }
  if (!reexportMode) {
    throw new Error(
      'BILD: local or @/ imports require a resolvable app root with app/components/Components (or src/app/components/Components). ' +
        'Set sitePath in ~/.bild.json, pass --site-path, or run from your Next app directory. ' +
        'Inlining into cmsDependencies is not supported.',
    )
  }

  for (const item of toRelocate) {
    const { path: p, spec } = item
    const specifiers = p.node.specifiers
    if (!specifiers.length) {
      report.warnings.push(`Side-effect import kept in template: ${item.spec}`)
      continue
    }

    if (reexportMode && specifiers.some((s) => t.isImportNamespaceSpecifier(s))) {
      report.warnings.push(
        `Namespace import from a local path cannot be merged via the Components barrel; keeping import as written: ${item.spec}`,
      )
      continue
    }

    // Relative / path-alias import: re-export from the components barrel.
    const resolved = item.resolvedPath || resolveRelativeImport(filePath, spec)
    if (!resolved) {
      report.warnings.push(`Could not resolve relative import: ${spec}`)
      continue
    }
    let srcText
    try {
      srcText = fs.readFileSync(resolved, 'utf8')
    } catch (e) {
      report.warnings.push(`Failed to read ${resolved}: ${e.message}`)
      continue
    }

    // Extract only the symbols the consumer actually imports.
    const wantedNames = specifiers
      .map((s) => {
        if (t.isImportSpecifier(s)) {
          return t.isIdentifier(s.imported) ? s.imported.name : s.imported.value
        }
        if (t.isImportDefaultSpecifier(s)) return '__default__'
        return null
      })
      .filter(Boolean)

    const localAliases = new Map() // importedName -> aliasName used in component

    for (const s of specifiers) {
      const localName = s.local.name
      if (t.isImportSpecifier(s)) {
        const importedName = t.isIdentifier(s.imported) ? s.imported.name : s.imported.value
        localAliases.set(importedName, localName)
      } else if (t.isImportDefaultSpecifier(s)) {
        localAliases.set('__default__', localName)
      }
    }

    const { defaultExportName, foundNames } = extractNamedExports({
      source: srcText,
      wantedNames,
      resolvedPath: resolved,
      generate,
    })

    const movedInThisDecl = []

    for (const name of wantedNames) {
      if (name === '__default__') {
        if (!defaultExportName) {
          report.warnings.push(`No default export found in ${spec}`)
          continue
        }
        const localName = localAliases.get('__default__')
        if (existingSymbols.has(localName)) {
          report.conflicts.push({ name: localName, from: spec })
          continue
        }
        const rec = { name: localName, from: spec, kind: 'default' }
        report.movedSymbols.push(rec)
        movedInThisDecl.push(rec)
        existingSymbols.add(localName)
      } else {
        if (!foundNames.has(name)) {
          report.warnings.push(`Symbol ${name} not found in ${spec}`)
          continue
        }
        if (existingSymbols.has(name)) {
          report.conflicts.push({ name, from: spec })
          continue
        }
        const rec = { name, from: spec, kind: 'named' }
        report.movedSymbols.push(rec)
        movedInThisDecl.push(rec)
        existingSymbols.add(name)
      }
    }

    if (reexportMode) {
      const fromClause = reexportFromClauseForBarrel(resolved, spec, barrelFilePath)
      const reexportLine = buildBarrelReexportLine(specifiers, movedInThisDecl, fromClause)
      const importFrom = componentsImportPathForBarrel
      if (reexportLine && importFrom) {
        report.barrelReexportLines.push(reexportLine)
        report.moved.push(spec)
        rewriteToAliasImport(p, specifiers, importFrom)
      }
    }
  }

  if (reexportMode && report.barrelReexportLines.length) {
    report.useBarrelReexports = true
  }
  report.appendedSource = ''
  logger(report)
  return report
}

function rewriteToAliasImport(importPath, specifiers, depsImportAlias) {
  // Replace the entire ImportDeclaration with one that imports from the deps alias.
  const newSpecifiers = specifiers.map((s) => {
    if (t.isImportDefaultSpecifier(s)) {
      // We re-export default as the local name; consumer should import as named.
      return t.importSpecifier(t.identifier(s.local.name), t.identifier(s.local.name))
    }
    if (t.isImportNamespaceSpecifier(s)) {
      // Namespace imports: unusual for utility deps; fall back to keeping the binding name.
      return t.importNamespaceSpecifier(t.identifier(s.local.name))
    }
    return s
  })
  importPath.replaceWith(t.importDeclaration(newSpecifiers, t.stringLiteral(depsImportAlias)))
}

/**
 * Pull named export declarations (and their default-export renames) from a source
 * file as strings. Intentionally simple: parses with the same Babel setup used by
 * the caller and emits the top-level declarations for the requested names.
 */
function extractNamedExports({ source, wantedNames, resolvedPath, generate }) {
  const parser = require('@babel/parser')
  const traverse = require('@babel/traverse').default || require('@babel/traverse')

  const plugins = ['jsx']
  if (/\.tsx?$/.test(resolvedPath)) plugins.push('typescript')

  let ast
  try {
    ast = parser.parse(source, { sourceType: 'module', plugins, errorRecovery: true })
  } catch (e) {
    return { chunks: [], defaultExportName: null, foundNames: new Set(), parseError: e.message }
  }

  normalizeTSTypeCastExpressionsForGenerator(ast, traverse)

  const chunks = []
  const foundNames = new Set()
  let defaultExportName = null

  traverse(ast, {
    ExportNamedDeclaration(p) {
      if (!p.node.declaration) return
      const decl = p.node.declaration
      if (t.isVariableDeclaration(decl)) {
        for (const d of decl.declarations) {
          if (t.isIdentifier(d.id) && wantedNames.includes(d.id.name)) {
            foundNames.add(d.id.name)
            chunks.push(generate(p.node).code)
            return
          }
        }
      }
      if (
        (t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) &&
        decl.id &&
        wantedNames.includes(decl.id.name)
      ) {
        foundNames.add(decl.id.name)
        chunks.push(generate(p.node).code)
      }
    },
    FunctionDeclaration(p) {
      if (!p.parentPath.isProgram()) return
      if (!p.node.id) return
      if (foundNames.has(p.node.id.name)) return
      if (wantedNames.includes(p.node.id.name)) {
        foundNames.add(p.node.id.name)
        chunks.push('export ' + generate(p.node).code)
      }
    },
    VariableDeclaration(p) {
      if (!p.parentPath.isProgram()) return
      for (const d of p.node.declarations) {
        if (t.isIdentifier(d.id) && wantedNames.includes(d.id.name) && !foundNames.has(d.id.name)) {
          foundNames.add(d.id.name)
          chunks.push('export ' + generate(p.node).code)
          return
        }
      }
    },
    ExportDefaultDeclaration(p) {
      if (!wantedNames.includes('__default__')) return
      const decl = p.node.declaration
      if (t.isFunctionDeclaration(decl) && decl.id) {
        defaultExportName = decl.id.name
        chunks.push('export ' + generate(decl).code)
      } else if (t.isIdentifier(decl)) {
        defaultExportName = decl.name
      } else {
        // `export default <expression>` — synthesize a named export.
        const name = `default_${path.basename(resolvedPath).replace(/\W/g, '_')}`
        defaultExportName = name
        chunks.push(`export const ${name} = ${generate(decl).code}`)
      }
    },
  })

  return { chunks, defaultExportName, foundNames }
}

module.exports = {
  extractDependencies,
  listCmsFileExportInfo,
  validateCmsAliasImportsMatchDepsFile,
}
