// Node 24+, no packages. Never retries a POST or starts/continues audio implicitly.
// Run: node conversation.ts saved-request.json new-response.json
import {readFile,open} from 'node:fs/promises';
import {pathToFileURL} from 'node:url';
export interface Intent{base_url:string;assignment_id:string;external_user_id:string;method:'GET'|'POST'|'DELETE';path:string;query?:Record<string,string|number>;idempotency_key?:string;body?:Record<string,unknown>}
const identifier='[A-Za-z0-9_-]{1,128}';
const paths=new RegExp('^/(?:chat|requests/'+identifier+'|sessions(?:/'+identifier+'(?:/(?:end|discard|history))?)?|session/status|data/(?:export|erase)|conversation/(?:catalog|insights|voice|data/export|sessions(?:/'+identifier+'(?:/(?:consent|playback|debrief|audio))?)?|audio/'+identifier+'(?:/(?:continue|segments/'+identifier+'))?))$');
const uuid=/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
export function requestOptions(intent:Intent,key:string){
  const base=new URL(intent.base_url);
  if(base.protocol!=='https:'||base.username||base.password||base.search||base.hash||!/^\/v1\/?$/.test(base.pathname)||!paths.test(intent.path)
    ||!uuid.test(intent.assignment_id)||typeof intent.external_user_id!=='string'||intent.external_user_id.length<1||intent.external_user_id.length>200
    ||/[\r\n\0]/.test(intent.external_user_id)||typeof key!=='string'||!/^[\x21-\x7e]{1,512}$/.test(key)||!['GET','POST','DELETE'].includes(intent.method))throw new Error('Invalid saved request or server credentials');
  const url=new URL(base.href.replace(/\/$/,'')+intent.path),headers:Record<string,string>={Authorization:'Bearer '+key,'X-Assignment-Id':intent.assignment_id,'X-User-Id':intent.external_user_id};
  for(const [name,value] of Object.entries(intent.query??{})){if(!['limit','cursor','session_id'].includes(name))throw new Error('Unexpected query field');url.searchParams.set(name,String(value));}
  let body:string|undefined;
  if(intent.method!=='GET'){
    if(!uuid.test(intent.idempotency_key??''))throw new Error('Persist an operation UUID before sending a mutation');
    headers['Idempotency-Key']=intent.idempotency_key!;headers['Content-Type']='application/json';body=JSON.stringify(intent.body??{});
    if(new TextEncoder().encode(body).length>49152)throw new Error('Request exceeds the example envelope');
  }else if(intent.body!==undefined)throw new Error('GET cannot have a body');
  return {url,options:{method:intent.method,headers,body,redirect:'error' as const}};
}
async function bounded(response:Response,limit:number){
  if(!response.body)throw new Error('Missing response body');const reader=response.body.getReader(),chunks:Uint8Array[]=[];let size=0;
  try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>limit){await reader.cancel();throw new Error('Response exceeds the example bound');}chunks.push(value);}}
  finally{reader.releaseLock();}
  const bytes=new Uint8Array(size);let offset=0;for(const value of chunks){bytes.set(value,offset);offset+=value.length;}return bytes;
}
/** A preview or HTTP 200 alone is never a completed purchase. Unknown events
 * may be ignored, but cannot substitute for identity, safety and final receipt. */
export function parseConversationStream(source:string,requireEnd=false){
  let requestId:string|undefined,sessionId:string|undefined,turn:Record<string,unknown>|undefined,safety:Record<string,unknown>|undefined,ended=false;
  let receipt:Record<string,unknown>|undefined,complete=false;
  for(const frame of source.replaceAll('\r\n','\n').split('\n\n')){
    const data=frame.split('\n').filter(line=>line.startsWith('data:')).map(line=>line.slice(5).trimStart()).join('\n');if(!data)continue;
    const event=JSON.parse(data);
    if(event.type==='error')throw new Error('Turn failed or remains uncertain; recover the original request '+(requestId??''));
    if(complete)throw new Error('Events followed completion');
    if(event.type==='metadata'){
      if(requestId||typeof event.request_id!=='string'||typeof event.sessionId!=='string')throw new Error('Invalid stream identity');requestId=event.request_id;sessionId=event.sessionId;
    }else if(event.type==='conversation_turn'){
      if(!requestId||event.turn?.request_id!==requestId||event.turn?.session_id!==sessionId)throw new Error('Mismatched saved turn');turn=event.turn;
    }else if(event.type==='safety_status')safety=event;
    else if(event.type==='session_ended')ended=true;
    else if(event.type==='done'){
      if(!requestId||!safety||event.sessionId!==sessionId||event.receipt?.request_id!==requestId||event.receipt?.session_id!==sessionId)throw new Error('Unverified final receipt');
      receipt=event.receipt;complete=true;
    }
  }
  if(!complete||requireEnd&&!ended)throw new Error('Incomplete stream; recover original request '+(requestId??''));
  return {request_id:requestId,session_id:sessionId,turn:turn??null,safety,receipt,session_ended:ended};
}
export async function execute(intent:Intent,key:string,fetcher:typeof fetch=fetch){
  const {url,options}=requestOptions(intent,key),controller=new AbortController(),timer=setTimeout(()=>controller.abort(),120000);
  try{
    const response=await fetcher(url,{...options,signal:controller.signal}),type=response.headers.get('content-type')?.split(';')[0].trim();
    const bytes=await bounded(response,type==='text/event-stream'?2*1024*1024:8*1024*1024);
    if(!response.ok){
      let code='HTTP_'+response.status,requestId='';try{const error=JSON.parse(new TextDecoder().decode(bytes));code=String(error.code??code);requestId=String(error.request_id??'');}catch{ /* Keep the HTTP status when the error body is not JSON. */ }
      throw new Error(code+' '+requestId+(code==='REQUEST_FAILED'?'; not charged: send it again with a new idempotency key':'; retain the original saved request'));
    }
    if(type==='audio/mpeg')return {bytes,status:response.status};
    const source=new TextDecoder('utf-8',{fatal:true}).decode(bytes);
    const data=type==='text/event-stream'?parseConversationStream(source,intent.path.endsWith('/end')):type==='application/json'?JSON.parse(source):null;
    if(data===null)throw new Error('Unexpected response type');
    if(data.operation_id!==undefined&&intent.body?.operation_id!==undefined&&data.operation_id!==intent.body.operation_id)throw new Error('Mismatched operation receipt');
    return {data,status:response.status,retry_after:response.headers.get('retry-after')};
  }finally{clearTimeout(timer);}
}
async function main(){
  const [input,output]=process.argv.slice(2);if(!input||!output)throw new Error('Usage: node conversation.ts saved-request.json new-response.json (or .mp3)');
  const source=await readFile(input);if(source.length>65536)throw new Error('Saved request too large');
  const intent=JSON.parse(source.toString('utf8')) as Intent;
  // Reserve a new output before dispatch. An empty file after a failure is not a
  // successful receipt; recover using the unchanged saved request and a new file.
  const destination=await open(output,'wx',0o600);
  try{const result=await execute(intent,process.env.FOREVERLEARNING_API_KEY??'');
    await destination.writeFile('bytes' in result?result.bytes!:JSON.stringify(result,null,2)+'\n');await destination.sync();
  }finally{await destination.close();}
  console.log('Saved response. Inspect its status and receipt before another operation.');
}
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main().catch(error=>{console.error(error instanceof Error?error.message:'Request unavailable');process.exitCode=1;});
