import assert from "node:assert/strict";
import test from "node:test";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import {
  finalResponse,
  sessionSnapshot,
  TextActivityBatcher,
  plainText,
  redactSensitive,
  summarizeTool,
} from "./device-lab-pager.ts";

test("text activity batches by character threshold without splitting Unicode", () => {
  const sent: string[] = [];
  const batch = new TextActivityBatcher((text) => sent.push(text), 10_000, 5);
  batch.add("你🙂abc");
  assert.deepEqual(sent, ["你🙂abc"]);
});

test("text activity flushes on the throttle timer", async () => {
  const sent: string[] = [];
  const batch = new TextActivityBatcher((text) => sent.push(text), 15, 100);
  batch.add("live output");
  assert.deepEqual(sent, []);
  await new Promise((resolve) => setTimeout(resolve, 30));
  assert.deepEqual(sent, ["live output"]);
});

test("plain text removes noisy markdown and redacts credentials", () => {
  const value = plainText("## Result\n**ready** with token=super-secret-value");
  assert.equal(value, "Result\nready with token=<redacted>");
  assert.equal(redactSensitive("Authorization: Bearer abc.def.ghi"), "Authorization=<redacted>");
  assert.equal(redactSensitive("Authorization: Basic dXNlcjpwYXNz"), "Authorization=<redacted>");
  assert.equal(redactSensitive("run --password private-value"), "run --password=<redacted>");
});

test("tool summaries keep only a short safe action", () => {
  assert.equal(
    summarizeTool("read", { path: "server/device_gateway/app.py", contents: "ignored" }),
    "Reading\nserver/device_gateway/app.py",
  );
  const command = summarizeTool("bash", { cmd: "curl -H Authorization:Bearer-secret https://example" });
  assert.match(command, /^Running\n/);
  assert.doesNotMatch(command, /Bearer-secret/);
});

test("final reply excludes commentary, tools and prior assistant messages", () => {
  assert.equal(finalResponse([
    {role:"assistant",content:[{type:"text",text:"Intermediate"}]},
    {role:"toolResult",content:[{type:"text",text:"tool output"}]},
    {role:"assistant",content:[{type:"thinking",thinking:"hidden"},{type:"text",text:"Final reply."}]},
  ]),"Final reply.");
  assert.equal(finalResponse([]),"");
});

test("registered Pi handlers emit live activity and settled final independently", async t => {
  const dir = mkdtempSync(join(tmpdir(), "pager-downlink-"));
  const saved = process.env.DEVICE_LAB_CREDENTIALS_FILE;
  process.env.DEVICE_LAB_CREDENTIALS_FILE = join(dir, "credentials.json");
  writeFileSync(process.env.DEVICE_LAB_CREDENTIALS_FILE, JSON.stringify({ pi_token: "test-only" }));
  const sent: any[] = [];
  t.mock.method(globalThis, "fetch", async (url: string, options: any) => {
    assert.ok(url.endsWith("/integrations/pi/events"));
    assert.equal(options.headers["x-device-lab-pi-token"], "test-only");
    sent.push(JSON.parse(options.body));
    return new Response("{}", { status: 200 });
  });
  try {
    // Fresh module gives this contract test its own token cache.
    const { default: register } = await import("./device-lab-pager.ts?downlink-test");
    const handlers = new Map<string, any>();
    register({ on: (name: string, handler: any) => handlers.set(name, handler) } as any);
    const ctx: any = { cwd: "/tmp/public-test", sessionManager: {
      getSessionId: () => "selected-session", getSessionName: () => "Public test",
    } };
    const emit = (name: string, event: any = {}) => handlers.get(name)(event, ctx);
    // A: no message_update at all. agent_end captures; agent_settled publishes once.
    await emit("agent_start");
    emit("agent_end", { messages: [{ role: "assistant", content: [{ type: "text", text: "你好！" }] }] });
    assert.deepEqual(sent.map(p => p.event), ["agent_start"]);
    await emit("agent_settled");
    await emit("agent_settled");
    assert.equal(sent.length, 2);
    assert.equal(sent[1].final_text, "你好！");
    assert.equal(sent[1].turn_id, sent[0].turn_id);
    // B: plain text, tool start/end, threshold flush, then a different final reply.
    sent.length = 0;
    await emit("agent_start");
    emit("message_update", { assistantMessageEvent: { type: "text_delta", delta: "正在检查。" } });
    assert.equal(sent.length, 1);
    await new Promise(resolve => setTimeout(resolve, 400));
    assert.equal(sent[1].activity, "正在检查。");
    emit("tool_execution_start", { toolName: "bash", args: { command: "sleep 3" } });
    emit("tool_execution_end", { toolName: "bash", isError: false });
    emit("message_update", { assistantMessageEvent: { type: "text_delta", delta: "x".repeat(120) } });
    assert.deepEqual(sent.slice(2).map(p => p.activity), ["Running\nsleep 3", "Completed\nbash", "x".repeat(120)]);
    emit("agent_end", { messages: [{ role: "assistant", content: [{ type: "text", text: "权威最终结果。" }] }] });
    await emit("agent_settled");
    assert.equal(sent.at(-1).final_text, "权威最终结果。");
    assert.ok(sent.every(p => p.session_id === "selected-session"));
    // C: failure is separate from useful final text and preserves secret redaction.
    await emit("agent_start");
    emit("agent_end", { messages: [{ role: "assistant", stopReason: "error", content: [
      { type: "text", text: "依赖不可用。token=private-value" },
    ] }] });
    await emit("agent_settled");
    assert.equal(sent.at(-1).failed, true);
    assert.equal(sent.at(-1).final_text, "依赖不可用。token=<redacted>");
    await emit("session_shutdown");
  } finally {
    if (saved === undefined) delete process.env.DEVICE_LAB_CREDENTIALS_FILE;
    else process.env.DEVICE_LAB_CREDENTIALS_FILE = saved;
    rmSync(dir, { recursive: true, force: true });
  }
});

 test('existing-session snapshot restores native final/recent text after overlay restart',()=>{
  const messages=[{role:'user',content:[{type:'text',text:'First prompt'}]},
    {role:'assistant',stopReason:'toolUse',content:[{type:'text',text:'Intermediate'}, {type:'toolCall',name:'read'}]},
    {role:'toolResult',content:[{type:'text',text:'tool contents'}]},
    {role:'assistant',stopReason:'stop',content:[{type:'thinking',thinking:'Never show'}, {type:'text',text:'Final 中文 token=private-value'}]}];
  const snapshot=sessionSnapshot(messages);
  assert.match(snapshot.recent_text,/Final 中文/);assert.match(snapshot.final_text,/Final 中文/);
  assert.doesNotMatch(JSON.stringify(snapshot),/Intermediate|tool contents|Never show|private-value/);
  assert.equal(snapshot.activity_type,'text');assert.equal(sessionSnapshot(messages,true).final_text,'');
  assert.equal(sessionSnapshot([]).recent_text,'');
});


test("queued user requests each complete before the native agent loop settles", async t => {
  const dir=mkdtempSync(join(tmpdir(),"pager-queued-"));
  const saved=process.env.DEVICE_LAB_CREDENTIALS_FILE;
  process.env.DEVICE_LAB_CREDENTIALS_FILE=join(dir,"credentials.json");
  writeFileSync(process.env.DEVICE_LAB_CREDENTIALS_FILE,JSON.stringify({pi_token:"test-only"}));
  const sent:any[]=[];
  t.mock.method(globalThis,"fetch",async (_url:any,options:any)=>{sent.push(JSON.parse(options.body));return new Response("{}");});
  try {
    const {default:register}=await import("./device-lab-pager.ts?queued-test");
    const handlers=new Map<string,any>();
    register({on:(name:string,fn:any)=>handlers.set(name,fn)} as any);
    const ctx:any={cwd:"/tmp/public-test",sessionManager:{getSessionId:()=>"queued-session",getSessionName:()=>"Queued"}};
    const emit=(name:string,event:any={})=>handlers.get(name)(event,ctx);
    const reply=(text:string,stopReason="stop")=>({role:"assistant",stopReason,content:[{type:"text",text}]});
    await emit("agent_start");await emit("message_start",{message:{role:"user"}});
    await emit("message_end",{message:{role:"assistant",stopReason:"toolUse",content:[{type:"toolCall",name:"bash"}]}});
    await emit("message_end",{message:reply("retrying","error")});
    assert.equal(sent.filter(x=>x.event==="agent_end").length,0);
    await emit("message_end",{message:reply("First completed.")});
    const first=sent.at(-1);assert.equal(first.event,"agent_end");assert.equal(first.final_text,"First completed.");
    await emit("message_start",{message:{role:"user"}}); // queued follow-up, no second agent_start event
    assert.equal(sent.at(-1).event,"agent_start");assert.notEqual(sent.at(-1).turn_id,first.turn_id);
    await emit("message_end",{message:reply("Second completed.")});
    await emit("agent_end",{messages:[reply("Second completed.")]});await emit("agent_settled");
    assert.deepEqual(sent.map(x=>x.event),["agent_start","agent_end","agent_start","agent_end"]);
    assert.equal(sent.at(-1).final_text,"Second completed.");
    // Failed terminal runs keep the old settled fallback, with no premature reply.
    await emit("agent_start");await emit("message_start",{message:{role:"user"}});
    await emit("message_end",{message:reply("Aborted.","aborted")});
    await emit("agent_end",{messages:[reply("Aborted.","aborted")]});
    assert.equal(sent.at(-1).event,"agent_start");await emit("agent_settled");
    assert.equal(sent.at(-1).failed,true);await emit("agent_settled");
    assert.equal(sent.filter(x=>x.event==="agent_end").length,3);
  } finally {
    if(saved===undefined)delete process.env.DEVICE_LAB_CREDENTIALS_FILE;else process.env.DEVICE_LAB_CREDENTIALS_FILE=saved;
    rmSync(dir,{recursive:true,force:true});
  }
});

test("low-level agent restarts inside one user turn keep the running timer anchored", async t => {
  const dir=mkdtempSync(join(tmpdir(),"pager-restart-"));
  const saved=process.env.DEVICE_LAB_CREDENTIALS_FILE;
  process.env.DEVICE_LAB_CREDENTIALS_FILE=join(dir,"credentials.json");
  writeFileSync(process.env.DEVICE_LAB_CREDENTIALS_FILE,JSON.stringify({pi_token:"test-only"}));
  const sent:any[]=[];
  t.mock.method(globalThis,"fetch",async (_url:any,options:any)=>{sent.push(JSON.parse(options.body));return new Response("{}");});
  try {
    const {default:register}=await import("./device-lab-pager.ts?restart-test");
    const handlers=new Map<string,any>();
    register({on:(name:string,fn:any)=>handlers.set(name,fn)} as any);
    const ctx:any={cwd:"/tmp/public-test",sessionManager:{getSessionId:()=>"restart-session",getSessionName:()=>"Restart"}};
    const emit=(name:string,event:any={})=>handlers.get(name)(event,ctx);
    const reply=(text:string,stopReason="stop")=>({role:"assistant",stopReason,content:[{type:"text",text}]});
    await emit("agent_start");await emit("message_start",{message:{role:"user"}});
    const anchor=sent.at(-1).turn_id;
    // Pi reopens a low-level run for automatic retry, then for compaction recovery.
    await emit("agent_start");await emit("agent_start");
    assert.equal(sent.filter(x=>x.event==="agent_start").length,1);
    await emit("agent_end",{messages:[reply("Eventually done.")]});await emit("agent_settled");
    assert.equal(sent.at(-1).event,"agent_end");assert.equal(sent.at(-1).turn_id,anchor);
    assert.equal(sent.at(-1).final_text,"Eventually done.");
    // The next user request still opens a fresh turn after the terminal event.
    await emit("agent_start");await emit("message_start",{message:{role:"user"}});
    assert.notEqual(sent.at(-1).turn_id,anchor);
  } finally {
    if(saved===undefined)delete process.env.DEVICE_LAB_CREDENTIALS_FILE;else process.env.DEVICE_LAB_CREDENTIALS_FILE=saved;
    rmSync(dir,{recursive:true,force:true});
  }
});
