import { createHash, randomUUID } from "node:crypto";
import { lstat, mkdir, readFile, readdir, readlink, rm, symlink, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import type { ProjectResolvedAttachment } from "@qjclaw/shared-types";

interface RunnerInput {
  vendorPackageDir: string;
  instrumentationDir?: string;
  projectRoot: string;
  sessionId: string;
  prompt: string;
  attachments?: ProjectResolvedAttachment[];
  runId?: string;
}

interface AgentCommandModule {
  [exportName: string]: unknown;
}

interface InstrumentedAgentEventModule {
  __qjcOnAgentEvent?: (listener: (event: InstrumentedAgentEvent) => void) => (() => boolean) | (() => void);
}

interface InstrumentedAgentEvent {
  runId?: string;
  stream?: string;
  data?: {
    text?: unknown;
    delta?: unknown;
  };
}

const EVENT_PREFIX = "QJC_WORKSPACE_EVENT\t";

function emit(payload: Record<string, unknown>): void {
  process.stdout.write(`${EVENT_PREFIX}${JSON.stringify(payload)}\n`);
}

function toErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

async function readStdin(): Promise<string> {
  const chunks: Buffer[] = [];
  for await (const chunk of process.stdin) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
  }
  return Buffer.concat(chunks).toString("utf8");
}

async function resolveAgentModulePath(vendorPackageDir: string): Promise<string> {
  const distDir = path.join(vendorPackageDir, "dist");
  const entries = await readdir(distDir);
  const agentModuleFile = [...entries]
    .filter((entry) => /^agent-[A-Za-z0-9_-]+\.js$/.test(entry))
    .sort()[0];
  if (!agentModuleFile) {
    throw new Error(`Unable to locate bundled OpenClaw agent module under ${distDir}.`);
  }

  return path.join(distDir, agentModuleFile);
}

interface LocalModuleImport {
  specifier: string;
  resolvedPath: string;
}

interface LocalModuleGraphNode {
  filePath: string;
  source: string;
  imports: LocalModuleImport[];
}

type AgentCommandFunction = (options: Record<string, unknown>, runtime?: Record<string, unknown>) => Promise<unknown>;

function buildInstrumentationKey(agentModulePath: string, eventModulePath: string, instrumentedSourcePaths: string[]): string {
  return createHash("sha1")
    .update(agentModulePath)
    .update("\n")
    .update(eventModulePath)
    .update("\n")
    .update(instrumentedSourcePaths.join("\n"))
    .digest("hex")
    .slice(0, 12);
}

function resolveInstrumentationDir(input: RunnerInput): string {
  const explicitDir = input.instrumentationDir?.trim();
  if (explicitDir) {
    return explicitDir;
  }

  const stateDir = process.env.OPENCLAW_STATE_DIR?.trim();
  if (stateDir) {
    return path.join(stateDir, "workspace-runner", "instrumented-modules");
  }

  return path.join(input.projectRoot, "memory", "workspace-runner", "instrumented-modules");
}

function rewriteModuleSpecifiers(
  source: string,
  baseDir: string,
  overrides: Record<string, string> = {}
): string {
  const toModuleUrl = (specifier: string): string => {
    const override = overrides[specifier];
    if (override) {
      return override;
    }
    if (specifier.startsWith(".")) {
      return pathToFileURL(path.resolve(baseDir, specifier)).href;
    }
    return specifier;
  };

  return source
    .replace(/\b((?:import|export)\s+[^"'\r\n;]+?\s+from\s*)(["'])([^"'\r\n]+)\2/g, (
      _match,
      prefix: string,
      quote: string,
      specifier: string
    ) => {
      return `${prefix}${quote}${toModuleUrl(specifier)}${quote}`;
    })
    .replace(/\b(import\s*)(["'])([^"'\r\n]+)\2/g, (
      _match,
      prefix: string,
      quote: string,
      specifier: string
    ) => {
      return `${prefix}${quote}${toModuleUrl(specifier)}${quote}`;
    })
    .replace(/\b(import\s*\(\s*)(["'])([^"'\r\n]+)\2/g, (_match, prefix: string, quote: string, specifier: string) => {
      return `${prefix}${quote}${toModuleUrl(specifier)}${quote}`;
    });
}

function normalizePathForCompare(value: string): string {
  return path.resolve(value).toLowerCase();
}

function isPathInsideDirectory(filePath: string, directoryPath: string): boolean {
  const relativePath = path.relative(directoryPath, filePath);
  return Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}

function resolveLocalJavaScriptSpecifier(baseDir: string, specifier: string, distDir: string): string | null {
  if (!specifier.startsWith(".") || !specifier.endsWith(".js")) {
    return null;
  }

  const resolvedPath = path.resolve(baseDir, specifier);
  if (resolvedPath !== distDir && !isPathInsideDirectory(resolvedPath, distDir)) {
    return null;
  }

  return resolvedPath;
}

function extractLocalModuleImports(source: string, baseDir: string, distDir: string): LocalModuleImport[] {
  const imports: LocalModuleImport[] = [];
  const seen = new Set<string>();
  const addSpecifier = (specifier: string) => {
    const resolvedPath = resolveLocalJavaScriptSpecifier(baseDir, specifier, distDir);
    if (!resolvedPath) {
      return;
    }
    const key = `${specifier}\n${normalizePathForCompare(resolvedPath)}`;
    if (seen.has(key)) {
      return;
    }
    seen.add(key);
    imports.push({ specifier, resolvedPath });
  };

  const fromImportPattern = /\b(?:import|export)\s+[^"'\r\n;]+?\s+from\s*(["'])([^"'\r\n]+)\1/g;
  for (const match of source.matchAll(fromImportPattern)) {
    addSpecifier(match[2]);
  }

  const sideEffectImportPattern = /\bimport\s*(["'])([^"'\r\n]+)\1/g;
  for (const match of source.matchAll(sideEffectImportPattern)) {
    addSpecifier(match[2]);
  }

  const dynamicImportPattern = /\bimport\s*\(\s*(["'])([^"'\r\n]+)\1/g;
  for (const match of source.matchAll(dynamicImportPattern)) {
    addSpecifier(match[2]);
  }

  return imports;
}

async function buildLocalModuleGraph(agentModulePath: string): Promise<Map<string, LocalModuleGraphNode>> {
  const distDir = path.dirname(agentModulePath);
  const graph = new Map<string, LocalModuleGraphNode>();
  const queue = [agentModulePath];

  while (queue.length > 0) {
    const filePath = queue.shift()!;
    const key = normalizePathForCompare(filePath);
    if (graph.has(key)) {
      continue;
    }

    const source = await readFile(filePath, "utf8");
    const imports = extractLocalModuleImports(source, path.dirname(filePath), distDir);
    graph.set(key, {
      filePath,
      source,
      imports
    });

    for (const importedModule of imports) {
      const importedKey = normalizePathForCompare(importedModule.resolvedPath);
      if (!graph.has(importedKey)) {
        queue.push(importedModule.resolvedPath);
      }
    }
  }

  return graph;
}

function resolveOnAgentEventLocalBinding(source: string): string | null {
  if (/\bexport\s+(?:async\s+)?function\s+onAgentEvent\b/.test(source)
    || /\bexport\s+(?:const|let|var)\s+onAgentEvent\b/.test(source)) {
    return "onAgentEvent";
  }

  const identifierPattern = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
  const exportBlockPattern = /\bexport\s*\{([^}]+)\}/g;
  for (const match of source.matchAll(exportBlockPattern)) {
    const afterExportBlock = source.slice((match.index ?? 0) + match[0].length).trimStart();
    if (afterExportBlock.startsWith("from")) {
      continue;
    }

    for (const rawSpecifier of match[1].split(",")) {
      const specifier = rawSpecifier.trim();
      const aliasMatch = specifier.match(/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/);
      if (!aliasMatch) {
        continue;
      }

      const localName = aliasMatch[1];
      const exportedName = aliasMatch[2] ?? localName;
      if ((localName === "onAgentEvent" || exportedName === "onAgentEvent") && identifierPattern.test(localName)) {
        return localName;
      }
    }
  }

  return null;
}

function selectAgentEventModule(graph: Map<string, LocalModuleGraphNode>): LocalModuleGraphNode | null {
  const candidates = [...graph.values()].filter((node) => resolveOnAgentEventLocalBinding(node.source));
  if (candidates.length === 0) {
    return null;
  }

  return candidates.sort((left, right) => {
    const leftFile = path.basename(left.filePath);
    const rightFile = path.basename(right.filePath);
    const leftPriority = /^agent-events-[A-Za-z0-9_-]+\.js$/.test(leftFile) ? 0 : 1;
    const rightPriority = /^agent-events-[A-Za-z0-9_-]+\.js$/.test(rightFile) ? 0 : 1;
    if (leftPriority !== rightPriority) {
      return leftPriority - rightPriority;
    }
    return leftFile.localeCompare(rightFile);
  })[0];
}

function resolveModulePathFromGraph(
  graph: Map<string, LocalModuleGraphNode>,
  sourcePath: string,
  targetPath: string
): string[] | null {
  const sourceKey = normalizePathForCompare(sourcePath);
  const targetKey = normalizePathForCompare(targetPath);
  const queue = [sourceKey];
  const parents = new Map<string, string | null>([[sourceKey, null]]);

  while (queue.length > 0) {
    const currentKey = queue.shift()!;
    if (currentKey === targetKey) {
      break;
    }

    const currentNode = graph.get(currentKey);
    if (!currentNode) {
      continue;
    }

    for (const importedModule of currentNode.imports) {
      const importedKey = normalizePathForCompare(importedModule.resolvedPath);
      if (parents.has(importedKey)) {
        continue;
      }
      parents.set(importedKey, currentKey);
      queue.push(importedKey);
    }
  }

  if (!parents.has(targetKey)) {
    return null;
  }

  const pathKeys: string[] = [];
  let cursor: string | null = targetKey;
  while (cursor) {
    pathKeys.push(cursor);
    cursor = parents.get(cursor) ?? null;
  }

  return pathKeys.reverse()
    .map((key) => graph.get(key)?.filePath)
    .filter((value): value is string => typeof value === "string");
}

async function ensureInstrumentationNodeModulesLink(instrumentationDir: string, vendorPackageDir: string): Promise<void> {
  const vendorNodeModulesPath = path.join(vendorPackageDir, "node_modules");
  try {
    const vendorNodeModules = await lstat(vendorNodeModulesPath);
    if (!vendorNodeModules.isDirectory()) {
      throw new Error(`${vendorNodeModulesPath} is not a directory.`);
    }
  } catch (error) {
    throw new Error(`Unable to locate OpenClaw node_modules at ${vendorNodeModulesPath}: ${toErrorMessage(error)}`);
  }

  const linkPath = path.join(instrumentationDir, "node_modules");
  const normalizeForCompare = (value: string): string => path.resolve(value).toLowerCase();
  const expectedTarget = normalizeForCompare(vendorNodeModulesPath);
  const existing = await lstat(linkPath).catch(() => undefined);

  if (existing) {
    if (existing.isSymbolicLink()) {
      const currentTarget = await readlink(linkPath);
      const resolvedTarget = normalizeForCompare(path.resolve(path.dirname(linkPath), currentTarget));
      if (resolvedTarget === expectedTarget) {
        return;
      }
    }
    await rm(linkPath, { recursive: true, force: true });
  }

  await symlink(vendorNodeModulesPath, linkPath, "junction");
}

async function ensureInstrumentedWorkspaceModules(agentModulePath: string, instrumentationDir: string): Promise<{
  agentModuleUrl: string;
  eventModuleUrl: string;
}> {
  const graph = await buildLocalModuleGraph(agentModulePath);
  const eventModule = selectAgentEventModule(graph);
  const onAgentEventLocalBinding = eventModule ? resolveOnAgentEventLocalBinding(eventModule.source) : null;
  if (!eventModule || !onAgentEventLocalBinding) {
    const moduleFiles = [...graph.values()].map((node) => path.basename(node.filePath)).sort();
    throw new Error(`Unable to locate OpenClaw onAgentEvent export from ${agentModulePath}. Reachable modules: ${moduleFiles.join(", ") || "(none)"}.`);
  }

  const instrumentedSourcePaths = resolveModulePathFromGraph(graph, agentModulePath, eventModule.filePath);
  if (!instrumentedSourcePaths?.length) {
    throw new Error(`Unable to resolve import path from ${agentModulePath} to ${eventModule.filePath}.`);
  }

  const distDir = path.dirname(agentModulePath);
  const vendorPackageDir = path.dirname(distDir);
  const instrumentationKey = buildInstrumentationKey(agentModulePath, eventModule.filePath, instrumentedSourcePaths);
  const instrumentedPathBySourcePath = new Map<string, string>();

  await mkdir(instrumentationDir, { recursive: true });
  await writeFile(path.join(instrumentationDir, "package.json"), JSON.stringify({ type: "module" }, null, 2), "utf8");
  await ensureInstrumentationNodeModulesLink(instrumentationDir, vendorPackageDir);

  for (const sourcePath of instrumentedSourcePaths) {
    const role = normalizePathForCompare(sourcePath) === normalizePathForCompare(agentModulePath)
      ? "agent"
      : normalizePathForCompare(sourcePath) === normalizePathForCompare(eventModule.filePath)
        ? "agent-events"
        : "agent-link";
    const sourceStem = path.basename(sourcePath, ".js").replace(/[^A-Za-z0-9_-]/g, "-");
    instrumentedPathBySourcePath.set(
      normalizePathForCompare(sourcePath),
      path.join(instrumentationDir, `.qjc-${role}-${sourceStem}-${instrumentationKey}.js`)
    );
  }

  for (const sourcePath of [...instrumentedSourcePaths].reverse()) {
    const sourceNode = graph.get(normalizePathForCompare(sourcePath));
    const instrumentedPath = instrumentedPathBySourcePath.get(normalizePathForCompare(sourcePath));
    if (!sourceNode || !instrumentedPath) {
      continue;
    }

    const overrides = Object.fromEntries(
      sourceNode.imports
        .map((importedModule): [string, string] | null => {
          const importedInstrumentedPath = instrumentedPathBySourcePath.get(normalizePathForCompare(importedModule.resolvedPath));
          return importedInstrumentedPath
            ? [importedModule.specifier, pathToFileURL(importedInstrumentedPath).href]
            : null;
        })
        .filter((entry): entry is [string, string] => Array.isArray(entry))
    );
    const instrumentedSource = rewriteModuleSpecifiers(sourceNode.source, path.dirname(sourcePath), overrides);
    const finalSource = normalizePathForCompare(sourcePath) === normalizePathForCompare(eventModule.filePath)
      ? `${instrumentedSource}\nexport { ${onAgentEventLocalBinding} as __qjcOnAgentEvent };\n`
      : instrumentedSource;
    await writeFile(instrumentedPath, finalSource, "utf8");
  }

  const instrumentedAgentPath = instrumentedPathBySourcePath.get(normalizePathForCompare(agentModulePath));
  const instrumentedEventModulePath = instrumentedPathBySourcePath.get(normalizePathForCompare(eventModule.filePath));
  if (!instrumentedAgentPath || !instrumentedEventModulePath) {
    throw new Error("Unable to prepare OpenClaw workspace instrumentation files.");
  }

  return {
    agentModuleUrl: pathToFileURL(instrumentedAgentPath).href,
    eventModuleUrl: pathToFileURL(instrumentedEventModulePath).href
  };
}

function resolveAgentCommand(agentModule: AgentCommandModule): AgentCommandFunction {
  const preferredExportNames = ["t", "agentCommandFromIngress", "agentCommand", "r", "n"];
  for (const exportName of preferredExportNames) {
    const candidate = agentModule[exportName];
    if (typeof candidate === "function") {
      return candidate as AgentCommandFunction;
    }
  }

  const actualExportNames = Object.keys(agentModule).sort();
  throw new Error(`Bundled OpenClaw agent module does not expose a supported agent command export. Expected one of: ${preferredExportNames.join(", ")}. Actual exports: ${actualExportNames.join(", ") || "(none)"}.`);
}

function extractReplyText(result: unknown): string {
  const payloads = Array.isArray((result as { payloads?: unknown[] } | null)?.payloads)
    ? ((result as { payloads: unknown[] }).payloads)
    : [];
  const parts: string[] = [];

  for (const payload of payloads) {
    if (typeof payload === "string") {
      parts.push(payload);
      continue;
    }
    if (!payload || typeof payload !== "object") {
      continue;
    }

    const typed = payload as {
      text?: unknown;
      markdown?: unknown;
      content?: unknown;
      caption?: unknown;
    };
    for (const candidate of [typed.text, typed.markdown, typed.content, typed.caption]) {
      if (typeof candidate === "string" && candidate.trim()) {
        parts.push(candidate);
        break;
      }
    }
  }

  return parts.join("\n\n").trim();
}

function createRuntimeSessionIdentity(sessionId: string): { sessionId: string; sessionKey: string } {
  const digest = createHash("sha1").update(sessionId).digest("hex");
  const runtimeSessionId = `desktop-${digest.slice(0, 32)}`;
  return {
    sessionId: runtimeSessionId,
    sessionKey: `${runtimeSessionId}-key`
  };
}

function renderAttachmentPrelude(projectRoot: string, attachments: ProjectResolvedAttachment[] | undefined): string {
  if (!attachments?.length) {
    return "";
  }

  const lines = [
    "",
    "Project attachments available for this request:",
    ...attachments.map((attachment, index) => {
      const relativePath = attachment.relativeProjectPath || path.relative(projectRoot, attachment.projectPath);
      return `${index + 1}. ${attachment.kind} | ${attachment.name} | ${relativePath}`;
    }),
    ""
  ];
  return lines.join("\n");
}

async function main(): Promise<void> {
  const inputRaw = await readStdin();
  const input = JSON.parse(inputRaw) as RunnerInput;
  const runId = input.runId?.trim() || randomUUID();

  process.env.OPENCLAW_HIDE_BANNER = "1";
  process.env.OPENCLAW_SUPPRESS_NOTES = "1";
  process.env.NODE_NO_WARNINGS = process.env.NODE_NO_WARNINGS || "1";
  process.chdir(input.projectRoot);

  emit({
    type: "started",
    runId
  });
  emit({
    type: "status",
    runId,
    stage: "workspace-agent",
    label: "Running project workspace agent"
  });

  const agentModulePath = await resolveAgentModulePath(input.vendorPackageDir);
  const instrumentedModules = await ensureInstrumentedWorkspaceModules(agentModulePath, resolveInstrumentationDir(input));
  const agentModule = await import(instrumentedModules.agentModuleUrl) as AgentCommandModule;
  const eventModule = await import(instrumentedModules.eventModuleUrl) as InstrumentedAgentEventModule;
  const agentCommand = resolveAgentCommand(agentModule);

  let streamedText = "";
  const unsubscribe = typeof eventModule.__qjcOnAgentEvent === "function"
    ? eventModule.__qjcOnAgentEvent((event) => {
      if (event.runId !== runId || event.stream !== "assistant") {
        return;
      }

      const fullText = typeof event.data?.text === "string" ? event.data.text : "";
      const deltaFromEvent = typeof event.data?.delta === "string" ? event.data.delta : "";
      const nextFullText = fullText && fullText.length >= streamedText.length
        ? fullText
        : deltaFromEvent
          ? streamedText + deltaFromEvent
          : streamedText;
      const textDelta = nextFullText.startsWith(streamedText)
        ? nextFullText.slice(streamedText.length)
        : deltaFromEvent || nextFullText;
      if (!textDelta) {
        return;
      }

      streamedText = nextFullText;
      emit({
        type: "delta",
        runId,
        textDelta,
        fullText: nextFullText
      });
    })
    : undefined;

  const silentRuntime = {
    log: () => undefined,
    info: () => undefined,
    warn: () => undefined,
    error: () => undefined,
    debug: () => undefined
  };

  const runtimeSession = createRuntimeSessionIdentity(input.sessionId);
  const message = `${input.prompt}${renderAttachmentPrelude(input.projectRoot, input.attachments)}`.trim();
  try {
    const result = await agentCommand({
      message,
      sessionId: runtimeSession.sessionId,
      sessionKey: runtimeSession.sessionKey,
      workspaceDir: input.projectRoot,
      runId,
      senderIsOwner: true,
      allowModelOverride: true
    }, silentRuntime);

    emit({
      type: "completed",
      runId,
      content: extractReplyText(result),
      result
    });
  } finally {
    unsubscribe?.();
  }
}

main().catch((error) => {
  emit({
    type: "error",
    message: toErrorMessage(error)
  });
  process.exitCode = 1;
});
