local createLibTest = require('/apis/libtest');
local createCloud = require('/apis/libcloud');
local fakes = require('/tests/harness/fake-eventloop');

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

local fakeOs = fakes.fakeOs;
local encode = textutils.serializeJSON;

-- traposId ----------------------------------------------------------------

testlib.test('traposId joins id and label', function()
  local cloud = createCloud();
  testlib.assertEquals(cloud.traposId(fakeOs(7, 'base')), '7:base');
end);

testlib.test('traposId renders empty/nil label as trailing colon', function()
  local cloud = createCloud();
  testlib.assertEquals(cloud.traposId(fakeOs(7, nil)), '7:');
  testlib.assertEquals(cloud.traposId(fakeOs(8, '')), '8:');
end);

-- uuid --------------------------------------------------------------------

testlib.test('uuid emits a v4-shaped string', function()
  local cloud = createCloud();
  local id = cloud.uuid();
  testlib.assertEquals(string.len(id), 36);
  testlib.assertEquals(string.sub(id, 15, 15), '4');
  testlib.assertTrue(string.match(id, '^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4') ~= nil);
end);

-- envelope builders -------------------------------------------------------

testlib.test('buildComputerRequest carries sender id and omits empty payload', function()
  local cloud = createCloud();
  local frame = cloud.buildComputerRequest('7:base', 'mid-1', 'probe_server', nil);
  testlib.assertEquals(frame.event, 'computer_request');
  testlib.assertEquals(frame.traposSenderId, '7:base');
  testlib.assertEquals(frame.messageId, 'mid-1');
  testlib.assertEquals(frame.type, 'probe_server');
  testlib.assertEquals(frame.payload, nil);

  local withPayload = cloud.buildComputerRequest('7:base', 'mid-2', 'x', { a = 1 });
  testlib.assertEquals(withPayload.payload.a, 1);
end);

testlib.test('buildHello includes secret only when non-empty', function()
  local cloud = createCloud();

  local withSecret = cloud.buildHello('7:base', 'hunter2', 'mid');
  testlib.assertEquals(withSecret.event, 'computer_request');
  testlib.assertEquals(withSecret.type, 'hello');
  testlib.assertEquals(withSecret.traposSenderId, '7:base');
  testlib.assertEquals(withSecret.payload.traposId, '7:base');
  testlib.assertEquals(withSecret.payload.secret, 'hunter2');

  local noSecret = cloud.buildHello('7:base', nil, 'mid');
  testlib.assertEquals(noSecret.payload.secret, nil);
  local emptySecret = cloud.buildHello('7:base', '', 'mid');
  testlib.assertEquals(emptySecret.payload.secret, nil);
end);

-- onMessage dispatch ------------------------------------------------------

testlib.test('onMessage replies to a reverse ping', function()
  local cloud = createCloud();
  local sent = {};
  cloud.onMessage(encode({ event = 'server_request', type = 'ping', messageId = 'g-1' }), {
    traposId = '7:base',
    send = function(frame) sent[#sent + 1] = frame; end,
  });

  testlib.assertEquals(#sent, 1);
  testlib.assertEquals(sent[1].event, 'computer_response');
  testlib.assertEquals(sent[1].type, 'ping');
  testlib.assertEquals(sent[1].ok, true);
  testlib.assertEquals(sent[1].messageId, 'g-1');
  testlib.assertEquals(sent[1].traposSenderId, '7:base');
  testlib.assertEquals(sent[1].payload.traposId, '7:base');
end);

testlib.test('onMessage rejects unknown reverse type with unknown_type', function()
  local cloud = createCloud();
  local sent = {};
  cloud.onMessage(encode({ event = 'server_request', type = 'dance', messageId = 'g-2' }), {
    traposId = '7:base',
    send = function(frame) sent[#sent + 1] = frame; end,
  });

  testlib.assertEquals(#sent, 1);
  testlib.assertEquals(sent[1].ok, false);
  testlib.assertEquals(sent[1].error.code, 'unknown_type');
end);

testlib.test('onMessage routes a served reverse type to trapos_cloud_request', function()
  local cloud = createCloud();
  local requested = {};
  cloud.onMessage(encode({
    event = 'server_request', type = 'exec-lua', messageId = 'g-3', payload = { code = 'x' },
  }), {
    traposId = '7:base',
    send = function() error('served type must not reply directly', 0); end,
    isServed = function(t) return t == 'exec-lua'; end,
    queueRequest = function(messageId, msgType, payload)
      requested[#requested + 1] = { messageId = messageId, msgType = msgType, payload = payload };
    end,
  });

  testlib.assertEquals(#requested, 1);
  testlib.assertEquals(requested[1].messageId, 'g-3');
  testlib.assertEquals(requested[1].msgType, 'exec-lua');
  testlib.assertEquals(requested[1].payload.code, 'x');
end);

testlib.test('onMessage replies unknown_type for a non-served reverse type', function()
  local cloud = createCloud();
  local sent = {};
  cloud.onMessage(encode({ event = 'server_request', type = 'exec-lua', messageId = 'g-4' }), {
    traposId = '7:base',
    send = function(frame) sent[#sent + 1] = frame; end,
    isServed = function() return false; end,
    queueRequest = function() error('non-served type must not route', 0); end,
  });

  testlib.assertEquals(#sent, 1);
  testlib.assertEquals(sent[1].ok, false);
  testlib.assertEquals(sent[1].error.code, 'unknown_type');
end);

testlib.test('onMessage drives hello via onHello', function()
  local cloud = createCloud();
  local calls = {};
  cloud.onMessage(encode({ event = 'server_response', type = 'hello', ok = true, payload = { networkId = 'global' } }), {
    onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end,
  });

  testlib.assertEquals(#calls, 1);
  testlib.assertEquals(calls[1].ok, true);
  testlib.assertEquals(calls[1].payload.networkId, 'global');
end);

testlib.test('onMessage queues a reply for a non-hello server_response', function()
  local cloud = createCloud();
  local queued = {};
  cloud.onMessage(encode({
    event = 'server_response', type = 'probe_server', ok = true,
    messageId = 'mid-9', payload = { serverOk = true },
  }), {
    queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
  });

  testlib.assertEquals(#queued, 1);
  testlib.assertEquals(queued[1][1], 'trapos_cloud_reply');
  testlib.assertEquals(queued[1][2], 'mid-9');
  testlib.assertEquals(queued[1][3], true);
  testlib.assertEquals(queued[1][4].serverOk, true);
end);

testlib.test('onMessage coerces a missing payload to an empty table', function()
  local cloud = createCloud();
  local queued = {};
  cloud.onMessage(encode({ event = 'server_response', type = 'noop', ok = true, messageId = 'mid-x' }), {
    queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
  });

  testlib.assertEquals(#queued, 1);
  testlib.assertEquals(type(queued[1][4]), 'table');
  testlib.assertEquals(next(queued[1][4]), nil);
end);

testlib.test('onMessage stays silent on malformed frames', function()
  local cloud = createCloud();
  local sent = 0;
  local ctx = {
    traposId = '7:base',
    send = function() sent = sent + 1; end,
    queueEvent = function() sent = sent + 1; end,
    onHello = function() sent = sent + 1; end,
  };
  cloud.onMessage('not json', ctx);
  cloud.onMessage(encode({ event = 'computer_request', type = 'ping' }), ctx);
  testlib.assertEquals(sent, 0);
end);

-- startSession wiring -----------------------------------------------------

local function startSession(extra)
  local cloud = createCloud();
  local el = fakes.fakeEventloop();
  local http = fakes.fakeHttp();
  local queued = {};
  local opts = {
    eventloop = el,
    http = http,
    url = 'ws://host:4444',
    os = fakeOs(7, 'base'),
    queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
  };
  if extra then
    for k, v in pairs(extra) do opts[k] = v; end
  end
  local session = cloud.startSession(opts);
  return { cloud = cloud, el = el, http = http, queued = queued, session = session, url = http.connects[1] };
end

testlib.test('startSession connects to the /gateway path', function()
  local ctx = startSession();
  testlib.assertEquals(ctx.url, 'ws://host:4444/gateway');
end);

testlib.test('startSession retries when websocketAsync throws', function()
  local http = { connects = {} };
  function http.websocketAsync(url)
    http.connects[#http.connects + 1] = url;
    error('open boom', 0);
  end
  function http.websocket()
    return nil;
  end

  local ctx = startSession({ http = http });

  testlib.assertEquals(ctx.session.isReady(), false);
  testlib.assertEquals(#http.connects, 1);
  testlib.assertEquals(#ctx.el.timers, 1);

  ctx.el.fireTimer(#ctx.el.timers);
  testlib.assertEquals(#http.connects, 2);
  testlib.assertEquals(#ctx.el.timers, 2);
end);

testlib.test('startSession sends hello on open and goes ready on ack', function()
  local ctx = startSession({ secret = 'hunter2' });
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);

  testlib.assertEquals(ctx.session.isReady(), false);
  testlib.assertEquals(#ws.sent, 1);
  local hello = textutils.unserializeJSON(ws.sent[1]);
  testlib.assertEquals(hello.type, 'hello');
  testlib.assertEquals(hello.payload.secret, 'hunter2');

  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
  testlib.assertEquals(ctx.session.isReady(), true);

  local connected = ctx.queued[#ctx.queued];
  testlib.assertEquals(connected[1], 'trapos_cloud_connected');
  testlib.assertEquals(connected[2], '7:base');
end);

testlib.test('startSession treats unauthorized hello as terminal', function()
  local ctx = startSession();
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  local timersAfterOpen = #ctx.el.timers; -- hello watchdog

  ctx.el.fire('websocket_message', ctx.url, encode({
    event = 'server_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' },
  }));

  testlib.assertEquals(ctx.session.isReady(), false);
  testlib.assertEquals(ctx.session.lastError(), 'unauthorized');
  testlib.assertEquals(ws.closed, true);

  -- A subsequent close must NOT schedule a reconnect.
  ctx.el.fire('websocket_closed', ctx.url);
  testlib.assertEquals(#ctx.el.timers, timersAfterOpen);
end);

testlib.test('startSession reconnects on a network drop', function()
  local ctx = startSession();
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  testlib.assertEquals(#ctx.http.connects, 1);

  ctx.el.fire('websocket_closed', ctx.url);
  ctx.el.fireTimer(#ctx.el.timers); -- the reconnect timer
  testlib.assertEquals(#ctx.http.connects, 2);
end);

testlib.test('startSession reconnects on an abnormal close with a non-displaced code', function()
  local ctx = startSession();
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  testlib.assertEquals(#ctx.http.connects, 1);

  ctx.el.fire('websocket_closed', ctx.url, '', 1006); -- abnormal close, not a displacement
  ctx.el.fireTimer(#ctx.el.timers);
  testlib.assertEquals(#ctx.http.connects, 2);
end);

testlib.test('startSession reconnect event reloads the secret immediately', function()
  local secret = 'old';
  local ctx = startSession({ getSecret = function() return secret; end });
  local ws1 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws1);
  local hello1 = textutils.unserializeJSON(ws1.sent[1]);
  testlib.assertEquals(hello1.payload.secret, 'old');
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));

  secret = 'new';
  ctx.el.fire('trapos_cloud_reconnect', 'login');

  testlib.assertEquals(ws1.closed, true);
  testlib.assertEquals(#ctx.http.connects, 2);
  local ws2 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws2);
  local hello2 = textutils.unserializeJSON(ws2.sent[1]);
  testlib.assertEquals(hello2.payload.secret, 'new');
end);

testlib.test('startSession reconnect event suppresses the intentional close', function()
  local ctx = startSession();
  local ws1 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws1);
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));

  ctx.el.fire('trapos_cloud_reconnect', 'login');
  local ws2 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws2);
  local timersAfterOpen = #ctx.el.timers;

  ctx.el.fire('websocket_closed', ctx.url, 'manual close');

  testlib.assertEquals(#ctx.el.timers, timersAfterOpen);
  testlib.assertEquals(#ctx.http.connects, 2);
end);

testlib.test('startSession reconnect event recovers after unauthorized', function()
  local secret = 'old';
  local ctx = startSession({ getSecret = function() return secret; end });
  local ws1 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws1);
  ctx.el.fire('websocket_message', ctx.url, encode({
    event = 'server_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' },
  }));
  testlib.assertEquals(ctx.session.lastError(), 'unauthorized');

  secret = 'new';
  ctx.el.fire('trapos_cloud_reconnect', 'login');

  testlib.assertEquals(ctx.session.lastError(), nil);
  testlib.assertEquals(#ctx.http.connects, 2);
  local ws2 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws2);
  local hello2 = textutils.unserializeJSON(ws2.sent[1]);
  testlib.assertEquals(hello2.payload.secret, 'new');
end);

-- login verification: candidate secret + correlation token ----------------

testlib.test('a verify reconnect sends the candidate secret in the next hello', function()
  local ctx = startSession({ getSecret = function() return 'saved'; end });
  ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok-1');
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  testlib.assertEquals(textutils.unserializeJSON(ws.sent[1]).payload.secret, 'candidate');
end);

testlib.test('a verified hello echoes the token, clears it, and keeps the candidate secret', function()
  local ctx = startSession({ getSecret = function() return 'saved'; end });
  ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok-9');
  local ws1 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws1);

  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
  local connected = ctx.queued[#ctx.queued];
  testlib.assertEquals(connected[1], 'trapos_cloud_connected');
  testlib.assertEquals(connected[2], '7:base');
  testlib.assertEquals(connected[3], 'tok-9');

  -- A later natural reconnect still uses the retained candidate, but emits no token.
  ctx.el.fire('websocket_closed', ctx.url);
  ctx.el.fireTimer(#ctx.el.timers);
  local ws2 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws2);
  testlib.assertEquals(textutils.unserializeJSON(ws2.sent[1]).payload.secret, 'candidate');
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
  local connected2 = ctx.queued[#ctx.queued];
  testlib.assertEquals(connected2[1], 'trapos_cloud_connected');
  testlib.assertEquals(connected2[3], nil);
end);

testlib.test('a rejected hello emits trapos_cloud_unauthorized with the verify token', function()
  local ctx = startSession();
  ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok-bad');
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);

  ctx.el.fire('websocket_message', ctx.url, encode({
    event = 'server_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad' },
  }));

  local evt = ctx.queued[#ctx.queued];
  testlib.assertEquals(evt[1], 'trapos_cloud_unauthorized');
  testlib.assertEquals(evt[2], '7:base');
  testlib.assertEquals(evt[3], 'unauthorized');
  testlib.assertEquals(evt[4], 'tok-bad');
  testlib.assertEquals(ctx.session.lastError(), 'unauthorized');
end);

testlib.test('a plain reconnect clears the candidate override and falls back to the saved secret', function()
  local ctx = startSession({ getSecret = function() return 'saved'; end });
  ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok');
  local ws1 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws1);
  testlib.assertEquals(textutils.unserializeJSON(ws1.sent[1]).payload.secret, 'candidate');

  ctx.el.fire('trapos_cloud_reconnect', 'login-restore');
  local ws2 = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws2);
  testlib.assertEquals(textutils.unserializeJSON(ws2.sent[1]).payload.secret, 'saved');
end);

testlib.test('the candidate override survives a websocket failure retry', function()
  local ctx = startSession({ getSecret = function() return 'saved'; end });
  ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok');
  ctx.el.fire('websocket_failure', ctx.url); -- first attempt fails before hello
  ctx.el.fireTimer(#ctx.el.timers); -- retry connect()
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  testlib.assertEquals(textutils.unserializeJSON(ws.sent[1]).payload.secret, 'candidate');
end);

testlib.test('startSession logs the reconnect loop once on entry and once on recovery', function()
  local logs = {};
  local function countMsg(msg)
    local n = 0;
    for _, entry in ipairs(logs) do
      if entry.msg == msg then n = n + 1; end
    end
    return n;
  end

  local ctx = startSession({
    log = function(level, msg, fields) logs[#logs + 1] = { level = level, msg = msg, fields = fields }; end,
  });

  -- Healthy connect: no reconnect-loop logs yet.
  ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs());
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
  testlib.assertEquals(ctx.session.isReady(), true);
  testlib.assertEquals(countMsg('reconnect loop started'), 0);

  -- Drop, then fail the retry several times: the loop is announced exactly once and the
  -- per-attempt chatter (connecting / connection failed) does not repeat while looping.
  ctx.el.fire('websocket_closed', ctx.url);
  local connectingBefore = countMsg('connecting');
  for _ = 1, 3 do
    ctx.el.fireTimer(#ctx.el.timers); -- reconnect timer -> connect()
    ctx.el.fire('websocket_failure', ctx.url); -- retry fails, schedules the next timer
  end
  testlib.assertEquals(countMsg('reconnect loop started'), 1);
  testlib.assertEquals(countMsg('connection closed'), 1); -- the first drop logged, not the retries
  testlib.assertEquals(countMsg('connecting'), connectingBefore); -- suppressed while looping
  testlib.assertEquals(countMsg('connection failed'), 0); -- suppressed while looping

  -- Recovery: a single "reconnected" line, no second "loop started".
  ctx.el.fireTimer(#ctx.el.timers); -- reconnect timer -> connect()
  ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs());
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));

  testlib.assertEquals(ctx.session.isReady(), true);
  testlib.assertEquals(countMsg('reconnect loop ended; reconnected'), 1);
  testlib.assertEquals(countMsg('reconnect loop started'), 1);
end);

testlib.test('startSession treats a displacement close (4000) as terminal', function()
  local ctx = startSession();
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
  testlib.assertEquals(ctx.session.isReady(), true);
  local timersBefore = #ctx.el.timers;

  ctx.el.fire('websocket_closed', ctx.url, 'displaced', 4000);

  testlib.assertEquals(ctx.session.isReady(), false);
  testlib.assertEquals(ctx.session.lastError(), 'displaced');
  -- No reconnect timer scheduled, and no further connect attempt even if one fired.
  testlib.assertEquals(#ctx.el.timers, timersBefore);
  testlib.assertEquals(#ctx.http.connects, 1);
end);

testlib.test('startSession treats a displacement close by reason as terminal', function()
  local ctx = startSession();
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
  testlib.assertEquals(ctx.session.isReady(), true);
  local timersBefore = #ctx.el.timers;

  ctx.el.fire('websocket_closed', ctx.url, 'displaced'); -- reason only, code absent

  testlib.assertEquals(ctx.session.lastError(), 'displaced');
  testlib.assertEquals(#ctx.el.timers, timersBefore);
  testlib.assertEquals(#ctx.http.connects, 1);
end);

testlib.test('startSession ignores a displacement of an already-superseded socket', function()
  -- A 4000 close that arrives before our reconnect completes hello is for a socket
  -- our own reconnect superseded; it must not brick the daemon into 'displaced'.
  local ctx = startSession();
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  -- still awaiting_hello (no ack yet): a stale displacement arrives.
  ctx.el.fire('websocket_closed', ctx.url, 'displaced', 4000);

  testlib.assertEquals(ctx.session.lastError(), nil);
  -- Not terminal: a hello ack on the live socket still brings us to ready.
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
  testlib.assertEquals(ctx.session.isReady(), true);
end);

testlib.test('hello watchdog closes a socket stuck awaiting ack', function()
  local ctx = startSession();
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  ctx.el.fireTimer(#ctx.el.timers); -- watchdog fires while still awaiting_hello
  testlib.assertEquals(ws.closed, true);
end);

testlib.test('send handler replies not_connected when not ready', function()
  local ctx = startSession();
  ctx.el.fire('trapos_cloud_send', 'mid-1', 'probe_server', nil);
  local reply = ctx.queued[#ctx.queued];
  testlib.assertEquals(reply[1], 'trapos_cloud_reply');
  testlib.assertEquals(reply[2], 'mid-1');
  testlib.assertEquals(reply[3], false);
  testlib.assertEquals(reply[5].code, 'not_connected');
end);

testlib.test('send handler forwards a request when ready without an immediate reply', function()
  local ctx = startSession();
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
  local queuedBefore = #ctx.queued;

  ctx.el.fire('trapos_cloud_send', 'mid-2', 'probe_server', nil);

  testlib.assertEquals(#ctx.queued, queuedBefore); -- no immediate reply
  local frame = textutils.unserializeJSON(ws.sent[#ws.sent]);
  testlib.assertEquals(frame.event, 'computer_request');
  testlib.assertEquals(frame.type, 'probe_server');
  testlib.assertEquals(frame.messageId, 'mid-2');
end);

-- request() ---------------------------------------------------------------

testlib.test('request resolves on a matching reply', function()
  local cloud = createCloud();
  local events = fakes.fakeEvents({
    { 'trapos_cloud_reply', 'mid', true, { serverOk = true }, n = 4 },
  });
  local ok, payload = cloud.request('probe_server', nil, {
    messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
    startTimer = events.startTimer, cancelTimer = events.cancelTimer,
  });

  testlib.assertEquals(ok, true);
  testlib.assertEquals(payload.serverOk, true);
  testlib.assertEquals(events.cancelled[1], 'timer-1');
end);

testlib.test('request returns the error on a failed reply', function()
  local cloud = createCloud();
  local events = fakes.fakeEvents({
    { 'trapos_cloud_reply', 'mid', false, nil, { code = 'unauthorized', message = 'x' }, n = 5 },
  });
  local ok, err = cloud.request('probe_server', nil, {
    messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
    startTimer = events.startTimer, cancelTimer = events.cancelTimer,
  });

  testlib.assertEquals(ok, false);
  testlib.assertEquals(err.code, 'unauthorized');
end);

testlib.test('request re-queues a mismatched reply and keeps waiting', function()
  local cloud = createCloud();
  local events = fakes.fakeEvents({
    { 'trapos_cloud_reply', 'other', true, { y = 2 }, n = 4 },
    { 'trapos_cloud_reply', 'mid', true, { x = 1 }, n = 4 },
  });
  local ok, payload = cloud.request('probe_server', nil, {
    messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
    startTimer = events.startTimer, cancelTimer = events.cancelTimer,
  });

  testlib.assertEquals(ok, true);
  testlib.assertEquals(payload.x, 1);
  -- queued[1] is the trapos_cloud_send; queued[2] is the re-queued reply.
  testlib.assertEquals(events.queued[2][1], 'trapos_cloud_reply');
  testlib.assertEquals(events.queued[2][2], 'other');
end);

testlib.test('request times out when the timer fires first', function()
  local cloud = createCloud();
  local events = fakes.fakeEvents({
    { 'timer', 'timer-1', n = 2 },
  });
  local ok, err = cloud.request('probe_server', nil, {
    messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
    startTimer = events.startTimer, cancelTimer = events.cancelTimer,
  });

  testlib.assertEquals(ok, false);
  testlib.assertEquals(err.code, 'timeout');
end);

-- daemon-side serve/respond wiring ----------------------------------------

local function readySession()
  local ctx = startSession();
  local ws = fakes.fakeWs();
  ctx.el.fire('websocket_success', ctx.url, ws);
  ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
  ctx.ws = ws;
  return ctx;
end

testlib.test('startSession routes an advertised reverse type to trapos_cloud_request', function()
  local ctx = readySession();
  ctx.el.fire('trapos_cloud_serve', 'exec-lua');
  local sentBefore = #ctx.ws.sent;

  ctx.el.fire('websocket_message', ctx.url, encode({
    event = 'server_request', type = 'exec-lua', messageId = 'g-9', payload = { code = 'x' },
  }));

  -- No immediate reply on the wire; a request event is queued for the server instead.
  testlib.assertEquals(#ctx.ws.sent, sentBefore);
  local req = ctx.queued[#ctx.queued];
  testlib.assertEquals(req[1], 'trapos_cloud_request');
  testlib.assertEquals(req[2], 'g-9');
  testlib.assertEquals(req[3], 'exec-lua');
  testlib.assertEquals(req[4].code, 'x');
end);

testlib.test('startSession replies unknown_type for an unadvertised reverse type', function()
  local ctx = readySession();
  ctx.el.fire('websocket_message', ctx.url, encode({
    event = 'server_request', type = 'exec-lua', messageId = 'g-10',
  }));

  local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
  testlib.assertEquals(frame.event, 'computer_response');
  testlib.assertEquals(frame.ok, false);
  testlib.assertEquals(frame.error.code, 'unknown_type');
end);

testlib.test('respond handler sends a computer_response when ready', function()
  local ctx = readySession();
  local sentBefore = #ctx.ws.sent;

  ctx.el.fire('trapos_cloud_respond', 'g-11', 'exec-lua', true, { ok = true, output = 'hi' }, nil);

  testlib.assertEquals(#ctx.ws.sent, sentBefore + 1);
  local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
  testlib.assertEquals(frame.event, 'computer_response');
  testlib.assertEquals(frame.type, 'exec-lua');
  testlib.assertEquals(frame.messageId, 'g-11');
  testlib.assertEquals(frame.ok, true);
  testlib.assertEquals(frame.payload.output, 'hi');
end);

testlib.test('respond handler drops the reply when not connected', function()
  local ctx = startSession(); -- never reaches 'ready'
  -- Must not raise even though there is no live socket to send on.
  ctx.el.fire('trapos_cloud_respond', 'g-12', 'exec-lua', true, { ok = true }, nil);
  testlib.assertEquals(ctx.session.isReady(), false);
end);

-- serve() / respond() helpers ---------------------------------------------

testlib.test('respond queues a trapos_cloud_respond event', function()
  local cloud = createCloud();
  local queued = {};
  cloud.respond('mid-1', 'exec-lua', true, { ok = true }, nil, {
    queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
  });

  testlib.assertEquals(#queued, 1);
  testlib.assertEquals(queued[1][1], 'trapos_cloud_respond');
  testlib.assertEquals(queued[1][2], 'mid-1');
  testlib.assertEquals(queued[1][3], 'exec-lua');
  testlib.assertEquals(queued[1][4], true);
  testlib.assertEquals(queued[1][5].ok, true);
end);

testlib.test('serve advertises the type and auto-responds on success', function()
  local cloud = createCloud();
  local el = fakes.fakeEventloop();
  local queued = {};
  cloud.serve('exec-lua', function(payload)
    return true, { echoed = payload.code };
  end, { eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end });

  testlib.assertEquals(queued[1][1], 'trapos_cloud_serve');
  testlib.assertEquals(queued[1][2], 'exec-lua');

  el.fire('trapos_cloud_request', 'mid-7', 'exec-lua', { code = 'print(1)' });
  local respond = queued[#queued];
  testlib.assertEquals(respond[1], 'trapos_cloud_respond');
  testlib.assertEquals(respond[2], 'mid-7');
  testlib.assertEquals(respond[4], true);
  testlib.assertEquals(respond[5].echoed, 'print(1)');
end);

testlib.test('serve ignores requests for other types', function()
  local cloud = createCloud();
  local el = fakes.fakeEventloop();
  local queued = {};
  cloud.serve('exec-lua', function() return true, {}; end, {
    eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
  });
  local before = #queued;
  el.fire('trapos_cloud_request', 'mid', 'write-file', {});
  testlib.assertEquals(#queued, before); -- no respond queued
end);

testlib.test('serve responds with the error table on a failure result', function()
  local cloud = createCloud();
  local el = fakes.fakeEventloop();
  local queued = {};
  cloud.serve('exec-lua', function() return false, { code = 'internal', message = 'nope' }; end, {
    eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
  });
  el.fire('trapos_cloud_request', 'mid', 'exec-lua', {});
  local respond = queued[#queued];
  testlib.assertEquals(respond[4], false);
  testlib.assertEquals(respond[6].code, 'internal');
end);

testlib.test('serve reports a thrown handler as an internal error', function()
  local cloud = createCloud();
  local el = fakes.fakeEventloop();
  local queued = {};
  cloud.serve('exec-lua', function() error('boom', 0); end, {
    eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
  });
  el.fire('trapos_cloud_request', 'mid', 'exec-lua', {});
  local respond = queued[#queued];
  testlib.assertEquals(respond[4], false);
  testlib.assertEquals(respond[6].code, 'internal');
  testlib.assertTrue(string.find(respond[6].message, 'boom', 1, true) ~= nil);
end);

testlib.run();
