import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { randomUUID } from "node:crypto";
import { readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

type LifecycleEventName =
  | "session_start"
  | "session_info_changed"
  | "agent_start"
  | "agent_end"
  | "session_shutdown";
type ActivityType = "text" | "tool";

const endpoint = process.env.DEVICE_LAB_GATEWAY_URL ?? "http://127.0.0.1:18081";
const credentialPath = process.env.DEVICE_LAB_CREDENTIALS_FILE
  ?? join(homedir(), ".config", "esp32-device-gateway", "device.json");
const POST_TIMEOUT_MS = 750;
const TOKEN_CACHE_MS = 60_000;
export const TEXT_BATCH_DELAY_MS = 350;
export const TEXT_BATCH_MAX_CHARS = 120;
const MAX_ACTIVITY_CHARS = 1000;
const MAX_TOOL_ACTIVITY_CHARS = 260;

let cachedToken = "";
let tokenReadAt = 0;

function metadata(ctx: ExtensionContext) {
  const sessionId = ctx.sessionManager.getSessionId();
  const name = ctx.sessionManager.getSessionName() || basename(ctx.cwd) || sessionId.slice(0, 8);
  return { session_id: sessionId, name, cwd: ctx.cwd };
}

function integrationToken(): string {
  const now = Date.now();
  if (now - tokenReadAt < TOKEN_CACHE_MS) return cachedToken;
  tokenReadAt = now;
  try {
    const value = JSON.parse(readFileSync(credentialPath, "utf8"));
    cachedToken = typeof value.pi_token === "string" ? value.pi_token : "";
  } catch {
    cachedToken = "";
  }
  return cachedToken;
}

function truncateCodePoints(value: string, maximum: number): string {
  const points = Array.from(value);
  return points.length <= maximum ? value : points.slice(points.length - maximum).join("");
}

export function redactSensitive(value: string): string {
  return value
    .replace(
      /\bauthorization\b\s*[:=]\s*["']?(?:Basic|Bearer)\s+[^\s,"';]+/gi,
      "Authorization=<redacted>",
    )
    .replace(
      /\bauthorization\b\s*[:=]\s*(?:Bearer\s+)?["']?[A-Za-z0-9._~+/=-]+/gi,
      "Authorization=<redacted>",
    )
    .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
    .replace(
      /--?(password|passwd|token|secret|api[_-]?key)(?:=|\s+)["']?[^\s,"';]+/gi,
      "--$1=<redacted>",
    )
    .replace(
      /\b(password|passwd|token|secret|api[_-]?key)\b\s*[:=]\s*["']?[^\s,"';]+/gi,
      "$1=<redacted>",
    )
    .replace(/\b[A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY)=[^\s]+/g, (match) => {
      const name = match.slice(0, match.indexOf("="));
      return `${name}=<redacted>`;
    });
}

export function plainText(value: string): string {
  const cleaned = redactSensitive(value)
    .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
    .replace(/```[^\n]*\n?/g, "")
    .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
    .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
    .replace(/^\s{0,3}(?:#{1,6}|>|[-*+] |\d+[.)] )\s*/gm, "")
    .replace(/\*\*([^*]+)\*\*/g, "$1")
    .replace(/__([^_]+)__/g, "$1")
    .replace(/~~([^~]+)~~/g, "$1")
    .replace(/`([^`]+)`/g, "$1")
    .replace(/\r\n?/g, "\n")
    .replace(/\n{3,}/g, "\n\n")
    .replace(/[ \t]{2,}/g, " ");
  return truncateCodePoints(cleaned, MAX_ACTIVITY_CHARS);
}

function firstString(args: unknown, keys: string[]): string {
  if (!args || typeof args !== "object") return "";
  const values = args as Record<string, unknown>;
  for (const key of keys) {
    if (typeof values[key] === "string" && values[key]) return values[key] as string;
  }
  return "";
}

export function summarizeTool(toolName: string, args: unknown): string {
  const normalized = toolName.toLowerCase();
  let action = "Using";
  let detail = toolName;
  if (/read|open|view/.test(normalized)) {
    action = "Reading";
    detail = firstString(args, ["path", "file_path", "file", "filename"]) || toolName;
  } else if (/write|edit|patch/.test(normalized)) {
    action = "Editing";
    detail = firstString(args, ["path", "file_path", "file", "filename"]) || toolName;
  } else if (/bash|shell|exec|command/.test(normalized)) {
    action = "Running";
    detail = firstString(args, ["command", "cmd"]).split("\n", 1)[0] || toolName;
  } else if (/search|grep|find/.test(normalized)) {
    action = "Searching";
    detail = firstString(args, ["query", "pattern", "path"]) || toolName;
  }
  const summary = `${action}\n${plainText(detail)}`;
  return truncateCodePoints(summary, MAX_TOOL_ACTIVITY_CHARS);
}

export class TextActivityBatcher {
  private pending = "";
  private timer: ReturnType<typeof setTimeout> | undefined;
  private readonly send: (text: string) => void;
  private readonly delayMs: number;
  private readonly maximumChars: number;

  constructor(
    send: (text: string) => void,
    delayMs = TEXT_BATCH_DELAY_MS,
    maximumChars = TEXT_BATCH_MAX_CHARS,
  ) {
    this.send = send;
    this.delayMs = delayMs;
    this.maximumChars = maximumChars;
  }

  add(delta: string): void {
    if (!delta) return;
    this.pending += delta;
    if (Array.from(this.pending).length >= this.maximumChars) {
      this.flush();
      return;
    }
    if (this.timer === undefined) {
      this.timer = setTimeout(() => this.flush(), this.delayMs);
      if (typeof this.timer.unref === "function") this.timer.unref();
    }
  }

  flush(): void {
    if (this.timer !== undefined) clearTimeout(this.timer);
    this.timer = undefined;
    const value = plainText(this.pending);
    this.pending = "";
    if (value) this.send(value);
  }

  reset(): void {
    if (this.timer !== undefined) clearTimeout(this.timer);
    this.timer = undefined;
    this.pending = "";
  }
}

async function postPayload(payload: Record<string, unknown>): Promise<void> {
  const token = integrationToken();
  if (!token) return;
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), POST_TIMEOUT_MS);
  if (typeof timeout.unref === "function") timeout.unref();
  try {
    await fetch(`${endpoint}/integrations/pi/events`, {
      method: "POST",
      headers: { "content-type": "application/json", "x-device-lab-pi-token": token },
      body: JSON.stringify(payload),
      signal: controller.signal,
    });
  } catch {
    // The pager is optional and must never interrupt or slow a Pi run.
  } finally {
    clearTimeout(timeout);
  }
}

function basePayload(ctx: ExtensionContext): Record<string, unknown> | undefined {
  try {
    return {
      type: "pi_session_state",
      ...metadata(ctx),
      timestamp: new Date().toISOString(),
    };
  } catch {
    return undefined;
  }
}

async function postLifecycle(
  event: LifecycleEventName,
  ctx: ExtensionContext,
  failed = false,
  final?: { turn_id: string; final_text: string },
): Promise<void> {
  const base = basePayload(ctx);
  if (base === undefined) return;
  await postPayload({ ...base, event, failed, ...final });
}

function postActivity(
  activityType: ActivityType,
  activity: string,
  ctx: ExtensionContext,
  toolName?: string,
): void {
  const base = basePayload(ctx);
  if (base === undefined || !activity) return;
  void postPayload({
    ...base,
    event: "activity",
    activity_type: activityType,
    activity,
    tool_name: toolName ? truncateCodePoints(toolName, 80) : undefined,
  });
}

function runFailed(messages: unknown[]): boolean {
  return messages.some((message) => {
    if (!message || typeof message !== "object") return false;
    const value = message as { role?: unknown; stopReason?: unknown };
    return value.role === "assistant" && (value.stopReason === "error" || value.stopReason === "aborted");
  });
}

// agent_end.messages contains this run's messages, not the session history.
export function finalResponse(messages: unknown[]): string {
  const last = messages.filter((m: any) => m?.role === "assistant").at(-1) as any;
  const value = (last?.content || []).filter((c: any) => c.type === "text")
    .map((c: any) => c.text).join("");
  return Array.from(redactSensitive(value)).slice(0, 16000).join("");
}

export default function deviceLabPager(pi: ExtensionAPI) {
  const runtimeId = randomUUID();
  let turnId = "", finalText = "", failedTurn = false, terminalSent = true;
  let approvalActive = true;
  let approvalChannel: any;
  let approvalModule: any;
  const loadApproval = async () => {
    const source = realpathSync(fileURLToPath(import.meta.url));
    approvalModule ??= await import(pathToFileURL(join(dirname(source), "approval.ts")).href);
    approvalChannel ??= new approvalModule.ApprovalChannel();
    return approvalModule;
  };
  pi.on("tool_call", async (event, ctx) => {
    try {
      const { should_request_approval } = await loadApproval();
      if (!approvalActive) return { block: true, reason: "Device Lab control channel cancelled" };
      if (!should_request_approval(event.toolName, event.input)) return undefined;
      const shell = ["bash", "shell", "exec", "powershell"].includes(event.toolName);
      const file = ["write", "edit", "apply_patch"].includes(event.toolName);
      const detail = firstString(event.input, shell ? ["command", "cmd"] : ["path", "file_path", "file"])
        || JSON.stringify(event.input);
      const decision = await approvalChannel.requestDecision({
        ...metadata(ctx), runtime_id: runtimeId, tool_call_id: event.toolCallId,
        kind: shell ? "command" : file ? "file_change" : "tool",
        title: shell ? "Run command?" : file ? "Edit file?" : `Run ${event.toolName}?`,
        detail: redactSensitive(detail).slice(0, 4000),
      });
      if (decision === "APPROVED" && approvalActive) return undefined;
      if (decision === "REJECTED") return { block: true, reason: "Rejected from Device Lab" };
    } catch { /* Approval failures always block; activity reporting stays optional. */ }
    return { block: true, reason: "Device Lab control channel cancelled" };
  });
  const textBatches = new Map<string, TextActivityBatcher>();
  let closeControl: (() => Promise<void>) | undefined;
  let heartbeat: ReturnType<typeof setInterval> | undefined;

  const batchFor = (ctx: ExtensionContext): TextActivityBatcher | undefined => {
    let sessionId: string;
    try {
      sessionId = ctx.sessionManager.getSessionId();
    } catch {
      return undefined;
    }
    let batch = textBatches.get(sessionId);
    if (batch === undefined) {
      batch = new TextActivityBatcher((text) => postActivity("text", text, ctx));
      textBatches.set(sessionId, batch);
    }
    return batch;
  };

  pi.on("session_start", async (_event, ctx) => {
    approvalActive = true;
    approvalChannel?.close();
    approvalChannel = undefined;
    if (heartbeat) clearInterval(heartbeat);
    if (closeControl) await closeControl();
    closeControl = undefined;
    // SDK hosts own an endpoint that calls session.prompt with preflightResult.
    // Existing interactive runtimes use the SDK's extension input dispatch.
    if (process.env.DEVICE_LAB_SDK_HOST !== "1") {
      try {
        // Pi's jiti loader preserves the extension symlink path. Resolve the real
        // source before loading its sibling so the existing single-file symlink
        // installation continues to work.
        const source = realpathSync(fileURLToPath(import.meta.url));
        const { openControl } = await import(pathToFileURL(join(dirname(source), "control-socket.ts")).href);
        const { sessionSettings } = await import(pathToFileURL(join(dirname(source), "settings.ts")).href);
        const sessionId = ctx.sessionManager.getSessionId();
        closeControl = await openControl(sessionId, async request => {
          if (request.method === "ping") return { session_id: sessionId, state: ctx.isIdle() ? "IDLE" : "RUNNING" };
          if (["settings", "update_settings"].includes(request.method))
            return sessionSettings(sessionId, () => ctx.model, ctx.modelRegistry, () => ctx.isIdle(),
              model => pi.setModel(model), request.method === "update_settings" ? request.model : undefined);
          if (request.method !== "input" || typeof request.text !== "string" || !request.text.trim() || request.text.length > 32000)
            throw new Error("invalid control input");
          pi.sendUserMessage(request.text, { deliverAs: "followUp", expandPromptTemplates: false });
          return { accepted: true };
        });
      } catch {
        // Never take another runtime's control endpoint or break an existing TUI.
      }
    }
    heartbeat = setInterval(() => { void postLifecycle("session_info_changed", ctx); }, 15000);
    heartbeat.unref?.();
    batchFor(ctx)?.reset();
    await postLifecycle("session_start", ctx);
  });
  pi.on("session_info_changed", (_event, ctx) => {
    void postLifecycle("session_info_changed", ctx);
  });
  pi.on("agent_start", async (_event, ctx) => {
    turnId = randomUUID(); finalText = ""; failedTurn = false; terminalSent = false;
    batchFor(ctx)?.reset();
    await postLifecycle("agent_start", ctx, false, {turn_id: turnId, final_text: ""});
  });
  pi.on("message_update", (event, ctx) => {
    const update = event.assistantMessageEvent;
    if (update.type === "text_delta") batchFor(ctx)?.add(update.delta);
  });
  pi.on("tool_execution_start", (event, ctx) => {
    batchFor(ctx)?.flush();
    postActivity("tool", summarizeTool(event.toolName, event.args), ctx, event.toolName);
  });
  pi.on("tool_execution_end", (event, ctx) => {
    const status = event.isError ? "Failed" : "Completed";
    postActivity("tool", `${status}\n${plainText(event.toolName)}`, ctx, event.toolName);
  });
  pi.on("agent_end", (event, ctx) => {
    batchFor(ctx)?.flush();
    finalText = finalResponse(event.messages);
    failedTurn = runFailed(event.messages);
  });
  // SDK 0.85.1: settled excludes pending retries/compaction/queued continuations.
  pi.on("agent_settled", async (_event, ctx) => {
    batchFor(ctx)?.flush();
    if (terminalSent) return;
    terminalSent = true;
    await postLifecycle("agent_end", ctx, failedTurn, {turn_id: turnId, final_text: finalText});
  });
  pi.on("session_shutdown", async (_event, ctx) => {
    approvalActive = false;
    approvalChannel?.close();
    if (heartbeat) clearInterval(heartbeat);
    heartbeat = undefined;
    if (closeControl) await closeControl();
    closeControl = undefined;
    let sessionId = "";
    try {
      sessionId = ctx.sessionManager.getSessionId();
    } catch {
      // The lifecycle post below will also safely ignore an invalid context.
    }
    textBatches.get(sessionId)?.flush();
    textBatches.delete(sessionId);
    await postLifecycle("session_shutdown", ctx);
  });

}
