import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
  addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
  FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
  installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'

const NAME = 'dsh-test-bin'

const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))

describe('resolveConfigPath', () => {
  it('resolves relative to the given cwd outside replay mode', () => {
    expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
    expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
  })

  it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
    expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
    expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
  })

  it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
    expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
    expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
  })
})

describe('loadEnv', () => {
  it('loads variables from .env in the given dir', () => {
    const dir = tmp()
    writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
    const warn = vi.fn()
    loadEnv(NAME, dir, warn)
    expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
    expect(warn).not.toHaveBeenCalled()
    delete process.env['DSH_APP_BOOT_SPEC_VAR']
  })

  it('stays silent when no .env exists (ambient environment wins)', () => {
    const warn = vi.fn()
    loadEnv(NAME, tmp(), warn)
    expect(warn).not.toHaveBeenCalled()
  })

  it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
    const dir = tmp()
    mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
    const warn = vi.fn()
    loadEnv(NAME, dir, warn)
    expect(warn).toHaveBeenCalledTimes(1)
    expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
  })

  it('defaults dir to the process cwd and warn to a stderr write', () => {
    const dir = tmp()
    writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
    const previous = process.cwd()
    process.chdir(dir)
    try {
      loadEnv(NAME) // happy path: the default warn sink is never invoked
    } finally {
      process.chdir(previous)
    }
    expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
    delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
    // The default warn sink itself: point it at a broken .env with stderr
    // spied, so the arrow body runs without polluting the test output.
    const broken = tmp()
    mkdirSync(join(broken, '.env'))
    const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
    let written: string[]
    try {
      loadEnv(NAME, broken)
      written = write.mock.calls.map(call => String(call[0]))
    } finally {
      write.mockRestore()
    }
    expect(written).toHaveLength(1)
    expect(written[0]).toContain(`${NAME}: failed to load .env: `)
  })
})

describe('installFailLoud', () => {
  function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
    const handlers: Array<(err: unknown) => void> = []
    const written: string[] = []
    const exits: number[] = []
    return {
      handlers, written, exits,
      on: (_event, handler) => { handlers.push(handler) },
      off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
      stderr: { write: (chunk: string) => { written.push(chunk) } },
      exit: (code: number) => { exits.push(code) },
    }
  }

  it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
    const proc = fakeProc()
    installFailLoud(NAME, proc)
    const error = new Error('boom')
    proc.handlers[0]!(error)
    expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
    expect(proc.written[0]).toContain(error.stack)
    expect(proc.exits).toEqual([1])
  })

  // One rejection is reported per install: the first is the diagnosis, so each
  // formatting case needs its own handler rather than reusing a latched one.
  it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
    const plain = fakeProc()
    installFailLoud(NAME, plain)
    plain.handlers[0]!('plain failure')
    expect(plain.written[0]).toContain('plain failure')
    expect(plain.exits).toEqual([1])

    const stackless = new Error('no stack')
    delete (stackless as { stack?: string }).stack
    const bare = fakeProc()
    installFailLoud(NAME, bare)
    bare.handlers[0]!(stackless)
    expect(bare.written[0]).toContain('no stack')
    expect(bare.exits).toEqual([1])
  })

  it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
    const proc = fakeProc()
    const uninstall = installFailLoud(NAME, proc)
    expect(proc.handlers).toHaveLength(1)
    uninstall()
    expect(proc.handlers).toHaveLength(0)
    // Default-proc arm: install on the real process, then immediately uninstall
    // so the suite leaks no handler and can never exit the runner.
    const before = process.listenerCount('unhandledRejection')
    const uninstallReal = installFailLoud(NAME)
    expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
    uninstallReal()
    expect(process.listenerCount('unhandledRejection')).toBe(before)
  })

  it('does not report an activation rejection shared by entries in the boot audit', async () => {
    const proc = fakeProc()
    installFailLoud(NAME, proc)
    const error = new Error('assembled activation failure')
    const audit = assertEntriesActivated({
      loader: {
        entries: () => ['broken-a', 'broken-b'].map(name => ({
          options: { name },
          fiber: {
            state: 3,
            inject: {},
            ctx: { get: () => undefined },
            await: async () => { throw error },
          },
        })),
      },
    } as unknown as Context, NAME)
    await Promise.resolve()
    await Promise.resolve()
    proc.handlers[0]!(error)
    expect(proc.written).toEqual([])
    expect(proc.exits).toEqual([])
    await expect(audit).rejects.toThrow('assembled activation failure')
    proc.handlers[0]!(error)
    expect(proc.exits).toEqual([1])
  })

  // The Loader mounts entries concurrently, so a terminal-owning surface can
  // already hold raw mode when a sibling entry rejects. Exiting without running
  // its teardown strands the terminal on the user's shell.
  it('awaits the release hook before exiting so the terminal owner can restore it', async () => {
    const proc = fakeProc()
    const order: string[] = []
    installFailLoud(NAME, proc, async () => {
      await Promise.resolve()
      order.push('released')
    })
    proc.handlers[0]!(new Error('sibling entry rejected'))
    expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
    // The release is in flight, so the exit has not committed yet.
    expect(proc.exits).toEqual([])
    await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
    expect(order).toEqual(['released'])
  })

  it('still exits when the release hook rejects', async () => {
    const proc = fakeProc()
    installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed')))
    proc.handlers[0]!(new Error('boom'))
    await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  })

  it('exits without waiting when a release hook never settles', async () => {
    vi.useFakeTimers()
    try {
      const proc = fakeProc()
      installFailLoud(NAME, proc, () => new Promise<void>(() => {}))
      proc.handlers[0]!(new Error('boom'))
      expect(proc.exits).toEqual([])
      await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS)
      expect(proc.exits).toEqual([1])
    } finally {
      vi.useRealTimers()
    }
  })

  // Loader failures arrive in bursts, and teardown's own disposers may reject.
  // Only the first rejection is the diagnosis; the handler must stay installed
  // so a later one cannot become uncaught and kill the process mid-teardown.
  it('reports only the first rejection and keeps handling later ones during the release', async () => {
    const proc = fakeProc()
    let released = false
    installFailLoud(NAME, proc, async () => {
      await Promise.resolve()
      released = true
    })
    proc.handlers[0]!(new Error('first rejection'))
    proc.handlers[0]!(new Error('second rejection'))
    expect(proc.handlers).toHaveLength(1)
    expect(proc.written).toHaveLength(1)
    expect(proc.written[0]).toContain('first rejection')
    await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
    expect(released).toBe(true)
  })
})

describe('assertEntriesLoaded', () => {
  const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
    ({ loader: { entries: () => entries } }) as unknown as Context

  it('passes when every enabled entry has a fiber', () => {
    expect(() => { assertEntriesLoaded(ctxWith([
      { fiber: {}, options: { name: 'a' } },
      { disabled: true, options: { name: 'off' } },
    ]), NAME) }).not.toThrow()
  })

  it('throws naming every enabled fiber-less entry', () => {
    expect(() => { assertEntriesLoaded(ctxWith([
      { fiber: {}, options: { name: 'ok' } },
      { options: { name: 'broken-a' } },
      { options: { name: 'broken-b' } },
    ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
  })
})

describe('assertEntriesActivated', () => {
  interface FakeFiber {
    state: number
    inject: Record<string, unknown>
    ctx: { get(name: string): unknown }
    await(): Promise<unknown>
  }

  const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
    loader: { entries: () => entries },
  }) as unknown as Context

  const fiber = (
    state: number,
    error?: unknown,
    inject: Record<string, unknown> = {},
    services: string[] = [],
  ): FakeFiber => ({
    state,
    inject,
    ctx: { get: name => services.includes(name) ? {} : undefined },
    await: error === undefined ? async () => undefined : async () => { throw error },
  })

  it('passes active entries and ignores disabled entries', async () => {
    let awaitCalls = 0
    const active = fiber(2)
    active.await = async () => {
      awaitCalls++
      return undefined
    }
    const disabled = fiber(3, new Error('disabled failure'))
    disabled.await = async () => {
      awaitCalls++
      throw new Error('disabled failure')
    }
    await expect(assertEntriesActivated(ctxWith([
      { fiber: active, options: { name: 'active' } },
      { fiber: disabled, disabled: true, options: { name: 'disabled' } },
    ]), NAME)).resolves.toBeUndefined()
    expect(awaitCalls).toBe(0)
  })

  it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
    const original = new Error('actual plugin failure')
    await expect(assertEntriesActivated(ctxWith([
      { fiber: fiber(3, original), options: { name: 'broken-plugin' } },
    ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
  })

  it('formats stackless and non-Error activation failures', async () => {
    const stackless = new Error('stackless failure')
    delete (stackless as { stack?: string }).stack
    await expect(assertEntriesActivated(ctxWith([
      { fiber: fiber(3, stackless), options: { name: 'stackless' } },
      { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
    ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
  })

  it('reports unresolved services for pending entries', async () => {
    let awaitCalls = 0
    const expected = [
      `${NAME}: 3 entries did not activate`,
      'waiting: pending (waiting for services: missingA, missingB)',
      'single-wait: pending (waiting for service: missing)',
      'unknown-wait: pending (waiting for services: unknown)',
    ].join('\n')
    const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
    const singleWait = fiber(0, undefined, { missing: {} })
    const unknownWait = fiber(0)
    for (const item of [waiting, singleWait, unknownWait]) {
      item.await = async () => {
        awaitCalls++
        return undefined
      }
    }
    await expect(assertEntriesActivated(ctxWith([
      { fiber: waiting, options: { name: 'waiting' } },
      { fiber: singleWait, options: { name: 'single-wait' } },
      { fiber: unknownWait, options: { name: 'unknown-wait' } },
    ]), NAME)).rejects.toThrow(expected)
    expect(awaitCalls).toBe(0)
  })

  it('retains the numeric diagnostic for a settled unexpected state', async () => {
    await expect(assertEntriesActivated(ctxWith([
      { fiber: fiber(4), options: { name: 'disposed' } },
    ]), NAME)).rejects.toThrow('disposed: fiber state 4')
  })
})

describe('loadOverlayPatches', () => {
  it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
    const dir = tmp()
    const valid = join(dir, 'valid.yml')
    writeFileSync(valid, '- id: target\n  config:\n    value: !!js process.env.VALUE\n')
    expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
    expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
    const malformed = join(dir, 'malformed.yml')
    writeFileSync(malformed, ': bad')
    expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
    const mapping = join(dir, 'mapping.yml')
    writeFileSync(mapping, 'id: target\n')
    expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
    const scalar = join(dir, 'scalar.yml')
    writeFileSync(scalar, '- scalar\n')
    expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
  })
})

describe('boot', () => {
  it('boots a leaf config through the real Loader and settles the tree', async () => {
    const dir = tmp()
    writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
    writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n  name: ./noop.mjs\n')
    const ctx = await boot(NAME, join(dir, 'cordis.yml'))
    try {
      const entries = [...ctx.loader.entries()]
      expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
    } finally {
      await ctx.fiber.dispose()
    }
  })

  it('runs host preparation before the Loader tree mounts', async () => {
    const dir = tmp()
    writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
    writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n  name: ./noop.mjs\n')
    const prepared: Context[] = []
    const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
      expect(hostCtx.loader).toBeDefined()
      expect([...hostCtx.loader.entries()]).toEqual([])
      prepared.push(hostCtx)
    })
    try {
      expect(prepared).toEqual([ctx])
    } finally {
      await ctx.fiber.dispose()
    }
  })

  it('disposes partial host setup and labels non-Error preparation failures', async () => {
    const dir = tmp()
    const failure = 42
    let disposed = false
    const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
      ctx.effect(() => () => { disposed = true })
      throw failure
    })

    await expect(task).rejects.toMatchObject({
      message: `${NAME}: host preparation failed: ${failure}`,
      cause: failure,
    })
    expect(disposed).toBe(true)
  })

  it('exposes dshHomePath to Loader config expressions', async () => {
    const dir = tmp()
    const dshHome = join(dir, 'home')
    vi.stubEnv('DSH_HOME', dshHome)
    writeFileSync(join(dir, 'capture.mjs'), [
      'export const name = "capture"',
      'export function apply(ctx, config) {',
      '  ctx.provide("capturedPath", config.path)',
      '}',
      '',
    ].join('\n'))
    writeFileSync(join(dir, 'cordis.yml'), [
      '- id: capture',
      '  name: ./capture.mjs',
      '  config:',
      "    path: !!js dshHomePath('sessions')",
      '',
    ].join('\n'))
    let ctx: Context | undefined
    try {
      ctx = await boot(NAME, join(dir, 'cordis.yml'))
      expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
    } finally {
      await ctx?.fiber.dispose()
      vi.unstubAllEnvs()
    }
  })

  it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
    // A surface can dispose the root fiber while boot() is still awaiting the
    // Loader, before the last entry settles. The Loader service goes with the
    // tree, so reading it for the post-boot assertions would crash an app that
    // exited exactly as the user asked.
    const dir = tmp()
    writeFileSync(join(dir, 'exiting.mjs'), [
      'export const name = "exiting"',
      'export function apply(ctx) {',
      '  void ctx.root.fiber.dispose()',
      '}',
      '',
    ].join('\n'))
    writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n  name: ./exiting.mjs\n')
    const ctx = await boot(NAME, join(dir, 'cordis.yml'))
    expect(ctx.get('loader')).toBeUndefined()
  })

  it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
    const dir = tmp()
    writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n  name: ./missing.mjs\n')
    await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
      `${NAME}: plugin tree failed to load: failed to apply loader entry`,
    )
  })

  it('appends the deepest cause with its original stack to the load failure', async () => {
    const dir = tmp()
    writeFileSync(join(dir, 'failing.mjs'), [
      'export function apply() {',
      "  const failure = new Error('pinned activation failure')",
      "  failure.stack = 'Error: pinned activation failure\\n    at failing-fixture'",
      '  throw failure',
      '}',
      '',
    ].join('\n'))
    writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n  name: ./failing.mjs\n')
    await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
      String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
      String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
    ].join('')))
  })

  it('falls back to the deepest cause message when its stack was erased', async () => {
    const dir = tmp()
    const deepest = new Error('stackless deep failure')
    delete (deepest as { stack?: string }).stack
    await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
      throw new Error('wrapped setup failure', { cause: deepest })
    })).rejects.toThrow(
      `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
    )
  })

  it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
    const dir = tmp()
    writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
    writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n  name: ./waiting.mjs\n')
    await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
      `${NAME}: 1 entry did not activate`,
      './waiting.mjs: pending (waiting for service: neverProvided)',
    ].join('\n'))
  })
})

describe('addHarnessSourceSection', () => {
  const SOURCE_ROOT = `${sep}opt${sep}harness-src`
  const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`

  it('distinguishes the source path from the current workdir between identity and persona', async () => {
    const ctx = new Context()
    try {
      await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
      const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
      expect(dispose).toBeTypeOf('function')
      const systemPrompt = ctx.get('systemPrompt')!
      const rendered = renderPrompt(await systemPrompt.assemble())
      expect(rendered).toContain(EXPECTED)
      // Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards
      // keep a drifted opener/persona string from a false pass through `-1 < n`.
      const identityAt = rendered.indexOf('You are an AI agent powered by the DeepSeek Harness SDK.')
      const sourceAt = rendered.indexOf(EXPECTED)
      const personaAt = rendered.indexOf('You are a coding agent.')
      expect(identityAt).toBeGreaterThanOrEqual(0)
      expect(personaAt).toBeGreaterThanOrEqual(0)
      expect(identityAt).toBeLessThan(sourceAt)
      expect(sourceAt).toBeLessThan(personaAt)
    } finally {
      await ctx.fiber.dispose()
    }
  })

  it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
    const ctx = new Context()
    try {
      expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
    } finally {
      await ctx.fiber.dispose()
    }
  })

  it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
    const ctx = new Context()
    try {
      await ctx.plugin(SystemPrompt, {})
      const systemPrompt = ctx.get('systemPrompt')!
      const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
      const present = await systemPrompt.assemble()
      expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
      dispose()
      const gone = await systemPrompt.assemble()
      expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
    } finally {
      await ctx.fiber.dispose()
    }
  })
})
