/**
 * AclSandbox orchestration failure-path tests: the win32 resolver is mocked
 * to hand each test a stub binding table, so every checked Win32 call in
 * init/spawn/dispose has a failing counterpart without opening real token or
 * ACL handles. Constructor validation, the fail-closed init cleanup, and the
 * dispose aggregation use the same stubs. Pure stubs — no real Win32 calls,
 * so these run on every platform; the real-FFI round-trip lives in
 * acl.spec.ts and runner.spec.ts (win32 only).
 */

import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'

import { PROCESS_INFORMATION } from '../src/ffi.ts'
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
import { Win32Error } from '../src/errors.ts'
import { AclSandbox } from '../src/index.ts'
import * as abi from '../src/win32-abi.ts'

const PVOID = koffi.pointer('void')

type MockFn = ReturnType<typeof vi.fn>

/** The stub binding table plus the mocks the assertions inspect directly. */
interface HappyStubs {
  api: Win32Bindings
  setNamedSecurityInfoW: MockFn
  convertStringSidToSidW: MockFn
  closeHandle: MockFn
  localFree: MockFn
  createRestrictedToken: MockFn
  createJobObjectW: MockFn
  getNamedSecurityInfoW: MockFn
}

const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined }))

vi.mock('../src/ffi.ts', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../src/ffi.ts')>()
  return {
    ...actual,
    win32: () => Promise.resolve(state.stubs?.api as Win32Bindings),
    win32Sync: () => state.stubs?.api as Win32Bindings,
  }
})

const scratchDirs: string[] = []
afterAll(() => {
  for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})

function scratch(): string {
  const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-index-'))
  scratchDirs.push(dir)
  return dir
}

/**
 * The stub the whole happy pipeline needs: token opening, capability-SID
 * parsing, workspace+temp grants, logon-SID scan, well-known SID, restricted token,
 * default-DACL merge, piped/inherited spawns, drains, and exit waits all
 * succeed. Every test flips one call per branch.
 */
function happyStubs(): HappyStubs {
  let next = 0n
  const fresh = () => ++next

  const openProcess = vi.fn(() => fresh())
  const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => {
    koffi.encode(slot, PVOID, fresh())
    return 1
  })
  const convertStringSidToSidW = vi.fn((_sid: string, slot: NativePtr) => {
    koffi.encode(slot, PVOID, fresh())
    return 1
  })
  const getTempPathW = vi.fn((_length: number, buffer: Buffer) => {
    const temp = tmpdir().replace(/[\\/]$/u, '')
    buffer.write(temp, 'utf16le')
    return temp.length
  })
  const createFileW = vi.fn(() => fresh())
  const getNamedSecurityInfoW = vi.fn((
    _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
    dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
  ) => {
    koffi.encode(dacl, PVOID, 0n)
    koffi.encode(descriptor, PVOID, 0n)
    return 0
  })
  const setEntriesInAclW = vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
    koffi.encode(newAcl, PVOID, fresh())
    return 0
  })
  const setNamedSecurityInfoW = vi.fn(() => 0)
  const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
    if (info === null) {
      koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8)
      return 0 // the size probe is expected to "fail"
    }
    if (cls === abi.TokenGroups) {
      info.writeUInt32LE(1, 0)
      info.writeBigUInt64LE(77n, abi.TOKEN_GROUPS_OFFSET)
      info.writeUInt32LE(abi.SE_GROUP_LOGON_ID, abi.TOKEN_GROUPS_OFFSET + 8)
    } else {
      info.writeBigUInt64LE(88n, 0) // the token's current default DACL
    }
    return 1
  })
  const getLengthSid = vi.fn(() => 12)
  const copySid = vi.fn(() => 1)
  const createWellKnownSid = vi.fn(() => 1)
  const isValidSid = vi.fn(() => 1)
  const createRestrictedToken = vi.fn((
    _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
    _rc: unknown, _rs: unknown, slot: NativePtr,
  ) => {
    koffi.encode(slot, PVOID, fresh())
    return 1
  })
  const setTokenInformation = vi.fn(() => 1)
  const createPipe = vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
    koffi.encode(readSlot, PVOID, fresh())
    koffi.encode(writeSlot, PVOID, fresh())
    return 1
  })
  const setHandleInformation = vi.fn(() => 1)
  const createProcessAsUserW = vi.fn((
    _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
    _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
  ) => {
    koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 })
    return 1
  })
  const peekNamedPipe = vi.fn(() => 0)
  const readFile = vi.fn(() => 1)
  const waitForSingleObject = vi.fn(() => 0)
  const getExitCodeProcess = vi.fn((_process: unknown, slot: NativePtr) => {
    koffi.encode(slot, 'uint32', 42)
    return 1
  })
  const createJobObjectW = vi.fn(() => fresh())
  const setInformationJobObject = vi.fn(() => 1)
  const assignProcessToJobObject = vi.fn(() => 1)
  const resumeThread = vi.fn(() => 0)
  const getStdHandle = vi.fn(() => fresh())
  const localFree = vi.fn(() => 0n)
  const closeHandle = vi.fn(() => 1)
  const getLastError = vi.fn(() => abi.ERROR_BROKEN_PIPE) // the drains' clean EOF
  const formatMessageW = vi.fn(() => 0)

  const api = {
    openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW,
    lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1),
    getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation,
    getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken,
    setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW,
    peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW,
    setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle,
    localFree, closeHandle, getLastError, formatMessageW,
  } as unknown as Win32Bindings
  return {
    api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree,
    createRestrictedToken, createJobObjectW, getNamedSecurityInfoW,
  }
}

beforeEach(() => {
  state.stubs = happyStubs()
})

describe('AclSandbox constructor validation', () => {
  it('rejects a writable directory that does not exist', () => {
    const missing = join(scratch(), 'missing')
    expect(() => new AclSandbox({ writableDirs: [missing], tempDir: null, mode: 'read-only' }))
      .toThrow(/writable dir does not exist/u)
  })

  it('resolves relative writable directories to absolute paths', () => {
    const dir = scratch()
    const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' })
    expect(sandbox.writableDirs).toEqual([resolve(dir)])
    expect(sandbox.mode).toBe('read-only')
    expect(sandbox.tempDir).toBeUndefined()
  })

  it('rejects temp authority under read-only', () => {
    const workspace = scratch()
    const temp = scratch()
    expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'read-only' }))
      .toThrow(/read-only does not accept a temp directory/u)
    expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'read-only' }))
      .toThrow(/read-only does not accept write SIDs/u)
    expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, tempWriteSid: 'S-1-4-9000-1-1', mode: 'read-only' }))
      .toThrow(/read-only does not accept write SIDs/u)
  })

  it('rejects a temp SID when temp writes are disabled', () => {
    const workspace = scratch()
    expect(() => new AclSandbox({
      writableDirs: [workspace],
      tempDir: null,
      writeSid: 'S-1-4-9000-2',
      tempWriteSid: 'S-1-4-9000-2-1',
      mode: 'workspace-write',
    })).toThrow(/temp write SID requires a temp directory/u)
  })
})

describe('AclSandbox init', () => {
  it('completes the happy workspace-write pipeline: workspace and temp grants, restricted token, resolved temp dir', async () => {
    const { setNamedSecurityInfoW } = state.stubs as HappyStubs
    const workspace = scratch()
    const temp = scratch()
    const sandbox = new AclSandbox({
      writableDirs: [workspace],
      tempDir: temp,
      writeSid: 'S-1-4-9000-1',
      tempWriteSid: 'S-1-4-9000-1-1',
      mode: 'workspace-write',
    })
    await sandbox.init()
    expect(sandbox.tempDir).toBe(resolve(temp))
    expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2)
  })

  it('requires an explicit private temp directory or null under workspace-write', () => {
    const workspace = scratch()
    expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }))
      .toThrow(/requires an explicit private temp directory or null/u)
  })

  it('applies no grants when the temp dir option is null', async () => {
    const { setNamedSecurityInfoW } = state.stubs as HappyStubs
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' })
    await sandbox.init()
    expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only
  })

  it('rejects a temp dir that does not exist', async () => {
    const workspace = scratch()
    const sandbox = new AclSandbox({
      writableDirs: [workspace],
      tempDir: join(scratch(), 'missing'),
      writeSid: 'S-1-4-9000-4',
      tempWriteSid: 'S-1-4-9000-4-1',
      mode: 'workspace-write',
    })
    await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u)
  })

  it('builds a read-only token without parsing a write SID or applying grants', async () => {
    const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' })
    await sandbox.init()
    expect(convertStringSidToSidW).not.toHaveBeenCalled()
    expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
    expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free
  })

  it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => {
    const { setNamedSecurityInfoW } = state.stubs as HappyStubs
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false })
    await sandbox.init()
    expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
    expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke
  })

  it('refuses a second init on the same instance', async () => {
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' })
    await sandbox.init()
    await expect(sandbox.init()).rejects.toThrow(/already initialized/u)
  })

  it('reports a ConvertStringSidToSidW failure before granting anything', async () => {
    const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
    convertStringSidToSidW.mockReturnValue(0)
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' })
    await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' })
    expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
  })

  it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => {
    const { convertStringSidToSidW } = state.stubs as HappyStubs
    convertStringSidToSidW.mockImplementation(() => 1) // no out slot write
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' })
    await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error)
  })

  it('aggregates failed current and restricted token closes after init', async () => {
    const { closeHandle, createRestrictedToken } = state.stubs as HappyStubs
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' })
    const restrictedToken = 99n
    createRestrictedToken.mockImplementation((
      _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
      _rc: unknown, _rs: unknown, slot: NativePtr,
    ) => {
      koffi.encode(slot, PVOID, restrictedToken)
      return 1
    })
    // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the
    // token-layer close of 1n succeeds and init's close of 2n fails.
    closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n || handle === restrictedToken ? 0 : 1))
    // The failure lands after this.token is stored but before this.api is
    // assigned. Cleanup retries the still-open handle and reports both close
    // failures plus the restricted-token close after releasing parsed SIDs.
    await expect(sandbox.init()).rejects.toMatchObject({
      errors: [
        { api: 'CloseHandle' },
        { api: 'CloseHandle' },
        { api: 'CloseHandle' },
      ],
    })
  })

  it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => {
    const { createRestrictedToken, localFree, getNamedSecurityInfoW } = state.stubs as HappyStubs
    const workspace = scratch()
    const temp = scratch()
    let inCleanup = false
    createRestrictedToken.mockImplementation(() => {
      inCleanup = true // the grants already landed: every later call is the cleanup's
      return 0
    })
    localFree.mockImplementation(() => (inCleanup ? 1n : 0n))
    getNamedSecurityInfoW.mockImplementation((
      _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
      dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
    ) => {
      if (inCleanup) return 2 // the cleanup's revocation read fails too
      koffi.encode(dacl, PVOID, 0n)
      koffi.encode(descriptor, PVOID, 0n)
      return 0
    })
    const sandbox = new AclSandbox({
      writableDirs: [workspace],
      tempDir: temp,
      writeSid: 'S-1-4-9000-10',
      tempWriteSid: 'S-1-4-9000-10-1',
      mode: 'workspace-write',
    })
    await expect(sandbox.init()).rejects.toThrow(/5 cleanup operation\(s\) also failed/u)
    expect(sandbox.tempDir).toBeUndefined()
  })
})

describe('AclSandbox spawn', () => {
  it('refuses to spawn before init', () => {
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' })
    expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u)
  })

  it('pipe spawn drains empty pipes and settles with the child exit code', async () => {
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' })
    await sandbox.init()
    const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace })
    expect(child.pid).toBe(1234)
    const expected = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }
    await expect(child.wait()).resolves.toEqual(expected)
    // The second wait reuses the settled exit-code promise instead of re-waiting.
    await expect(child.wait()).resolves.toEqual(expected)
  })

  it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => {
    const { closeHandle } = state.stubs as HappyStubs
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' })
    await sandbox.init()
    const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
    await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 })
    expect(closeHandle).toHaveBeenCalled()
  })

  it('inherit spawn reports a failed close of the kill-on-close job', async () => {
    const { closeHandle, createJobObjectW } = state.stubs as HappyStubs
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' })
    await sandbox.init()
    let jobHandle = 0n
    closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1))
    const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
    jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr
    await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' })
  })
})

describe('AclSandbox dispose', () => {
  it('is a no-op before init', () => {
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' })
    expect(() => { sandbox.dispose() }).not.toThrow()
  })

  it('aggregates a failing temp revocation into an AggregateError', async () => {
    const { getNamedSecurityInfoW } = state.stubs as HappyStubs
    const workspace = scratch()
    const temp = scratch()
    const sandbox = new AclSandbox({
      writableDirs: [workspace],
      tempDir: temp,
      writeSid: 'S-1-4-9000-16',
      tempWriteSid: 'S-1-4-9000-16-1',
      mode: 'workspace-write',
    })
    await sandbox.init()
    getNamedSecurityInfoW.mockReturnValue(2)
    expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u)
  })

  it('aggregates SID and token cleanup failures into an AggregateError', async () => {
    const { localFree } = state.stubs as HappyStubs
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' })
    await sandbox.init()
    localFree.mockReturnValue(1n)
    expect(() => { sandbox.dispose() }).toThrow(AggregateError)
  })

  it('reports a failed close of the restricted token', async () => {
    const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs
    const workspace = scratch()
    const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' })
    let restrictedToken = 0n
    createRestrictedToken.mockImplementation((
      _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
      _rc: unknown, _rs: unknown, slot: NativePtr,
    ) => {
      restrictedToken = 99n
      koffi.encode(slot, PVOID, restrictedToken)
      return 1
    })
    closeHandle.mockImplementation((handle: NativePtr) => (handle === restrictedToken ? 0 : 1))
    await sandbox.init()
    expect(() => { sandbox.dispose() }).toThrow(AggregateError)
  })
})
