/**
 * test/setup-hooks.test.ts — T-hooks-installer tests
 *
 * Tests the exported pure functions from scripts/setup-hooks.mjs:
 *   - computeMaxTimeSec: L5/SEC-M4 --max-time computation
 *   - buildPermCommand: held-permission curl with dynamic max-time
 *   - buildNtfyCommand: ntfy bridge curl (SEC-C6: no token literal)
 *   - buildStatusLineCommand: absolute path to statusline.mjs
 *   - FF_EVENTS: must include PostToolUse (H1/AC-A4.5)
 *   - applyHooks: integration — install/remove idempotency, ntfy, statusLine
 *
 * Acceptance criteria covered:
 *   AC-A1.7  ntfy command has correct priority; settings.json has NO token literal
 *   AC-A4.5  FF_EVENTS contains PostToolUse
 *   AC-B2.1  statusLine segment: present after install, idempotent, --remove cleans
 *   SEC-C6   token not in settings.json/argv
 *   SEC-M4   --max-time > permTimeoutMs/1000
 *   L5       --max-time computed from PERM_TIMEOUT_MS env, not hardcoded
 */

import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — vitest handles .mjs; tsc does not check test/ files
import {
  computeMaxTimeSec,
  buildPermCommand,
  buildNtfyCommand,
  buildStatusLineCommand,
  FF_EVENTS,
  FF_COMMAND,
  PERM_COMMAND,
  MARKER,
  STATUSLINE_MARKER,
  applyHooks,
} from '../scripts/setup-hooks.mjs'

// Path to the scripts directory (for absolute path assertions)
const SCRIPTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../scripts')

// ── computeMaxTimeSec ────────────────────────────────────────────────────────

describe('computeMaxTimeSec', () => {
  it('adds CURL_BUFFER_SEC (5) on top of ceil(permTimeoutMs/1000)', () => {
    // 300000ms = 300s + 5 = 305
    expect(computeMaxTimeSec(300_000)).toBe(305)
  })

  it('handles custom permTimeoutMs correctly', () => {
    // 60000ms = 60s + 5 = 65
    expect(computeMaxTimeSec(60_000)).toBe(65)
    // 90001ms = ceil(90.001) = 91 + 5 = 96
    expect(computeMaxTimeSec(90_001)).toBe(96)
  })

  it('uses default (300000ms) when permTimeoutMs is 0 (invalid)', () => {
    expect(computeMaxTimeSec(0)).toBe(305)
  })

  it('uses default when permTimeoutMs is negative', () => {
    expect(computeMaxTimeSec(-1000)).toBe(305)
  })

  it('uses default when called with no arguments', () => {
    expect(computeMaxTimeSec()).toBe(305)
  })

  it('returns value > permTimeoutMs/1000 (L5/SEC-M4)', () => {
    const permTimeoutMs = 180_000
    const maxTime = computeMaxTimeSec(permTimeoutMs)
    expect(maxTime).toBeGreaterThan(permTimeoutMs / 1000)
  })
})

// ── buildPermCommand ─────────────────────────────────────────────────────────

describe('buildPermCommand', () => {
  it('includes the provided maxTimeSec in --max-time', () => {
    const cmd = buildPermCommand(305)
    expect(cmd).toContain('--max-time 305')
  })

  it('does NOT contain the old hardcoded value 350', () => {
    const cmd = buildPermCommand(305)
    expect(cmd).not.toContain('350')
  })

  it('references WEBTERM_HOOK_URL/permission', () => {
    const cmd = buildPermCommand(305)
    expect(cmd).toContain('WEBTERM_HOOK_URL')
    expect(cmd).toContain('/permission')
  })

  it('sends body via --data-binary @- (reads stdin)', () => {
    const cmd = buildPermCommand(305)
    expect(cmd).toContain('--data-binary @-')
  })
})

// ── buildNtfyCommand ─────────────────────────────────────────────────────────

describe('buildNtfyCommand', () => {
  it('high-priority command contains "Priority: high"', () => {
    const cmd = buildNtfyCommand('high')
    expect(cmd).toContain('Priority: high')
  })

  it('low-priority command contains "Priority: low"', () => {
    const cmd = buildNtfyCommand('low')
    expect(cmd).toContain('Priority: low')
  })

  it('uses shell variable references for URL, TOPIC, and TOKEN (SEC-C6)', () => {
    const cmd = buildNtfyCommand('high')
    expect(cmd).toContain('WEBTERM_NTFY_URL')
    expect(cmd).toContain('WEBTERM_NTFY_TOPIC')
    expect(cmd).toContain('WEBTERM_NTFY_TOKEN')
  })

  it('does NOT contain any literal token value (SEC-C6)', () => {
    // The token must be referenced via env var, never as a literal string
    const cmd = buildNtfyCommand('high')
    // No hardcoded token patterns (Bearer followed by non-$ string)
    expect(cmd).not.toMatch(/Bearer [^$]/)
  })

  it('contains MARKER (WEBTERM_HOOK_URL) for cleanup idempotency', () => {
    // The no-op guard references WEBTERM_HOOK_URL, ensuring cleanup logic catches it
    const cmd = buildNtfyCommand('high')
    expect(cmd).toContain(MARKER) // MARKER = 'WEBTERM_HOOK_URL'
  })

  it('is a no-op outside web-terminal (guard: [ -n "$WEBTERM_HOOK_URL" ])', () => {
    const cmd = buildNtfyCommand('low')
    expect(cmd).toContain('WEBTERM_HOOK_URL')
    // Command has a conditional that makes it a no-op when WEBTERM_HOOK_URL is absent
    expect(cmd).toMatch(/\[ -n .+WEBTERM_HOOK_URL/)
  })

  it('always ends with || true (fire-and-forget, no hook failure)', () => {
    expect(buildNtfyCommand('high')).toContain('|| true')
    expect(buildNtfyCommand('low')).toContain('|| true')
  })
})

// ── buildStatusLineCommand ────────────────────────────────────────────────────

describe('buildStatusLineCommand', () => {
  it('includes the STATUSLINE_MARKER (statusline.mjs)', () => {
    const cmd = buildStatusLineCommand(SCRIPTS_DIR)
    expect(cmd).toContain(STATUSLINE_MARKER) // 'statusline.mjs'
  })

  it('uses an absolute path to statusline.mjs', () => {
    const cmd = buildStatusLineCommand(SCRIPTS_DIR)
    // Command should contain an absolute path
    const scriptPath = path.join(SCRIPTS_DIR, 'statusline.mjs')
    expect(cmd).toContain(scriptPath)
  })

  it('starts with "node " (runs the script with Node)', () => {
    const cmd = buildStatusLineCommand(SCRIPTS_DIR)
    expect(cmd).toMatch(/^node /)
  })
})

// ── FF_EVENTS ────────────────────────────────────────────────────────────────

describe('FF_EVENTS', () => {
  it('contains PostToolUse (H1/AC-A4.5)', () => {
    expect(FF_EVENTS).toContain('PostToolUse')
  })

  it('contains PreToolUse', () => {
    expect(FF_EVENTS).toContain('PreToolUse')
  })

  it('contains Stop and SessionEnd (DONE signals)', () => {
    expect(FF_EVENTS).toContain('Stop')
    expect(FF_EVENTS).toContain('SessionEnd')
  })

  it('contains UserPromptSubmit', () => {
    expect(FF_EVENTS).toContain('UserPromptSubmit')
  })

  it('contains Notification', () => {
    expect(FF_EVENTS).toContain('Notification')
  })
})

// ── FF_COMMAND / PERM_COMMAND constants ───────────────────────────────────────

describe('FF_COMMAND', () => {
  it('contains MARKER for cleanup identification', () => {
    expect(FF_COMMAND).toContain(MARKER)
  })

  it('is a fire-and-forget (redirects to /dev/null, || true)', () => {
    expect(FF_COMMAND).toContain('>/dev/null')
    expect(FF_COMMAND).toContain('|| true')
  })
})

describe('PERM_COMMAND (module-level constant)', () => {
  it('does NOT contain the old hardcoded 350', () => {
    // L5: --max-time must be computed, not a bare literal 350
    expect(PERM_COMMAND).not.toContain(' 350 ')
    expect(PERM_COMMAND).not.toMatch(/--max-time 350\b/)
  })

  it('contains --max-time with a computed numeric value', () => {
    expect(PERM_COMMAND).toMatch(/--max-time \d+/)
  })

  it('computed --max-time > default permTimeoutMs/1000 (SEC-M4)', () => {
    const match = PERM_COMMAND.match(/--max-time (\d+)/)
    expect(match).toBeTruthy()
    const maxTime = Number(match![1])
    // Default permTimeoutMs = 300000ms = 300s; max-time must be > 300
    expect(maxTime).toBeGreaterThan(300)
  })
})

// ── applyHooks (integration tests) ───────────────────────────────────────────

describe('applyHooks', () => {
  let tmpDir: string
  let settingsPath: string

  beforeEach(() => {
    tmpDir = mkdtempSync(path.join(tmpdir(), 'webterm-setup-hooks-test-'))
    settingsPath = path.join(tmpDir, 'settings.json')
  })

  afterEach(() => {
    rmSync(tmpDir, { recursive: true, force: true })
  })

  it('creates settings.json when it does not exist', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    expect(existsSync(settingsPath)).toBe(true)
  })

  it('installs hook entry for every FF_EVENT', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    for (const ev of FF_EVENTS) {
      expect(settings.hooks[ev], `hook for ${ev} should exist`).toBeDefined()
      expect(Array.isArray(settings.hooks[ev])).toBe(true)
      expect(settings.hooks[ev].length).toBeGreaterThanOrEqual(1)
    }
  })

  it('installs PostToolUse hook (H1/AC-A4.5)', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    expect(settings.hooks['PostToolUse']).toBeDefined()
    const groups = settings.hooks['PostToolUse']
    expect(groups.some((g: { hooks?: Array<{ command?: string }> }) =>
      g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
    )).toBe(true)
  })

  it('installs PermissionRequest hook with computed --max-time (L5)', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 60_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    expect(settings.hooks['PermissionRequest']).toBeDefined()
    const allCommands: string[] = settings.hooks['PermissionRequest']
      .flatMap((g: { hooks?: Array<{ command?: string }> }) =>
        g.hooks?.map((h) => h.command ?? '') ?? []
      )
    const permCmd = allCommands.find((c) => c.includes('/permission'))
    expect(permCmd).toBeDefined()
    // permTimeoutMs=60000 → ceil(60)+5=65
    expect(permCmd).toContain('--max-time 65')
    // must NOT contain old hardcoded value
    expect(permCmd).not.toContain(' 350 ')
  })

  it('installs statusLine handler (B2/AC-B2.1)', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    expect(settings.statusLine).toBeDefined()
    expect(settings.statusLine).toContain(STATUSLINE_MARKER)
    expect(settings.statusLine).toContain(path.join(SCRIPTS_DIR, 'statusline.mjs'))
  })

  it('is idempotent: running twice produces same result (B2/AC-B2.1)', () => {
    const opts = { settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }
    applyHooks(opts)
    const first = JSON.parse(readFileSync(settingsPath, 'utf8'))
    applyHooks(opts)
    const second = JSON.parse(readFileSync(settingsPath, 'utf8'))
    expect(second).toEqual(first)
  })

  it('--remove removes all web-terminal hook entries (AC-B2.1)', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    // All hook events added by us should be gone
    for (const ev of [...FF_EVENTS, 'PermissionRequest']) {
      const groups = settings.hooks?.[ev]
      if (groups) {
        const hasOurHook = groups.some((g: { hooks?: Array<{ command?: string }> }) =>
          g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
        )
        expect(hasOurHook).toBe(false)
      }
    }
  })

  it('--remove removes statusLine if it was ours (AC-B2.1)', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    expect(settings.statusLine).toBeUndefined()
  })

  it('preserves user non-web-terminal hooks during install', () => {
    const existing = { hooks: { SomeOtherEvent: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] } }
    writeFileSync(settingsPath, JSON.stringify(existing))
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    expect(settings.hooks['SomeOtherEvent']).toBeDefined()
    expect(settings.hooks['SomeOtherEvent'][0].hooks[0].command).toBe('echo hi')
  })

  it('preserves user non-web-terminal hooks during --remove', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settingsAfterInstall = JSON.parse(readFileSync(settingsPath, 'utf8'))
    // Simulate user adding their own hook
    settingsAfterInstall.hooks['UserOwnEvent'] = [{ hooks: [{ type: 'command', command: 'echo mine' }] }]
    writeFileSync(settingsPath, JSON.stringify(settingsAfterInstall))
    applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    expect(settings.hooks?.['UserOwnEvent']).toBeDefined()
  })

  it('preserves user statusLine if not ours', () => {
    const existing = { statusLine: 'node my-custom-statusline.js' }
    writeFileSync(settingsPath, JSON.stringify(existing))
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    // User's statusLine was NOT overwritten (it doesn't contain STATUSLINE_MARKER)
    expect(settings.statusLine).toBe('node my-custom-statusline.js')
  })

  it('creates a .webterm-bak before writing when settings exists', () => {
    writeFileSync(settingsPath, JSON.stringify({ hooks: {} }))
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    expect(existsSync(`${settingsPath}.webterm-bak`)).toBe(true)
  })

  // ── ntfy bridge tests (H3, SEC-C6) ─────────────────────────────────────────

  it('without ntfy: PermissionRequest has exactly 1 hook group', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    expect(settings.hooks['PermissionRequest'].length).toBe(1)
  })

  it('with ntfy: PermissionRequest has 2 hook groups (main + ntfy high)', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    expect(settings.hooks['PermissionRequest'].length).toBe(2)
  })

  it('with ntfy: PermissionRequest ntfy group has high priority (AC-A1.7)', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    const allCommands: string[] = settings.hooks['PermissionRequest']
      .flatMap((g: { hooks?: Array<{ command?: string }> }) =>
        g.hooks?.map((h) => h.command ?? '') ?? []
      )
    const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL'))
    expect(ntfyCmd).toBeDefined()
    expect(ntfyCmd).toContain('Priority: high')
  })

  it('with ntfy: Stop has ntfy group with low priority (AC-A1.7)', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    const allCommands: string[] = settings.hooks['Stop']
      .flatMap((g: { hooks?: Array<{ command?: string }> }) =>
        g.hooks?.map((h) => h.command ?? '') ?? []
      )
    const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL'))
    expect(ntfyCmd).toBeDefined()
    expect(ntfyCmd).toContain('Priority: low')
  })

  it('with ntfy: SessionEnd has ntfy group with low priority', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    const allCommands: string[] = settings.hooks['SessionEnd']
      .flatMap((g: { hooks?: Array<{ command?: string }> }) =>
        g.hooks?.map((h) => h.command ?? '') ?? []
      )
    const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL'))
    expect(ntfyCmd).toBeDefined()
    expect(ntfyCmd).toContain('Priority: low')
  })

  it('with ntfy: settings.json contains NO token literal (SEC-C6/AC-A1.7)', () => {
    // Even with ntfy enabled, the token must never appear as a literal
    // We simulate having a WEBTERM_NTFY_TOKEN in env at test time; the command
    // should reference it as $WEBTERM_NTFY_TOKEN, not the actual value
    applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const content = readFileSync(settingsPath, 'utf8')
    // The token variable is referenced, not expanded — grep for Bearer pattern
    // Ensure no "Bearer <actual-token>" pattern (only "Bearer $WEBTERM_NTFY_TOKEN")
    expect(content).not.toMatch(/Bearer [^$"\\]/)
    // The $ reference must be present
    expect(content).toContain('WEBTERM_NTFY_TOKEN')
  })

  it('with ntfy: --remove removes ntfy hook groups too', () => {
    applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    applyHooks({ settingsPath, remove: true, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
    const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
    // After remove, no groups with MARKER should remain
    for (const ev of Object.keys(settings.hooks ?? {})) {
      const groups: Array<{ hooks?: Array<{ command?: string }> }> = settings.hooks[ev] ?? []
      const hasOurHook = groups.some((g) =>
        g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
      )
      expect(hasOurHook).toBe(false)
    }
  })
})
