import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
  DivoomManager,
  type DivoomSenderCall,
  getDivoomOutDir,
} from './manager';

function createGifAssets(dir: string, names: string[]): void {
  for (const name of names) {
    writeFileSync(path.join(dir, name), 'gif');
  }
}

describe('DivoomManager', () => {
  let tempDir: string;
  let calls: DivoomSenderCall[];
  let pythonPath: string;
  let scriptPath: string;
  let originalDivoomEnv: string | undefined;
  let originalXdgDataHome: string | undefined;

  beforeEach(() => {
    originalDivoomEnv = process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
    originalXdgDataHome = process.env.XDG_DATA_HOME;
    delete process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
    tempDir = mkdtempSync(path.join(tmpdir(), 'divoom-test-'));
    // Set XDG_DATA_HOME to a temp path to avoid writing to real user data directory
    process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg-data');
    calls = [];
    pythonPath = path.join(tempDir, 'python');
    scriptPath = path.join(tempDir, 'divoom_send.py');
    writeFileSync(pythonPath, 'python');
    writeFileSync(scriptPath, 'script');
    createGifAssets(tempDir, [
      'intro.gif',
      'orchestrator.gif',
      'explorer.gif',
      'fixer.gif',
      'input.gif',
      'oracle.gif',
    ]);
  });

  afterEach(() => {
    if (originalDivoomEnv === undefined) {
      delete process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
    } else {
      process.env.OH_MY_OPENCODE_SLIM_DIVOOM = originalDivoomEnv;
    }
    if (originalXdgDataHome === undefined) {
      delete process.env.XDG_DATA_HOME;
    } else {
      process.env.XDG_DATA_HOME = originalXdgDataHome;
    }
    rmSync(tempDir, { recursive: true, force: true });
  });

  function createManager(config: { enabled?: boolean } = {}) {
    return new DivoomManager(
      {
        enabled: config.enabled ?? true,
        python: pythonPath,
        script: scriptPath,
      },
      (call) => {
        calls.push(call);
      },
      { assetDir: tempDir },
    );
  }

  test('does nothing when disabled', async () => {
    const manager = createManager({ enabled: false });

    manager.onPluginLoad();
    manager.onTaskStart({
      parentSessionId: 'parent',
      callId: 'call-1',
      args: { subagent_type: 'explorer' },
    });
    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
    await manager.flush();

    expect(calls).toHaveLength(0);
  });

  test('shows intro on plugin load when enabled', async () => {
    const manager = createManager();

    manager.onPluginLoad();
    await manager.flush();

    expect(calls).toHaveLength(1);
    expect(calls[0].args[1]).toBe(path.join(tempDir, 'intro.gif'));
  });

  test('can be enabled for one run with env var', async () => {
    process.env.OH_MY_OPENCODE_SLIM_DIVOOM = '1';
    const manager = new DivoomManager(
      {
        python: pythonPath,
        script: scriptPath,
      },
      (call) => {
        calls.push(call);
      },
      { assetDir: tempDir },
    );

    manager.onPluginLoad();
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'intro.gif'),
    ]);
  });

  test('env var force-enables even when config disables', async () => {
    process.env.OH_MY_OPENCODE_SLIM_DIVOOM = 'true';
    const manager = createManager({ enabled: false });

    manager.onPluginLoad();
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'intro.gif'),
    ]);
  });

  test('shows task agent then orchestrator after a single task', async () => {
    const manager = createManager();

    manager.onTaskStart({
      parentSessionId: 'parent',
      callId: 'call-1',
      args: { subagent_type: 'explorer' },
    });
    await manager.flush();
    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'explorer.gif'),
      path.join(tempDir, 'orchestrator.gif'),
    ]);
  });

  test('shows orchestrator while busy and intro when idle', async () => {
    const manager = createManager();

    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'busy',
      isOrchestrator: true,
    });
    await manager.flush();
    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'idle',
      isOrchestrator: true,
    });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'orchestrator.gif'),
      path.join(tempDir, 'intro.gif'),
    ]);
  });

  test('idle clears active child state and returns to intro', async () => {
    const manager = createManager();

    manager.onTaskStart({
      parentSessionId: 'parent',
      callId: 'call-1',
      args: { subagent_type: 'explorer' },
    });
    await manager.flush();
    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'busy',
      isOrchestrator: true,
    });
    await manager.flush();
    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'idle',
      isOrchestrator: true,
    });
    await manager.flush();
    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
    await manager.flush();
    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'idle',
      isOrchestrator: true,
    });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'explorer.gif'),
      path.join(tempDir, 'intro.gif'),
    ]);
  });

  test('explicit question requests show input until work resumes', async () => {
    const manager = createManager();

    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'busy',
      isOrchestrator: true,
    });
    await manager.flush();
    manager.onUserInputRequired({ sessionId: 'parent', requestId: 'q-1' });
    await manager.flush();
    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'busy',
      isOrchestrator: true,
    });
    await manager.flush();
    manager.onUserInputResolved({ sessionId: 'parent', requestId: 'q-1' });
    await manager.flush();
    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'busy',
      isOrchestrator: true,
    });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'orchestrator.gif'),
      path.join(tempDir, 'input.gif'),
      path.join(tempDir, 'orchestrator.gif'),
    ]);
  });

  test('child prompt restores delegated agent after reply', async () => {
    const manager = createManager();

    manager.onTaskStart({
      parentSessionId: 'parent',
      callId: 'call-1',
      args: { subagent_type: 'explorer' },
    });
    await manager.flush();
    manager.onUserInputRequired({ sessionId: 'child', requestId: 'p-1' });
    await manager.flush();
    manager.onUserInputResolved({ sessionId: 'child', requestId: 'p-1' });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'explorer.gif'),
      path.join(tempDir, 'input.gif'),
      path.join(tempDir, 'explorer.gif'),
    ]);
  });

  test('overlapping prompts keep input until all resolve', async () => {
    const manager = createManager();

    manager.onUserInputRequired({ sessionId: 'one', requestId: 'p-1' });
    await manager.flush();
    manager.onUserInputRequired({ sessionId: 'two', requestId: 'p-2' });
    await manager.flush();
    manager.onUserInputResolved({ sessionId: 'one', requestId: 'p-1' });
    await manager.flush();
    manager.onUserInputResolved({ sessionId: 'two', requestId: 'p-2' });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'input.gif'),
      path.join(tempDir, 'intro.gif'),
    ]);
  });

  test('session deletion clears pending input and rerenders', async () => {
    const manager = createManager();

    manager.onUserInputRequired({ sessionId: 'child', requestId: 'p-1' });
    await manager.flush();
    manager.onSessionDeleted({ sessionId: 'child' });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'input.gif'),
      path.join(tempDir, 'intro.gif'),
    ]);
  });

  test('orchestrator deletion clears busy display', async () => {
    const manager = createManager();

    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'busy',
      isOrchestrator: true,
    });
    await manager.flush();
    manager.onSessionDeleted({ sessionId: 'parent', isOrchestrator: true });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'orchestrator.gif'),
      path.join(tempDir, 'intro.gif'),
    ]);
  });

  test('pending user input falls back to intro when input gif is absent', async () => {
    rmSync(path.join(tempDir, 'input.gif'));
    const manager = createManager();

    manager.onUserInputRequired({ sessionId: 'parent', requestId: 'q-1' });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'intro.gif'),
    ]);
  });

  test('keeps first agent visible for parallel tasks', async () => {
    const manager = createManager();

    manager.onTaskStart({
      parentSessionId: 'parent',
      callId: 'call-1',
      args: { subagent_type: 'explorer' },
    });
    await manager.flush();
    manager.onTaskStart({
      parentSessionId: 'parent',
      callId: 'call-2',
      args: { subagent_type: 'fixer' },
    });
    await manager.flush();
    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-1' });
    await manager.flush();
    manager.onTaskEnd({ parentSessionId: 'parent', callId: 'call-2' });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'explorer.gif'),
      path.join(tempDir, 'orchestrator.gif'),
    ]);
  });

  test('falls back to orchestrator gif for unknown agents', async () => {
    const manager = createManager();

    manager.onTaskStart({
      parentSessionId: 'parent',
      callId: 'call-1',
      args: { subagent_type: 'custom-agent' },
    });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'orchestrator.gif'),
    ]);
  });

  test('uses configured sender settings and gif overrides', async () => {
    const customGif = path.join(tempDir, 'custom-oracle.gif');
    const customPython = path.join(tempDir, 'custom-python');
    const customScript = path.join(tempDir, 'custom-divoom-send.py');
    writeFileSync(customGif, 'gif');
    writeFileSync(customPython, 'python');
    writeFileSync(customScript, 'script');
    const manager = new DivoomManager(
      {
        enabled: true,
        python: customPython,
        script: customScript,
        size: 64,
        fps: 12,
        speed: 250,
        maxFrames: 10,
        posterizeBits: 4,
        gifs: { oracle: customGif },
      },
      (call) => {
        calls.push(call);
      },
      { assetDir: tempDir },
    );

    manager.onTaskStart({
      parentSessionId: 'parent',
      callId: 'call-1',
      args: { subagent_type: 'oracle' },
    });
    await manager.flush();

    expect(calls[0].command).toBe(customPython);
    expect(calls[0].args).toHaveLength(14);
    expect(calls[0].args[0]).toBe(customScript);
    expect(calls[0].args[1]).toBe(customGif);
    expect(calls[0].args.slice(2, 13)).toEqual([
      '--size',
      '64',
      '--fps',
      '12',
      '--speed',
      '250',
      '--max-frames',
      '10',
      '--posterize-bits',
      '4',
      '--out-dir',
    ]);
    // Verify out-dir is absolute (last arg)
    expect(path.isAbsolute(calls[0].args[13])).toBe(true);
  });

  test('drops stale queued sends and keeps latest requested gif', async () => {
    const manager = createManager();

    manager.onPluginLoad();
    manager.onOrchestratorStatus({
      sessionId: 'parent',
      status: 'busy',
      isOrchestrator: true,
    });
    manager.onTaskStart({
      parentSessionId: 'parent',
      callId: 'call-1',
      args: { subagent_type: 'fixer' },
    });
    await manager.flush();

    expect(calls.map((call) => call.args[1])).toEqual([
      path.join(tempDir, 'fixer.gif'),
    ]);
  });

  test('missing executable does not poison future attempts', async () => {
    const manager = new DivoomManager(
      {
        enabled: true,
        python: path.join(tempDir, 'missing-python'),
        script: scriptPath,
      },
      (call) => {
        calls.push(call);
      },
      { assetDir: tempDir },
    );

    manager.onPluginLoad();
    await manager.flush();
    manager.onPluginLoad();
    await manager.flush();

    expect(calls).toHaveLength(0);
  });

  test('out-dir is absolute and independent of process.cwd', async () => {
    const originalCwd = process.cwd();
    const tempCwd = mkdtempSync(path.join(tmpdir(), 'divoom-cwd-test-'));

    try {
      process.chdir(tempCwd);
      const manager = createManager();

      manager.onPluginLoad();
      await manager.flush();

      expect(calls).toHaveLength(1);
      const outDirArg = calls[0].args[calls[0].args.length - 1];
      // Must be absolute, not relative to cwd
      expect(path.isAbsolute(outDirArg)).toBe(true);
      // Must not start with the temp cwd
      expect(outDirArg.startsWith(tempCwd)).toBe(false);
      // Must contain the expected path segments
      expect(outDirArg).toContain('divoom');
      expect(outDirArg).toContain('captures');
    } finally {
      process.chdir(originalCwd);
      rmSync(tempCwd, { recursive: true, force: true });
    }
  });

  test('empty XDG_DATA_HOME falls back to homedir/.local/share', async () => {
    // Set empty XDG_DATA_HOME
    process.env.XDG_DATA_HOME = '';
    const homeDir = path.join(tempDir, 'home');
    const outDirArg = getDivoomOutDir(homeDir);
    // Must be absolute
    expect(path.isAbsolute(outDirArg)).toBe(true);
    expect(outDirArg).toStartWith(path.join(tempDir, 'home'));
    // Must contain the expected path segments
    expect(outDirArg).toContain('divoom');
    expect(outDirArg).toContain('captures');
  });

  test('relative XDG_DATA_HOME falls back to homedir/.local/share', async () => {
    // Set relative XDG_DATA_HOME (should be rejected)
    process.env.XDG_DATA_HOME = 'relative/path/to/data';
    const homeDir = path.join(tempDir, 'home');
    const outDirArg = getDivoomOutDir(homeDir);
    // Must be absolute
    expect(path.isAbsolute(outDirArg)).toBe(true);
    // Must not start with the relative path
    expect(outDirArg.startsWith('relative')).toBe(false);
    expect(outDirArg).toStartWith(path.join(tempDir, 'home'));
    // Must contain the expected path segments
    expect(outDirArg).toContain('divoom');
    expect(outDirArg).toContain('captures');
  });

  test('whitespace-only XDG_DATA_HOME falls back to homedir/.local/share', async () => {
    // Set whitespace-only XDG_DATA_HOME (should be rejected after trim)
    process.env.XDG_DATA_HOME = '   ';
    const homeDir = path.join(tempDir, 'home');
    const outDirArg = getDivoomOutDir(homeDir);
    // Must be absolute
    expect(path.isAbsolute(outDirArg)).toBe(true);
    expect(outDirArg).toStartWith(path.join(tempDir, 'home'));
    // Must contain the expected path segments
    expect(outDirArg).toContain('divoom');
    expect(outDirArg).toContain('captures');
  });
});
