// packed-artifact closure oracle. packs the real workspace tarballs, unpacks
// them into isolated temp dirs, and proves a tarball-only consumer imports
// the foundation contracts through package exports with no workspace path.
import { execFileSync } from 'node:child_process'
import {
  existsSync,
  mkdirSync,
  mkdtempSync,
  readdirSync,
  readFileSync,
  realpathSync,
  renameSync,
  writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { auditUnpackedManifest, findForbiddenDependencies } from './closure'
import { loadUserOneOptions } from '../vite/loadConfig'

const here = dirname(fileURLToPath(import.meta.url))
const oneDir = resolve(here, '../..')
const vxrnDir = resolve(here, '../../../vxrn')
const vitePluginMetroDir = resolve(here, '../../../vite-plugin-metro')
const utilsDir = resolve(here, '../../../utils')
const workspaceRoot = resolve(here, '../../..')
const basicStarterDir = resolve(workspaceRoot, '../examples/one-basic')

const KNOWN_ONE_BLOCKERS: string[] = []
const KNOWN_VXRN_BLOCKERS: string[] = []
const KNOWN_VITE_PLUGIN_METRO_BLOCKERS: string[] = []

function packToDir(packageDir: string, outDir: string): string {
  const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'))
  const fileName = `${manifest.name.replace('@', '').replace('/', '-')}-${manifest.version}.tgz`
  execFileSync('bun', ['pm', 'pack', '--destination', outDir], {
    cwd: packageDir,
    encoding: 'utf8',
  })
  const tarball = join(outDir, fileName)
  expect(existsSync(tarball)).toBe(true)
  return tarball
}

// packs each package and every workspace package it depends on, so a consumer
// installs the release closure itself and never an older or missing npm version
function packWorkspaceClosure(packageDirs: string[], outDir: string) {
  const workspaces = new Map<string, string>()
  for (const entry of readdirSync(workspaceRoot, { withFileTypes: true })) {
    const packageDir = join(workspaceRoot, entry.name)
    const manifestPath = join(packageDir, 'package.json')
    if (!entry.isDirectory() || !existsSync(manifestPath)) continue
    const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
    workspaces.set(manifest.name, packageDir)
  }

  const tarballs: Record<string, string> = {}
  const pending = [...packageDirs]
  for (const packageDir of pending) {
    const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'))
    if (tarballs[manifest.name]) continue
    tarballs[manifest.name] = packToDir(packageDir, outDir)
    for (const name of Object.keys({
      ...manifest.dependencies,
      ...manifest.optionalDependencies,
      ...manifest.peerDependencies,
    })) {
      const dependencyDir = workspaces.get(name)
      if (dependencyDir) pending.push(dependencyDir)
    }
  }
  return tarballs
}

function unpack(tarball: string, destDir: string): string {
  mkdirSync(destDir, { recursive: true })
  execFileSync('tar', ['-xzf', tarball, '-C', destDir])
  return join(destDir, 'package')
}

function getInstalledPackageNames(nodeModulesDir: string): string[] {
  const names: string[] = []
  const seen = new Set<string>()

  const visitPackage = (packageDir: string) => {
    const realDir = realpathSync(packageDir)
    if (seen.has(realDir)) return
    seen.add(realDir)
    const manifest = JSON.parse(readFileSync(join(realDir, 'package.json'), 'utf8'))
    if (typeof manifest.name === 'string') names.push(manifest.name)
    visitNodeModules(join(realDir, 'node_modules'))
  }

  const visitNodeModules = (dir: string) => {
    if (!existsSync(dir)) return
    for (const entry of readdirSync(dir, { withFileTypes: true })) {
      if (entry.name.startsWith('.')) continue
      const entryPath = join(dir, entry.name)
      if (entry.name.startsWith('@')) {
        for (const child of readdirSync(entryPath, { withFileTypes: true })) {
          if (child.isDirectory() || child.isSymbolicLink()) {
            visitPackage(join(entryPath, child.name))
          }
        }
      } else if (entry.isDirectory() || entry.isSymbolicLink()) {
        visitPackage(entryPath)
      }
    }
  }

  visitNodeModules(nodeModulesDir)
  return names.sort()
}

describe('packed-artifact closure oracle', () => {
  it('keeps the packed framework manifests free of Expo dependencies', () => {
    const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'one-packed-closure-')))
    const onePkg = JSON.parse(
      readFileSync(
        join(unpack(packToDir(oneDir, tmp), join(tmp, 'one')), 'package.json'),
        'utf8'
      )
    )
    expect(auditUnpackedManifest(onePkg)).toEqual(KNOWN_ONE_BLOCKERS)
    expect(onePkg.dependencies).not.toHaveProperty(
      '@react-native-masked-view/masked-view'
    )
    expect(onePkg.peerDependencies).not.toHaveProperty('react-native-safe-area-context')
    const vxrnExtracted = unpack(packToDir(vxrnDir, tmp), join(tmp, 'vxrn'))
    const vxrnPkg = JSON.parse(readFileSync(join(vxrnExtracted, 'package.json'), 'utf8'))
    expect(auditUnpackedManifest(vxrnPkg)).toEqual(KNOWN_VXRN_BLOCKERS)
    expect(vxrnPkg.exports['./expo-plugin']).toBe('./expo-plugin.cjs')
    expect(existsSync(join(vxrnExtracted, 'expo-plugin.cjs'))).toBe(true)
    expect(existsSync(join(vxrnExtracted, 'native-project-patches.cjs'))).toBe(true)
    const vitePluginMetroPkg = JSON.parse(
      readFileSync(
        join(
          unpack(packToDir(vitePluginMetroDir, tmp), join(tmp, 'vite-plugin-metro')),
          'package.json'
        ),
        'utf8'
      )
    )
    expect(auditUnpackedManifest(vitePluginMetroPkg)).toEqual(
      KNOWN_VITE_PLUGIN_METRO_BLOCKERS
    )
  })

  it('ships the one/native entrypoint with dist and types in the tarball', () => {
    const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'one-packed-native-')))
    const extracted = unpack(packToDir(oneDir, tmp), join(tmp, 'one'))
    for (const target of [
      // one's own native code, autolinked as the `one` dependency
      'One.podspec',
      'react-native.config.cjs',
      'android/build.gradle',
      'android/CMakeLists.txt',
      'nitrogen/generated/ios/One+autolinking.rb',
      'dist/esm/safe-area-context/index.native.js',
      'dist/esm/native/index.mjs',
      'dist/cjs/native/index.cjs',
      'types/native/index.d.ts',
    ]) {
      expect(existsSync(join(extracted, target))).toBe(true)
    }
    const dts = readFileSync(join(extracted, 'types/native/index.d.ts'), 'utf8')
    for (const name of [
      'NativeAppManifest',
      'ONE_PUBLIC_PREFIX',
      'ONE_PLATFORM_ENV',
      'pickOnePublicEnv',
      'validateNativeApp',
    ]) {
      expect(dts).toContain(name)
    }
    // the closure oracle stays test-internal: one/native publishes only the
    // app and env contracts, never a customer-facing Expo ban.
    for (const name of [
      'createResolutionRecorder',
      'findForbiddenDependencies',
      'isForbiddenExpoSpecifier',
      'FORBIDDEN_EXPO_PATTERN_SOURCES',
      'OneNativePlatform',
      'selectOneNativePlatform',
    ]) {
      expect(dts).not.toContain(name)
    }
    const exportsMap = JSON.parse(
      readFileSync(join(extracted, 'package.json'), 'utf8')
    ).exports
    expect(exportsMap['./native'].import).toBe('./dist/esm/native/index.mjs')
  })

  it('imports one/native in a tarball-only consumer with no workspace path', () => {
    const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'one-packed-consumer-')))
    const consumerModules = join(tmp, 'consumer', 'node_modules')
    mkdirSync(consumerModules, { recursive: true })
    const extracted = join(consumerModules, 'one')
    renameSync(unpack(packToDir(oneDir, tmp), join(tmp, 'one')), extracted)
    mkdirSync(join(consumerModules, '@vxrn'), { recursive: true })
    renameSync(
      unpack(packToDir(utilsDir, tmp), join(tmp, 'utils')),
      join(consumerModules, '@vxrn', 'utils')
    )
    const script = `
const url = await import.meta.resolve('one/native');
if (url.includes(${JSON.stringify(workspaceRoot)})) throw new Error('resolved into the workspace: ' + url);
const utilsUrl = await import.meta.resolve('@vxrn/utils/publicEnv');
if (utilsUrl.includes(${JSON.stringify(workspaceRoot)})) throw new Error('utils resolved into the workspace: ' + utilsUrl);
const m = await import('one/native');
m.validateNativeApp({ name: 'T', ios: { bundleId: 'a.b' }, android: { applicationId: 'a.b' } });
if (m.pickOnePublicEnv({ ONE_PUBLIC_A: '1' }).ONE_PUBLIC_A !== '1') throw new Error('env contract broken');
if (m.ONE_PUBLIC_PREFIX !== 'ONE_PUBLIC_' || m.ONE_PLATFORM_ENV !== 'ONE_PLATFORM') throw new Error('env names broken');
if ('createResolutionRecorder' in m || 'selectOneNativePlatform' in m || 'findForbiddenDependencies' in m) throw new Error('test-only oracle leaked into one/native');
console.log('one/native ok ' + url);
`
    const out = execFileSync(
      process.execPath,
      ['--input-type=module', '--eval', script],
      { cwd: join(tmp, 'consumer'), encoding: 'utf8' }
    )
    expect(out).toContain('one/native ok')
    expect(out).not.toContain(workspaceRoot)
  })

  it('keeps the default packed closure Expo-free and bundles Metro after its preset is installed', () => {
    const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'one-packed-metro-')))
    const tarballs = join(tmp, 'tarballs')
    mkdirSync(tarballs)
    const packedClosure = packWorkspaceClosure(
      [oneDir, vxrnDir, vitePluginMetroDir, utilsDir],
      tarballs
    )
    const appDir = join(tmp, 'app')
    mkdirSync(join(appDir, 'app'), { recursive: true })
    writeFileSync(join(appDir, '.watchmanconfig'), '{}\n')

    writeFileSync(
      join(appDir, 'package.json'),
      JSON.stringify(
        {
          private: true,
          type: 'module',
          dependencies: {
            one: `file:${packedClosure.one}`,
            vxrn: `file:${packedClosure.vxrn}`,
            '@vxrn/vite-plugin-metro': `file:${packedClosure['@vxrn/vite-plugin-metro']}`,
            '@vxrn/utils': `file:${packedClosure['@vxrn/utils']}`,
            react: '19.2.3',
            'react-native': '0.87.1',
            'react-native-web': '^0.21.2',
            vite: '^8.2.2',
            metro: '^0.87.0',
            'metro-config': '^0.87.0',
          },
          overrides: Object.fromEntries(
            Object.entries(packedClosure).map(([name, tarball]) => [
              name,
              `file:${tarball}`,
            ])
          ),
        },
        null,
        2
      )
    )
    writeFileSync(
      join(appDir, 'babel.config.cjs'),
      `const oneBabelPreset = require('one/babel-preset')
const preset = oneBabelPreset.default || oneBabelPreset
module.exports = function (api) {
  return preset(api, { projectRoot: __dirname })
}\n`
    )
    writeFileSync(
      join(appDir, 'metro.config.cjs'),
      `const { withOne } = require('one/metro-config')
module.exports = withOne(__dirname, { loadViteConfig: false })
`
    )
    writeFileSync(
      join(appDir, 'tsconfig.json'),
      JSON.stringify({ compilerOptions: { baseUrl: '.', paths: {} } })
    )
    writeFileSync(
      join(appDir, 'app/index.tsx'),
      `import React from 'react'
import { Text } from 'react-native'
import { One } from 'one'
export default function App() {
  void One.iOS.Button
  void One.Android.Button
  void One.UI.Blur
  void One.UI.SafeArea.View
  return <Text>packed metro ok</Text>
}
`
    )

    execFileSync('bun', ['install', '--ignore-scripts'], {
      cwd: appDir,
      encoding: 'utf8',
      timeout: 180_000,
    })

    const installedNames = getInstalledPackageNames(join(appDir, 'node_modules'))
    expect(findForbiddenDependencies(installedNames)).toEqual([])
    for (const [name, tarball] of Object.entries(packedClosure)) {
      const installedDir = realpathSync(join(appDir, 'node_modules', name))
      expect(installedDir.startsWith(`${appDir}/node_modules/`)).toBe(true)
      const packedDir = unpack(tarball, join(tmp, 'expected', name))
      expect(
        JSON.parse(readFileSync(join(installedDir, 'package.json'), 'utf8'))
      ).toEqual(JSON.parse(readFileSync(join(packedDir, 'package.json'), 'utf8')))
    }

    const resolutionScript = `
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
for (const specifier of [
  'one/package.json',
  'vxrn/package.json',
  '@vxrn/vite-plugin-metro/package.json',
  '@react-native/metro-config/package.json',
  '@react-native/babel-preset/package.json',
]) {
  const url = await import.meta.resolve(specifier)
  if (url.includes(${JSON.stringify(workspaceRoot)})) {
    throw new Error('resolved into the workspace: ' + url)
  }
}
const config = require('one/react-native-config')
const fs = require('node:fs')
const path = require('node:path')
const root = process.cwd()
fs.mkdirSync(path.join(root, 'ios', 'Packed.xcodeproj'), {recursive: true})
fs.writeFileSync(path.join(root, 'ios', 'Podfile'), "target 'Packed' do\\nend\\n")
fs.mkdirSync(path.join(root, 'android', 'app', 'src', 'main'), {recursive: true})
fs.writeFileSync(path.join(root, 'android', 'app', 'build.gradle'), 'android { namespace "dev.one.packed" }')
fs.writeFileSync(path.join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml'), '<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="dev.one.packed"><application /></manifest>')
const ios = config.platforms.ios.projectConfig(root, {})
const android = config.platforms.android.projectConfig(root, {})
if (ios.sourceDir !== path.join(root, 'ios') || android.sourceDir !== path.join(root, 'android')) {
  throw new Error('native config did not discover both app-owned projects')
}
for (const name of ['run-ios', 'run-android', 'bundle']) {
  if (!config.commands.some(command => command.name === name)) {
    throw new Error('native config is missing command ' + name)
  }
}
for (const [name, dependency] of Object.entries(config.dependencies)) {
  // a package one absorbs has no root: the entry only turns its native code off
  if (dependency.root === undefined) {
    if (dependency.platforms?.ios !== null || dependency.platforms?.android !== null) {
      throw new Error('react-native config entry ' + name + ' neither links nor disables native code')
    }
    continue
  }
  if (dependency.root.includes(${JSON.stringify(workspaceRoot)})) {
    throw new Error('react-native config resolved ' + name + ' into the workspace: ' + dependency.root)
  }
  if (require(dependency.root + '/package.json').name !== name) {
    throw new Error('react-native config resolved ' + name + ' to the wrong package')
  }
}
if (!config.dependencies['react-native-nitro-modules']) {
  throw new Error('react-native config does not link one native dependencies')
}
`
    execFileSync(process.execPath, ['--input-type=module', '--eval', resolutionScript], {
      cwd: appDir,
      encoding: 'utf8',
    })

    expect(() =>
      execFileSync(
        process.execPath,
        ['-e', "require('./metro.config.cjs').catch((error) => { console.error(error.message); process.exitCode = 1 })"],
        { cwd: appDir, encoding: 'utf8' }
      )
    ).toThrow(/Metro requires babel-preset-expo.*Install it/)

    const expoPresetVersion = JSON.parse(
      readFileSync(join(workspaceRoot, '../node_modules/babel-preset-expo/package.json'), 'utf8')
    ).version
    execFileSync('bun', ['add', '-d', `babel-preset-expo@${expoPresetVersion}`], {
      cwd: appDir,
      encoding: 'utf8',
      timeout: 180_000,
    })
    expect(getInstalledPackageNames(join(appDir, 'node_modules'))).toContain('babel-preset-expo')

    for (const dev of ['true', 'false']) {
      const bundlePath = join(appDir, `bundle.${dev}.js`)
      execFileSync(
        join(appDir, 'node_modules/.bin/metro'),
        [
          'build',
          'node_modules/one/metro-entry.js',
          '--config',
          'metro.config.cjs',
          '--platform',
          'ios',
          '--dev',
          dev,
          '--minify',
          dev === 'false' ? 'true' : 'false',
          '--out',
          bundlePath,
        ],
        { cwd: appDir, encoding: 'utf8', timeout: 180_000 }
      )
      expect(readFileSync(bundlePath).byteLength).toBeGreaterThan(1_000_000)
    }
  })

  it('keeps the generated Basic starter on the zero-Expo One contract', async () => {
    const packageJson = JSON.parse(
      readFileSync(join(basicStarterDir, 'package.json'), 'utf8')
    )
    expect(auditUnpackedManifest(packageJson)).toEqual([])
    expect(packageJson.dependencies).not.toHaveProperty('react-native-safe-area-context')
    expect(packageJson.devDependencies).toHaveProperty('@react-native-community/template')
    expect(existsSync(join(basicStarterDir, 'app.json'))).toBe(false)

    const previousCwd = process.cwd()
    try {
      process.chdir(basicStarterDir)
      const rolldown = await loadUserOneOptions('build', true)
      expect(rolldown.oneOptions.native).toMatchObject({
        app: {
          name: 'OneBasic',
          ios: { bundleId: 'dev.onestack.starter' },
          android: { applicationId: 'dev.onestack.starter' },
        },
      })
      expect(rolldown.oneOptions.native).not.toHaveProperty('bundler')
    } finally {
      process.chdir(previousCwd)
    }
  })
}, 180000)
