/**
 * DesktopTool unit tests.
 *
 * All native deps mocked: osController + grounding adapter are stubs.
 * Verifies:
 *   - kill switch (env)
 *   - per-agent allowlist
 *   - parameter dispatch + validation
 *   - intent → grounding → coord validation pipeline
 *   - drag's screenshot reuse optimisation
 *   - audit logging shape
 */

import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
import { DesktopTool, DesktopAction } from '../DesktopTool.js';

// ─── fakes ─────────────────────────────────────────────────────────────

function fakeOsc(overrides = {}) {
  const calls = [];
  const record = (name) => jest.fn(async (...args) => { calls.push({ name, args }); });
  return {
    screenshot:   jest.fn(async () => Buffer.from('png-bytes')),
    screenSize:   jest.fn(async () => ({ width: 1920, height: 1080, dpr: 1 })),
    mouseMove:    record('mouseMove'),
    mouseClick:   record('mouseClick'),
    mouseScroll:  record('mouseScroll'),
    mouseDrag:    record('mouseDrag'),
    typeText:     record('typeText'),
    keyPress:     record('keyPress'),
    listWindows:  jest.fn(async () => [{ title: 'Notepad' }, { title: 'Browser' }]),
    focusWindow:  jest.fn(async ({ titleMatch }) => ({
      focused: titleMatch === 'note' || titleMatch === 'Notepad',
      title: 'Notepad',
    })),
    describeCapabilities: jest.fn(async () => ({
      os: 'win32', display: null,
      screenshot: true, mouseInput: true, keyboardInput: true, windowFocus: true,
      degradedReason: null, nativeLoaded: true,
    })),
    _calls: calls,
    ...overrides,
  };
}

/** A stub aiService that carries a base url + a fake key manager. */
function fakeAIService() {
  return {
    baseUrl: 'https://api.test.loxia.ai',
    config: { apiKey: 'test-loxia-key' },
    apiKeyManager: {
      getKeysForRequest: jest.fn(() => ({ loxiaApiKey: 'test-loxia-key' })),
    },
  };
}

/**
 * Build a DesktopTool with a stubbed grounding adapter that returns
 * the coords we tell it to.
 */
function makeTool({ osc = fakeOsc(), groundResult, aiService = fakeAIService() } = {}) {
  const tool = DesktopTool.withDependencies({
    aiService,
    osController: osc,
    config: {},
    logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
  });
  // Pre-seed adapter cache so we don't hit the registry.
  tool._adapterCache = {
    ground: jest.fn(async () => groundResult || {
      answer: 'x=500, y=400',
      coords: { x: 500, y: 400 },
      usage: { input_tokens: 1, output_tokens: 1 },
      elapsed_ms: 5,
    }),
  };
  return tool;
}

const FULL_ALLOW = { allowedActions: Object.values(DesktopAction) };

// ─── kill switch / allowlist ───────────────────────────────────────────

describe('DesktopTool — gating', () => {
  const originalEnv = process.env.LOXIA_DESKTOP_TOOL_DISABLED;
  afterEach(() => {
    if (originalEnv === undefined) delete process.env.LOXIA_DESKTOP_TOOL_DISABLED;
    else process.env.LOXIA_DESKTOP_TOOL_DISABLED = originalEnv;
  });

  test('env kill switch short-circuits every call', async () => {
    process.env.LOXIA_DESKTOP_TOOL_DISABLED = '1';
    const tool = makeTool();
    const r = await tool.execute({ action: 'screenshot' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(false);
    expect(r.code).toBe('DESKTOP_DISABLED');
  });

  test('default (no allowedActions) refuses every action', async () => {
    const tool = makeTool();
    const r = await tool.execute({ action: 'screenshot' }, {});
    expect(r.success).toBe(false);
    expect(r.code).toBe('NOT_PERMITTED');
  });

  test('explicit allowlist gates per action', async () => {
    const tool = makeTool();
    const r1 = await tool.execute(
      { action: 'screenshot' },
      { toolConfig: { allowedActions: ['screenshot'] } },
    );
    expect(r1.success).toBe(true);

    const r2 = await tool.execute(
      { action: 'click', x: 10, y: 10 },
      { toolConfig: { allowedActions: ['screenshot'] } }, // click NOT in list
    );
    expect(r2.success).toBe(false);
    expect(r2.code).toBe('NOT_PERMITTED');
  });

  test('unknown action returns INVALID_ACTION', async () => {
    const tool = makeTool();
    const r = await tool.execute({ action: 'self_destruct' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(false);
    expect(r.code).toBe('INVALID_ACTION');
  });
});

// ─── action dispatch ───────────────────────────────────────────────────

describe('DesktopTool — actions', () => {
  test('screenshot saves to disk and returns the path (no inline base64)', async () => {
    const tool = makeTool();
    const r = await tool.execute({ action: 'screenshot' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(true);
    // Critical: we MUST NOT return raw base64 bytes in the tool result —
    // the agent can't read PNG bytes from text, and dumping 200 KB of
    // base64 into the conversation derails the next model turn.
    expect(r).not.toHaveProperty('screenshotBase64');
    expect(typeof r.screenshotPath).toBe('string');
    expect(r.screenshotPath).toMatch(/screenshot-\d+\.png$/);
    expect(r.size).toMatchObject({ width: 1920, height: 1080 });
    expect(r.bytes).toBe(Buffer.from('png-bytes').length);
  });

  test('click with raw (x,y) skips grounding', async () => {
    const osc = fakeOsc();
    const tool = makeTool({ osc });
    const r = await tool.execute(
      { action: 'click', x: 100, y: 200, button: 'right' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expect(r.coords).toEqual({ x: 100, y: 200 });
    expect(r.groundedFrom).toBeNull();
    expect(osc.mouseClick).toHaveBeenCalledWith(100, 200, { button: 'right', count: undefined });
    // Grounding adapter NOT consulted.
    expect(tool._adapterCache.ground).not.toHaveBeenCalled();
  });

  test('click with intent grounds, then clicks', async () => {
    const osc = fakeOsc();
    const tool = makeTool({ osc });
    const r = await tool.execute(
      { action: 'click', intent: 'the Save button' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expect(r.coords).toEqual({ x: 500, y: 400 });
    expect(r.groundedFrom).toBe('the Save button');
    expect(tool._adapterCache.ground).toHaveBeenCalledTimes(1);
    expect(osc.mouseClick).toHaveBeenCalledWith(500, 400, expect.any(Object));
  });

  test('click with neither coords nor intent fails INVALID_INPUT', async () => {
    const tool = makeTool();
    const r = await tool.execute({ action: 'click' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(false);
    expect(r.code).toBe('INVALID_INPUT');
  });

  test('grounding miss (no coords parsed) → GROUNDING_FAILED', async () => {
    const tool = makeTool({
      groundResult: { answer: 'I have no idea where that is', usage: {}, elapsed_ms: 1 },
    });
    const r = await tool.execute(
      { action: 'click', intent: 'mystery button' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(false);
    expect(r.code).toBe('GROUNDING_FAILED');
    expect(r.error).toMatch(/no idea/);
  });

  test('grounded coords outside screen → COORDS_OUT_OF_BOUNDS', async () => {
    const tool = makeTool({
      groundResult: { answer: 'x=9999, y=9999', coords: { x: 9999, y: 9999 } },
    });
    const r = await tool.execute(
      { action: 'click', intent: 'somewhere' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(false);
    expect(r.code).toBe('COORDS_OUT_OF_BOUNDS');
  });

  test('type forwards text and length', async () => {
    const osc = fakeOsc();
    const tool = makeTool({ osc });
    const r = await tool.execute(
      { action: 'type', text: 'hello world' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expect(osc.typeText).toHaveBeenCalledWith('hello world', expect.any(Object));
  });

  test('type with empty text → INVALID_INPUT', async () => {
    const tool = makeTool();
    const r = await tool.execute({ action: 'type', text: '' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(false);
    expect(r.code).toBe('INVALID_INPUT');
  });

  test('key_press forwards keys array', async () => {
    const osc = fakeOsc();
    const tool = makeTool({ osc });
    const r = await tool.execute(
      { action: 'key_press', keys: ['Control', 'S'] },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expect(osc.keyPress).toHaveBeenCalledWith(['Control', 'S']);
    expect(r.output).toMatch(/Control\+S/);
  });

  test('key_press empty keys → INVALID_INPUT', async () => {
    const tool = makeTool();
    const r = await tool.execute({ action: 'key_press', keys: [] }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(false);
    expect(r.code).toBe('INVALID_INPUT');
  });

  test('scroll requires dy', async () => {
    const tool = makeTool();
    const r = await tool.execute(
      { action: 'scroll', x: 100, y: 100 },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(false);
    expect(r.code).toBe('INVALID_INPUT');
  });

  test('scroll with raw coords + dy works', async () => {
    const osc = fakeOsc();
    const tool = makeTool({ osc });
    const r = await tool.execute(
      { action: 'scroll', x: 100, y: 100, dy: -3 },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expect(osc.mouseScroll).toHaveBeenCalledWith(100, 100, -3);
  });

  test('drag with raw from/to → two coord pairs, no grounding', async () => {
    const osc = fakeOsc();
    const tool = makeTool({ osc });
    const r = await tool.execute(
      { action: 'drag', from: { x: 10, y: 20 }, to: { x: 50, y: 60 } },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expect(r.from).toEqual({ x: 10, y: 20 });
    expect(r.to).toEqual({ x: 50, y: 60 });
    expect(osc.mouseDrag).toHaveBeenCalledTimes(1);
    expect(tool._adapterCache.ground).not.toHaveBeenCalled();
  });

  test('list_windows returns titles, filters empty, caps to 50', async () => {
    // Build a stub that mimics a real OS — most "windows" have empty
    // titles (system handles) and the OS reports way more than 50.
    const osc = fakeOsc({
      listWindows: jest.fn(async () => {
        const out = [{ title: 'Notepad' }, { title: 'Browser' }];
        for (let i = 0; i < 200; i++) out.push({ title: '' });          // untitled handles
        for (let i = 0; i < 60; i++)  out.push({ title: `App-${i}` });  // 60 real titles
        return out;
      }),
    });
    const tool = makeTool({ osc });
    const r = await tool.execute({ action: 'list_windows' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(true);
    expect(r.totalCount).toBe(262);
    expect(r.titledCount).toBe(62);        // 2 + 60 titled
    expect(r.windows).toHaveLength(50);    // capped
    expect(r.truncated).toBe(true);
    expect(r.output).toMatch(/Notepad/);
    expect(r.output).toMatch(/filtered 200 untitled/);
  });

  test('focus_window without titleMatch → INVALID_INPUT', async () => {
    const tool = makeTool();
    const r = await tool.execute({ action: 'focus_window' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(false);
    expect(r.code).toBe('INVALID_INPUT');
  });

  test('focus_window match found', async () => {
    const tool = makeTool();
    const r = await tool.execute(
      { action: 'focus_window', titleMatch: 'note' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expect(r.focused).toBe(true);
  });

  test('describe_capabilities returns the capability snapshot', async () => {
    const tool = makeTool();
    const r = await tool.execute({ action: 'describe_capabilities' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(true);
    expect(r.capabilities.os).toBe('win32');
    expect(r.output).toMatch(/screenshot=true/);
  });
});

// ─── adapter build ─────────────────────────────────────────────────────

// ─── REGRESSION: every tool result is action-tagged ────────────────────
//
// Background: in a real run on 2026-05-13 the agent thought a click on
// the Netflix taskbar icon had succeeded because msg[5] in the
// conversation came back with `success:true` and a screenshot path —
// but msg[5] was actually a stale duplicate-screenshot result that
// landed where the click result was expected (msg[7] later carried the
// real click failure, by which point the agent had moved on).
//
// Fix: every action's result `output` MUST begin with "[action: <id>]"
// so the model can ALWAYS map a tool result back to the action it
// belongs to, even when results arrive out-of-order in batched messages.
//
// These tests lock the invariant. Adding a new action without tagging
// the output will fail here and force the author to update both
// places.

describe('REGRESSION: every desktop result includes its action tag', () => {
  function expectActionTag(result, expected) {
    expect(typeof result.output).toBe('string');
    expect(result.output.startsWith(`[action: ${expected}]`)).toBe(true);
    // Structured field too, so consumers don't have to parse the prefix.
    expect(result.action).toBe(expected);
  }

  test('screenshot output begins with [action: screenshot]', async () => {
    const r = await makeTool().execute({ action: 'screenshot' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(true);
    expectActionTag(r, 'screenshot');
  });

  test('click output begins with [action: click]', async () => {
    const r = await makeTool().execute(
      { action: 'click', x: 100, y: 200 },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expectActionTag(r, 'click');
  });

  test('type output begins with [action: type]', async () => {
    const r = await makeTool().execute(
      { action: 'type', text: 'x' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expectActionTag(r, 'type');
  });

  test('key_press output begins with [action: key_press]', async () => {
    const r = await makeTool().execute(
      { action: 'key_press', keys: ['LeftWin'] },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expectActionTag(r, 'key_press');
  });

  test('scroll output begins with [action: scroll]', async () => {
    const r = await makeTool().execute(
      { action: 'scroll', x: 1, y: 1, dy: 5 },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expectActionTag(r, 'scroll');
  });

  test('drag output begins with [action: drag]', async () => {
    const r = await makeTool().execute(
      { action: 'drag', from: { x: 1, y: 1 }, to: { x: 2, y: 2 } },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expectActionTag(r, 'drag');
  });

  test('list_windows output begins with [action: list_windows]', async () => {
    const r = await makeTool().execute({ action: 'list_windows' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(true);
    expectActionTag(r, 'list_windows');
  });

  test('focus_window output begins with [action: focus_window]', async () => {
    const r = await makeTool().execute(
      { action: 'focus_window', titleMatch: 'note' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expectActionTag(r, 'focus_window');
  });

  test('describe_capabilities output begins with [action: describe_capabilities]', async () => {
    const r = await makeTool().execute({ action: 'describe_capabilities' }, { toolConfig: FULL_ALLOW });
    expect(r.success).toBe(true);
    expectActionTag(r, 'describe_capabilities');
  });

  test('failure outputs also tag the action (so an error mid-batch is unambiguous)', async () => {
    const r = await makeTool().execute(
      { action: 'type', text: '' },  // invalid, triggers _fail
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(false);
    expect(r.output).toMatch(/^\[action: type\]/);
    expect(r.action).toBe('type');
  });

  test('grounding-failure click result tags the action', async () => {
    const tool = makeTool({
      groundResult: { answer: 'nope', usage: {}, elapsed_ms: 1 },
    });
    const r = await tool.execute(
      { action: 'click', intent: 'foo' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(false);
    expect(r.output).toMatch(/^\[action: click\]/);
    expect(r.action).toBe('click');
  });
});

describe('DesktopTool — adapter construction', () => {
  test('intent path without aiService → NOT_INITIALISED', async () => {
    const tool = new DesktopTool({ osController: fakeOsc() });
    tool._adapterCache = null; // force real build
    const r = await tool.execute(
      { action: 'click', intent: 'foo' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(false);
    expect(r.code).toBe('NOT_INITIALISED');
  });
});

// ─── parseParameters ───────────────────────────────────────────────────

describe('DesktopTool — parseParameters', () => {
  test('parses {parameters: ...} envelope', () => {
    const tool = makeTool();
    const out = tool.parseParameters('{"parameters": {"action": "screenshot"}}');
    expect(out).toEqual({ action: 'screenshot' });
  });

  test('parses bare object', () => {
    const tool = makeTool();
    const out = tool.parseParameters('{"action": "click", "x": 1, "y": 2}');
    expect(out).toEqual({ action: 'click', x: 1, y: 2 });
  });

  test('rejects non-JSON', () => {
    const tool = makeTool();
    expect(() => tool.parseParameters('<click/>')).toThrow(/JSON/);
  });
});

// ─── drag screenshot reuse ─────────────────────────────────────────────

describe('DesktopTool — drag screenshot reuse', () => {
  test('drag with two intents calls screenshot once, grounds twice', async () => {
    const osc = fakeOsc();
    const tool = makeTool({ osc });
    tool._adapterCache = {
      ground: jest.fn(async ({ intent }) => ({
        answer: 'ok',
        coords: intent === 'A' ? { x: 100, y: 100 } : { x: 200, y: 200 },
      })),
    };
    const r = await tool.execute(
      { action: 'drag', fromIntent: 'A', toIntent: 'B' },
      { toolConfig: FULL_ALLOW },
    );
    expect(r.success).toBe(true);
    expect(osc.screenshot).toHaveBeenCalledTimes(1); // reused for second leg
    expect(tool._adapterCache.ground).toHaveBeenCalledTimes(2);
    expect(r.from).toEqual({ x: 100, y: 100 });
    expect(r.to).toEqual({ x: 200, y: 200 });
  });
});
