local createLibTest = require('/apis/libtest');

local testlib = createLibTest({ ... });

local function containsLine(lines, expected)
  for _, line in ipairs(lines) do
    if line == expected then return true; end
  end
  return false;
end

local function containsText(lines, expected)
  for _, line in ipairs(lines) do
    if string.find(line, expected, 1, true) then return true; end
  end
  return false;
end

local RECONNECT_EVENT = 'trapos_cloud_reconnect';

local function fakeSettings(values, saveOk)
  return {
    values = values or {},
    saveCount = 0,
    saveOk = saveOk ~= false,
    get = function(self, key)
      return self.values[key];
    end,
    set = function(self, key, value)
      self.values[key] = value;
    end,
    unset = function(self, key)
      self.values[key] = nil;
    end,
    save = function(self)
      self.saveCount = self.saveCount + 1;
      return self.saveOk;
    end,
  };
end

-- Find the token from the most recent login-verify reconnect, so scripted verdicts can echo the
-- exact token the program generated for the current attempt (no test-only seam in the program).
local function latestVerifyToken(queuedEvents)
  for i = #queuedEvents, 1, -1 do
    local e = queuedEvents[i];
    if e[1] == RECONNECT_EVENT and e[2] == 'login-verify' then
      return e[4];
    end
  end
  return nil;
end

-- Fake eventloop standing in for /apis/eventloop. Each runLoop() consumes the next entry from
-- `attempts` (one per verifyPassword call) and dispatches the listed verdict steps to the
-- program's registered handlers, echoing the captured token. A matching verdict / timeout stops
-- the loop (the program's finish() calls stopLoop); a mismatched/tokenless step is dispatched but
-- ignored by the program, so the loop continues to the next step.
local function makeFakeEventloop(queuedEvents, attempts)
  local attemptIndex = 0;
  return function()
    local handlers = {};
    local timeoutFn = nil;
    local stopped = false;
    local el = {};

    function el.register(name, fn)
      handlers[name] = fn;
      return function() end;
    end
    function el.setTimeout(fn)
      timeoutFn = fn;
      return function() end;
    end
    function el.stopLoop()
      stopped = true;
    end

    function el.runLoop()
      attemptIndex = attemptIndex + 1;
      local steps = attempts[attemptIndex] or {};
      local token = latestVerifyToken(queuedEvents);
      for _, step in ipairs(steps) do
        if stopped then break; end
        if step == 'connected' then
          if handlers.trapos_cloud_connected then handlers.trapos_cloud_connected('7:base', token); end
        elseif step == 'connected-mismatch' then
          if handlers.trapos_cloud_connected then handlers.trapos_cloud_connected('7:base', 'other-token'); end
        elseif step == 'connected-tokenless' then
          if handlers.trapos_cloud_connected then handlers.trapos_cloud_connected('7:base', nil); end
        elseif step == 'unauthorized' then
          if handlers.trapos_cloud_unauthorized then handlers.trapos_cloud_unauthorized('7:base', 'unauthorized', token); end
        elseif step == 'unauthorized-mismatch' then
          if handlers.trapos_cloud_unauthorized then handlers.trapos_cloud_unauthorized('7:base', 'unauthorized', 'other-token'); end
        elseif step == 'timeout' then
          if timeoutFn then timeoutFn(); end
        elseif step == 'terminate' then
          if handlers.terminate then handlers.terminate(); end
          break; -- the real default loop breaks on terminate
        end
      end
    end

    el.startLoop = el.runLoop;
    return el;
  end
end

local function runCloud(args, opts)
  opts = opts or {};
  local lines = {};
  local writes = {};
  local readMasks = {};
  local cloudRequests = {};
  local queuedEvents = {};
  local settingsLib = opts.settings or fakeSettings();
  local readValues = opts.readValues or {};
  local fakeCreateEventLoop = makeFakeEventloop(queuedEvents, opts.attempts or {});
  local uuidCounter = 0;

  local env = setmetatable({
    print = function(line)
      lines[#lines + 1] = line or '';
    end,
    write = function(text)
      writes[#writes + 1] = text or '';
    end,
    read = function(mask)
      readMasks[#readMasks + 1] = mask;
      local value = readValues[#readMasks];
      if value == nil then return ''; end
      return value;
    end,
    settings = {
      get = function(key) return settingsLib:get(key); end,
      set = function(key, value) return settingsLib:set(key, value); end,
      unset = function(key) return settingsLib:unset(key); end,
      save = function() return settingsLib:save(); end,
    },
    os = setmetatable({
      queueEvent = function(...)
        queuedEvents[#queuedEvents + 1] = table.pack(...);
      end,
    }, { __index = os }),
    require = function(path)
      if path == '/apis/libcloud' then
        return function()
          return {
            request = function(msgType, payload, requestOpts)
              cloudRequests[#cloudRequests + 1] = {
                msgType = msgType,
                payload = payload,
                opts = requestOpts,
              };
              return opts.requestOk ~= false, opts.requestResult or { serverOk = true, opencodeOk = true };
            end,
            uuid = function()
              uuidCounter = uuidCounter + 1;
              return 'tok-' .. uuidCounter;
            end,
          };
        end;
      end
      if path == '/apis/eventloop' then
        return fakeCreateEventLoop;
      end
      if path == '/apis/libversion' then
        return function()
          return { forSelf = function() return '0.0.0-test'; end };
        end;
      end
      return require(path);
    end,
  }, { __index = _G });

  local chunk, loadErr = loadfile('/programs/cloud.lua', 't', env);
  if not chunk then error(loadErr, 0); end
  local ok, err = pcall(chunk, table.unpack(args or {}));

  return {
    ok = ok,
    err = err,
    lines = lines,
    writes = writes,
    readMasks = readMasks,
    cloudRequests = cloudRequests,
    queuedEvents = queuedEvents,
    settings = settingsLib,
  };
end

-- Count queued events of a given name, optionally with a given reason as the first arg.
local function countQueued(queuedEvents, name, reason)
  local n = 0;
  for _, e in ipairs(queuedEvents) do
    if e[1] == name and (reason == nil or e[2] == reason) then
      n = n + 1;
    end
  end
  return n;
end

testlib.test('cloud help lists status and login without health', function()
  local ctx = runCloud({ '--help' });

  testlib.assertTrue(containsLine(ctx.lines, '  cloud status'));
  testlib.assertTrue(containsLine(ctx.lines, '  cloud login [--force|-f] [password]'));
  testlib.assertTrue(not containsLine(ctx.lines, '  cloud health'));
  testlib.assertTrue(not containsText(ctx.lines, 'set-password'));
end);

testlib.test('cloud health is not kept as an alias', function()
  local ctx = runCloud({ 'health' }, {
    settings = fakeSettings({ ['cloud.url'] = 'wss://example.test' }),
  });

  testlib.assertEquals(#ctx.cloudRequests, 0);
  testlib.assertTrue(containsLine(ctx.lines, 'cloud usage:'));
end);

testlib.test('cloud status probes the server', function()
  local ctx = runCloud({ 'status' }, {
    settings = fakeSettings({ ['cloud.url'] = 'wss://example.test' }),
    requestResult = { serverOk = true, opencodeOk = false, opencodeDetail = 'missing key' },
  });

  testlib.assertEquals(#ctx.cloudRequests, 1);
  testlib.assertEquals(ctx.cloudRequests[1].msgType, 'probe_server');
  testlib.assertTrue(containsLine(ctx.lines, 'serverOk: true'));
  testlib.assertTrue(containsLine(ctx.lines, 'opencodeOk: false'));
  testlib.assertTrue(containsLine(ctx.lines, 'opencode: missing key'));
end);

testlib.test('cloud set-password is not kept as an alias', function()
  local ctx = runCloud({ 'set-password', 'hunter2' });

  testlib.assertEquals(ctx.settings.values['cloud.password'], nil);
  testlib.assertEquals(ctx.settings.saveCount, 0);
  testlib.assertTrue(containsLine(ctx.lines, 'cloud usage:'));
end);

testlib.test('cloud login verifies then saves a prompted password', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login' }, {
    settings = settingsLib,
    readValues = { 'hunter2' },
    attempts = { { 'connected' } },
  });

  testlib.assertEquals(ctx.ok, true);
  testlib.assertEquals(ctx.writes[1], 'Password: ');
  testlib.assertEquals(ctx.readMasks[1], '*');
  testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
  testlib.assertEquals(settingsLib.saveCount, 1);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 0);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-saved'), 0);
  testlib.assertTrue(containsLine(ctx.lines, 'logged in'));
  -- the verify reconnect carries the candidate secret, never persisted before acceptance
  local verify = ctx.queuedEvents[1];
  testlib.assertEquals(verify[2], 'login-verify');
  testlib.assertEquals(verify[3], 'hunter2');
end);

testlib.test('cloud login verifies then saves a password argument without prompting', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login', 'hunter2' }, {
    settings = settingsLib,
    attempts = { { 'connected' } },
  });

  testlib.assertEquals(ctx.ok, true);
  testlib.assertEquals(#ctx.readMasks, 0);
  testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
  testlib.assertEquals(settingsLib.saveCount, 1);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1);
  testlib.assertTrue(containsLine(ctx.lines, 'logged in'));
end);

testlib.test('cloud login refuses replacing without force, before checking cloud.url', function()
  local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); -- note: no cloud.url
  local ctx = runCloud({ 'login' }, {
    settings = settingsLib,
    readValues = { 'new' },
  });

  testlib.assertEquals(#ctx.readMasks, 0);
  testlib.assertEquals(settingsLib.values['cloud.password'], 'old');
  testlib.assertEquals(settingsLib.saveCount, 0);
  testlib.assertEquals(#ctx.queuedEvents, 0);
  testlib.assertTrue(containsText(ctx.lines, 'use cloud login --force'));
  testlib.assertTrue(not containsLine(ctx.lines, 'set cloud.url first'));
end);

testlib.test('cloud login --force verifies then replaces an existing password', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test', ['cloud.password'] = 'old' });
  local ctx = runCloud({ 'login', '--force' }, {
    settings = settingsLib,
    readValues = { 'new' },
    attempts = { { 'connected' } },
  });

  testlib.assertEquals(ctx.ok, true);
  testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
  testlib.assertEquals(settingsLib.saveCount, 1);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1);
end);

testlib.test('cloud login -f verifies then replaces from a password argument', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test', ['cloud.password'] = 'old' });
  local ctx = runCloud({ 'login', '-f', 'new' }, {
    settings = settingsLib,
    attempts = { { 'connected' } },
  });

  testlib.assertEquals(ctx.ok, true);
  testlib.assertEquals(#ctx.readMasks, 0);
  testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
  testlib.assertEquals(settingsLib.saveCount, 1);
end);

testlib.test('cloud login without cloud.url fails fast without prompting or saving', function()
  local settingsLib = fakeSettings();
  local ctx = runCloud({ 'login' }, {
    settings = settingsLib,
    readValues = { 'hunter2' },
  });

  testlib.assertEquals(#ctx.readMasks, 0);
  testlib.assertEquals(settingsLib.values['cloud.password'], nil);
  testlib.assertEquals(settingsLib.saveCount, 0);
  testlib.assertEquals(#ctx.queuedEvents, 0);
  testlib.assertTrue(containsLine(ctx.lines, 'set cloud.url first'));
end);

testlib.test('cloud login re-prompts after a wrong password and saves only the accepted one', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login' }, {
    settings = settingsLib,
    readValues = { 'wrong', 'right' },
    attempts = { { 'unauthorized' }, { 'connected' } },
  });

  testlib.assertEquals(ctx.ok, true);
  testlib.assertEquals(settingsLib.values['cloud.password'], 'right');
  testlib.assertEquals(settingsLib.saveCount, 1);
  testlib.assertEquals(#ctx.readMasks, 2);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 2);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1);
  testlib.assertTrue(containsText(ctx.lines, 'invalid password, try again'));
end);

testlib.test('cloud login re-prompts on a blank password without verifying it', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login' }, {
    settings = settingsLib,
    readValues = { '', 'good' },
    attempts = { { 'connected' } },
  });

  testlib.assertEquals(ctx.ok, true);
  testlib.assertEquals(settingsLib.values['cloud.password'], 'good');
  testlib.assertEquals(#ctx.readMasks, 2);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1);
  testlib.assertEquals(ctx.queuedEvents[1][3], 'good'); -- never the blank value
  testlib.assertTrue(containsLine(ctx.lines, 'password cannot be empty'));
end);

testlib.test('cloud login interactive timeout restores and does not save', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login' }, {
    settings = settingsLib,
    readValues = { 'hunter2' },
    attempts = { { 'timeout' } },
  });

  testlib.assertEquals(ctx.ok, true);
  testlib.assertEquals(settingsLib.values['cloud.password'], nil);
  testlib.assertEquals(settingsLib.saveCount, 0);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1);
  testlib.assertTrue(containsLine(ctx.lines, 'cloud login failed: could not reach gateway'));
end);

testlib.test('cloud login interactive terminate restores and raises Terminated', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login' }, {
    settings = settingsLib,
    readValues = { 'hunter2' },
    attempts = { { 'terminate' } },
  });

  testlib.assertEquals(ctx.ok, false);
  testlib.assertTrue(string.find(tostring(ctx.err), 'Terminated', 1, true) ~= nil);
  testlib.assertEquals(settingsLib.values['cloud.password'], nil);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1);
end);

testlib.test('cloud login non-interactive blank password errors without restore or save', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login', '' }, { settings = settingsLib });

  testlib.assertEquals(ctx.ok, false);
  testlib.assertTrue(string.find(tostring(ctx.err), 'password cannot be empty', 1, true) ~= nil);
  testlib.assertEquals(settingsLib.values['cloud.password'], nil);
  testlib.assertEquals(settingsLib.saveCount, 0);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 0);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 0);
end);

testlib.test('cloud login non-interactive wrong password restores and errors', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login', 'wrong' }, {
    settings = settingsLib,
    attempts = { { 'unauthorized' } },
  });

  testlib.assertEquals(ctx.ok, false);
  testlib.assertTrue(string.find(tostring(ctx.err), 'invalid password', 1, true) ~= nil);
  testlib.assertEquals(settingsLib.values['cloud.password'], nil);
  testlib.assertEquals(settingsLib.saveCount, 0);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1);
end);

testlib.test('cloud login non-interactive timeout restores and errors', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login', 'hunter2' }, {
    settings = settingsLib,
    attempts = { { 'timeout' } },
  });

  testlib.assertEquals(ctx.ok, false);
  testlib.assertTrue(string.find(tostring(ctx.err), 'could not reach gateway', 1, true) ~= nil);
  testlib.assertEquals(settingsLib.saveCount, 0);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1);
end);

testlib.test('cloud login ignores tokenless and mismatched verdicts until its own token matches', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
  local ctx = runCloud({ 'login', 'hunter2' }, {
    settings = settingsLib,
    attempts = { { 'connected-tokenless', 'unauthorized-mismatch', 'connected' } },
  });

  testlib.assertEquals(ctx.ok, true);
  testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
  testlib.assertEquals(settingsLib.saveCount, 1);
  testlib.assertTrue(containsLine(ctx.lines, 'logged in'));
end);

testlib.test('cloud login save failure restores the old password in memory and errors', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test', ['cloud.password'] = 'old' }, false);
  local ctx = runCloud({ 'login', '--force', 'new' }, {
    settings = settingsLib,
    attempts = { { 'connected' } },
  });

  testlib.assertEquals(ctx.ok, false);
  testlib.assertTrue(string.find(tostring(ctx.err), 'could not save settings', 1, true) ~= nil);
  testlib.assertEquals(settingsLib.values['cloud.password'], 'old');
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1);
  testlib.assertTrue(not containsLine(ctx.lines, 'logged in'));
end);

testlib.test('cloud login save failure with no prior password unsets it and errors', function()
  local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }, false);
  local ctx = runCloud({ 'login', 'new' }, {
    settings = settingsLib,
    attempts = { { 'connected' } },
  });

  testlib.assertEquals(ctx.ok, false);
  testlib.assertTrue(string.find(tostring(ctx.err), 'could not save settings', 1, true) ~= nil);
  testlib.assertEquals(settingsLib.values['cloud.password'], nil);
  testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1);
end);

testlib.run();
