/**
 * User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader
 * (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over
 * a real Loader tree, kept live through transactional HMR.
 */

import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Hmr from '@cordisjs/plugin-hmr'
import Loader from '@cordisjs/plugin-loader'
import Timer from '@cordisjs/plugin-timer'
import {
  boot,
  loadOptionalPatches,
  PROFILE_PATCH_FILENAME,
  watchUserPatches,
} from '../src/index.ts'

const NAME = 'dsh-test-bin'

const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))

async function eventually(test: () => boolean, message: string): Promise<void> {
  const deadline = Date.now() + 10_000
  while (!test()) {
    if (Date.now() >= deadline) throw new Error(message)
    await new Promise(resolve => setTimeout(resolve, 10))
  }
}

const settleChokidarChangeThrottle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 75))

describe('loadOptionalPatches', () => {
  afterEach(() => {
    delete process.env.DSH_HOME
  })

  it('returns undefined when no user patch file exists', () => {
    expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined()
  })

  it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
    const dir = tmp()
    writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [
      '- id: agent-loop',
      "  name: '@deepseek-ai/dsh-agent-loop'",
      '  config:',
      '    model: !!js process.env.DSH_SPEC_MODEL',
      '- insert:',
      '    - id: llm',
      "      name: '@deepseek-ai/dsh-llm-pi-ai'",
      '',
    ].join('\n'))
    const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))
    expect(patches).toHaveLength(2)
    expect(patches?.[0]).toMatchObject({
      id: 'agent-loop',
      config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } },
    })
    expect(patches?.[1]?.insert).toHaveLength(1)
  })

  it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => {
    const dir = tmp()
    mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file
    expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
      .toThrow(new RegExp(`^${NAME}: failed to read patches `))
  })

  it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => {
    const dir = tmp()
    writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'invalid: [unclosed\n')
    expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
      .toThrow(new RegExp(`^${NAME}: failed to parse patches `))
    writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n  config:\n    a: !!js\n')
    expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
      .toThrow(new RegExp(`^${NAME}: failed to parse patches `))
  })

  it('fails loud when the file is not a top-level array or an entry is not an object', () => {
    const dir = tmp()
    writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'id: not-a-list\n')
    expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
      .toThrow('must be a top-level YAML array of loader patch entries')
    writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- just-a-string\n')
    expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
      .toThrow(`${NAME}: patches entry 1 in`)
  })
})

describe('boot with user patches', () => {
  function writeTree(dir: string): string {
    writeFileSync(join(dir, 'noop.mjs'), [
      'export const name = "noop"',
      'export function apply(_ctx, config = {}) {',
      '  if (config.fail) throw new Error("candidate config failed")',
      '}',
      '',
    ].join('\n'))
    writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n  name: ./noop.mjs\n  config:\n    value: base\n')
    return join(dir, 'cordis.yml')
  }

  function entryConfig(ctx: Context, id: string): unknown {
    return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
  }

  it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
    const dir = tmp()
    const userDir = tmp()
    writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [
      '- id: noop',
      '  name: ./noop.mjs',
      '  config:',
      '    value: !!js process.env.DSH_APP_BOOT_USER_SPEC',
      '- insert:',
      '    - id: user-extra',
      '      name: ./noop.mjs',
      '',
    ].join('\n'))
    process.env['DSH_APP_BOOT_USER_SPEC'] = 'user-value'
    const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(userDir, PROFILE_PATCH_FILENAME)))
    try {
      const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
      // The mounted plugin received the interpolated environment value.
      expect(noop?.fiber?.config).toEqual({ value: 'user-value' })
      expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true)
    } finally {
      await ctx.fiber.dispose()
      delete process.env['DSH_APP_BOOT_USER_SPEC']
    }
  })

  it('mounts no patch layer for an absent or empty user layer', async () => {
    const dir = tmp()
    const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME)))
    try {
      expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' })
    } finally {
      await ctx.fiber.dispose()
    }
    const empty = tmp()
    writeFileSync(join(empty, PROFILE_PATCH_FILENAME), '[]\n')
    const ctxEmpty = await boot(NAME, writeTree(tmp()), loadOptionalPatches(NAME, join(empty, PROFILE_PATCH_FILENAME)))
    try {
      expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' })
    } finally {
      await ctxEmpty.fiber.dispose()
    }
  })

  it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
    const dir = tmp()
    const userDir = tmp()
    const filename = join(userDir, PROFILE_PATCH_FILENAME)
    const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
    const ctx = await boot(NAME, writeTree(dir), basePatches)
    await ctx.plugin(Timer)
    await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
    const failures: Array<{ filename: string; error: Error }> = []
    ctx.on('hmr/config-update-failed', (failedFilename, error) => {
      failures.push({ filename: failedFilename, error })
    })
    const dispose = await watchUserPatches(ctx, {
      binName: NAME,
      filename,
      compose: userPatches => [...basePatches, ...userPatches],
    })
    try {
      writeFileSync(filename, '- id: noop\n  config:\n    value: live\n')
      await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied')

      writeFileSync(filename, '- id: noop\n  config:\n    fail: true\n')
      await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
      expect(failures[0]).toMatchObject({ filename })
      expect(failures[0]?.error).toBeInstanceOf(Error)
      expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
      await settleChokidarChangeThrottle()

      writeFileSync(filename, 'invalid: [unclosed\n')
      await eventually(() => failures.length === 2, 'parse failure was not broadcast')
      expect(failures[1]?.error).toBeInstanceOf(Error)
      expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
      await settleChokidarChangeThrottle()

      writeFileSync(filename, '- id: noop\n  config:\n    value: recovered\n')
      await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied')
      await settleChokidarChangeThrottle()

      unlinkSync(filename)
      await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
      expect(failures).toHaveLength(2)
      await settleChokidarChangeThrottle()

      // Default compose: the user layer IS the whole patch list, so a
      // fresh generation replaces the app-owned layer instead of stacking on it.
      await dispose()
      const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
      try {
        writeFileSync(filename, '- id: noop\n  config:\n    value: identity\n')
        await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
      } finally {
        await disposeDefault()
      }
    } finally {
      await dispose()
      await ctx.fiber.dispose()
    }
  })

  it('fails loud when the exact watcher lacks HMR or a root Include', async () => {
    const dir = tmp()
    const withoutHmr = await boot(NAME, writeTree(dir))
    await expect(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service')
    await withoutHmr.fiber.dispose()

    const withoutInclude = new Context()
    withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href
    await withoutInclude.plugin(Loader)
    await withoutInclude.plugin(Timer)
    await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
    await expect(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry')
    await withoutInclude.fiber.dispose()
  })

  it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => {
    // A surface can dispose the whole tree while registerConfig's effect
    // registration is still in flight (the HMR effect then fails with
    // INACTIVE_EFFECT); the app is exiting exactly as asked, so the watcher
    // must not crash the process. The stub makes the race deterministic — the
    // live-teardown ordering itself is not stageable.
    const dir = tmp()
    const ctx = await boot(NAME, writeTree(dir))
    try {
      const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
      ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
      const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
      await expect(dispose()).resolves.toBeUndefined()
    } finally {
      await ctx.fiber.dispose()
    }
  })

  it('propagates registration failures other than mid-teardown', async () => {
    const dir = tmp()
    const filename = join(tmp(), PROFILE_PATCH_FILENAME)
    const ctx = await boot(NAME, writeTree(dir))
    try {
      await ctx.plugin(Timer)
      await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
      const dispose = await watchUserPatches(ctx, { binName: NAME, filename })
      // Same user-layer path registered twice: HMR refuses; not a teardown race.
      await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
      await dispose()
    } finally {
      await ctx.fiber.dispose()
    }
  })
})
