import { expect,test } from 'bun:test'; import { createInboxPoller, parseLivePanes, resolveDeploymentSha } from './botmaster-proxy.js';

const DEPLOYED_SHA = '1234567890abcdef1234567890abcdef12345678';

test('deployment identity accepts only a full lowercase revision',()=>{
  expect(resolveDeploymentSha(DEPLOYED_SHA)).toBe(DEPLOYED_SHA);
  expect(()=>resolveDeploymentSha(undefined)).toThrow(/full lowercase 40-character git SHA/);
  expect(()=>resolveDeploymentSha('abc123')).toThrow(/full lowercase 40-character git SHA/);
  expect(()=>resolveDeploymentSha(DEPLOYED_SHA.toUpperCase())).toThrow(/full lowercase 40-character git SHA/);
});

test('parses the underscore-separated tmux pane records emitted in production',()=>{
  expect(parseLivePanes('%355_bwrap_2681946\n')).toEqual([{pane:'%355',command:'bwrap',panePid:2681946}]);
  expect(parseLivePanes('%355_bwrap\n%bad_bwrap_2681946\n%355_bwrap_zero')).toEqual([]);
});

// A missing owner id once logged a line and then dropped every owner reply on the floor.
// Silence is indistinguishable from "nothing was sent", so construction must refuse.
test('unset owner refuses to build a poller at all',()=>{
  expect(()=>createInboxPoller({ownerUserId:null,query:async()=>[],store:{cursor:()=>0} as any,log:()=>{}}))
    .toThrow(/OVERDECK_BOTMASTER_OWNER_USER_ID/);
});

function fakeStore(overrides:any={}){const state:any={cursor:0,inserted:[] as any[],byTg:new Map<string,any>(),mainClaim:null,...overrides};return {cursor:()=>state.cursor,setCursor:(_c:string,d:number)=>{state.cursor=d},getByTgMessageId:(chatId:number,tg:number)=>state.byTg.get(`${chatId}:${tg}`)??null,mintId:()=>`m${state.inserted.length}`,insertMessage:(m:any)=>{state.inserted.push(m)},getMessage:()=>null,getMainClaim:()=>state.mainClaim,_state:state} as any}

test('plain owner text with no reply and no #id routes to the fresh main claim',async()=>{
  const store=fakeStore({mainClaim:{sessionId:'orchestrator-1',claimedAt:Date.now()}});
  const poll=createInboxPoller({ownerUserId:42,store,query:async()=>[{channel:'overdeck',chat_id:-1,message_id:5,date:1000,text:'talk to me',user_id:42}],log:()=>{}});
  await poll('overdeck');
  expect(store._state.inserted).toHaveLength(1);
  expect(store._state.inserted[0].sessionId).toBe('orchestrator-1');
  expect(store._state.inserted[0].parentId).toBeNull();
});

test('plain owner text with a stale main claim notifies the owner instead of routing blind',async()=>{
  const now=8*60*60*1000;
  const store=fakeStore({mainClaim:{sessionId:'dead-session',claimedAt:now-7*60*60*1000}});
  let notified='';
  const poll=createInboxPoller({ownerUserId:42,store,query:async()=>[{channel:'overdeck',chat_id:-1,message_id:6,date:1000,text:'talk to me',user_id:42}],notifyOwner:async(t)=>{notified=t},now:()=>now,log:()=>{}});
  await poll('overdeck');
  expect(store._state.inserted).toHaveLength(0);
  expect(notified).toBe('No active main session — last claim by dead-ses went quiet 7h ago.');
});

test('plain owner text with no main claim at all notifies the owner',async()=>{
  const store=fakeStore({mainClaim:null});
  let notified='';
  const poll=createInboxPoller({ownerUserId:42,store,query:async()=>[{channel:'overdeck',chat_id:-1,message_id:7,date:1000,text:'talk to me',user_id:42}],notifyOwner:async(t)=>{notified=t},log:()=>{}});
  await poll('overdeck');
  expect(store._state.inserted).toHaveLength(0);
  expect(notified).toContain('No active main session');
});

// The owner cannot distinguish "not received" from "received, still working".
test('every registered owner message is acknowledged by id',async()=>{
  const store=fakeStore({mainClaim:{sessionId:'orchestrator-1',claimedAt:Date.now()}});
  const acked:string[]=[];
  const poll=createInboxPoller({ownerUserId:42,store,query:async()=>[{channel:'overdeck',chat_id:-1,message_id:9,date:1000,text:'steer me',user_id:42}],ack:async(_c,_chat,id)=>{acked.push(id)},log:()=>{}});
  await poll('overdeck');
  expect(acked).toEqual([store._state.inserted[0].id]);
});

// A Telegram failure must never cost the message itself.
test('an ack failure still registers the message',async()=>{
  const store=fakeStore({mainClaim:{sessionId:'orchestrator-1',claimedAt:Date.now()}});
  const poll=createInboxPoller({ownerUserId:42,store,query:async()=>[{channel:'overdeck',chat_id:-1,message_id:10,date:1000,text:'steer me',user_id:42}],ack:async()=>{throw new Error('telegram down')},log:()=>{}});
  await poll('overdeck');
  expect(store._state.inserted).toHaveLength(1);
});

test('an inbound attachment is fetched before registration and its saved path reaches the session',async()=>{
  const store=fakeStore({mainClaim:{sessionId:'orchestrator-1',claimedAt:Date.now()}}),events:string[]=[];
  const poll=createInboxPoller({ownerUserId:42,store,query:async()=>[{channel:'overdeck',chat_id:-1,message_id:11,date:1000,text:'read this',user_id:42,file_id:'file-1',file_name:'brief.txt'}],tokenForChannel:async()=>{events.push('token');return 'secret'},fetchInboundFile:async(_token,_file,_name,id)=>{events.push('fetch');return `/safe/${id}/brief.txt`},log:()=>{}});
  await poll('overdeck');
  expect(events).toEqual(['token','fetch']);
  expect(store._state.inserted[0].text).toBe('read this\n[attachment saved: /safe/11/brief.txt]');
});

test('a failed inbound attachment fetch still registers the message and logs only its id',async()=>{
  const store=fakeStore({mainClaim:{sessionId:'orchestrator-1',claimedAt:Date.now()}}),logs:string[]=[];
  const poll=createInboxPoller({ownerUserId:42,store,query:async()=>[{channel:'overdeck',chat_id:-1,message_id:12,date:1000,text:'read this',user_id:42,file_id:'file-1',file_name:'brief.txt'}],tokenForChannel:async()=>{throw new Error('secret-token')},log:(s)=>logs.push(s)});
  await poll('overdeck');
  expect(store._state.inserted[0].text).toBe('read this\n[attachment could not be fetched: brief.txt]');
  expect(logs).toEqual(['[botmaster-proxy] attachment fetch failed for message 12']);
});
