import { homedir } from "node:os";
import { join } from "node:path";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import type { Logger } from "pino";
import { AGENT } from "@/core/constants.js";
import { AgentError, JobTimeoutError, JobAbortedError } from "@/core/errors.js";
import type { AgentRunner, CliRunResult, RunAgentOptions, StreamJsonEvent } from "@/types/index.js";

/**
 * Linux ARG_MAX is ~2 MB for argv + envp combined.  The effective ceiling for
 * argv alone depends on how much space envp occupies (AWS/ECS injects many
 * variables).  128 KB is conservative enough to avoid E2BIG even in heavy-env
 * containers.  Prompts above this are written to a temp file and the agent is
 * told to read it.
 */
const PROMPT_ARGV_LIMIT = 128 * 1024;

const HU_BOT_PROMPT_DIR = ".hu-bot";

const CLAUDE_AGENT_BIN = join(homedir(), ".local", "bin", "claude");

/**
 * Per-log-line field cap. Docker/FireLens splits any stdout line longer than
 * ~16 KB into separate events — and splitting a JSON log mid-string yields
 * invalid JSON in Datadog. 8 KB leaves room for the Pino envelope so every
 * emitted line stays a single, parseable record.
 */
const LOG_FIELD_MAX = 8 * 1024;

function truncateStreamJsonForLog(json: string): string {
  if (json.length <= LOG_FIELD_MAX) return json;
  return `${json.slice(0, LOG_FIELD_MAX)}…`;
}

export class ClaudeCliClient implements AgentRunner {
  /** issueKey-scoped child logger for the active run; falls back to the base logger. */
  private _log: Logger | null = null;
  /** Accumulates streamed assistant text/thinking so it is logged in bounded chunks, not per token. */
  private streamBuffer = "";
  private streamPhase: "text" | "thinking" | null = null;

  constructor(
    private readonly apiKey: string,
    private readonly model: string,
    private readonly baseUrl: string | undefined,
    private readonly logger: Logger,
  ) {}

  async runAgent(
    prompt: string,
    workDir: string,
    timeoutMs = AGENT.INITIAL_TIMEOUT_MS,
    options?: RunAgentOptions,
  ): Promise<CliRunResult> {
    const log = options?.issueKey ? this.logger.child({ issueKey: options.issueKey }) : this.logger;

    this._log = log;

    const startTime = Date.now();
    const elapsedTag = () => `+${((Date.now() - startTime) / 1000).toFixed(1)}s`;

    const promptBytes = Buffer.byteLength(prompt, "utf-8");

    if (options?.signal?.aborted) {
      throw new JobAbortedError("Claude CLI run aborted before start");
    }

    log.info(
      { workDir, model: this.model, promptChars: prompt.length, promptBytes, timeoutMs },
      "claude_cli_started",
    );
    const useFile = promptBytes > PROMPT_ARGV_LIMIT;
    let promptRelPath: string | null = null;
    let autoGeneratedDir: string | null = null;

    if (useFile) {
      const jobDir =
        options?.jobDir ??
        `${HU_BOT_PROMPT_DIR}/prompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
      if (!options?.jobDir) autoGeneratedDir = join(workDir, jobDir);

      const dir = join(workDir, jobDir);
      mkdirSync(dir, { recursive: true });
      promptRelPath = `${jobDir}/prompt.md`;
      writeFileSync(join(dir, "prompt.md"), prompt);
      log.info(
        {
          promptBytes,
          limit: PROMPT_ARGV_LIMIT,
          promptFile: promptRelPath,
          autoDir: !!autoGeneratedDir,
        },
        "claude_cli_prompt_written_to_file",
      );
    }

    let removeAbortListener: (() => void) | undefined;
    try {
      const agentBinExists = await Bun.file(CLAUDE_AGENT_BIN).exists();
      const bin = agentBinExists ? CLAUDE_AGENT_BIN : "claude";

      const cliFlags = [
        "--model",
        this.model,
        "--output-format",
        "stream-json",
        "--verbose",
        // Required so Claude emits intermediate content-block-delta events
        // (stream_event), which feed the Datadog thinking/tool streaming and
        // the deltaBuffer fallback in extractAssistantText.
        "--include-partial-messages",
        "--dangerously-skip-permissions",
      ];

      const effectivePrompt = useFile
        ? `Read and follow ALL instructions in the file ${promptRelPath} (in your current working directory). That file contains the complete task description, ticket context, repo rules, and expected output format. Do NOT summarize it — execute the task exactly as described.`
        : prompt;

      const proc = Bun.spawn([bin, "-p", effectivePrompt, ...cliFlags], {
        cwd: workDir,
        stdin: "ignore",
        stdout: "pipe",
        stderr: "pipe",
        env: {
          ...process.env,
          ANTHROPIC_API_KEY: this.apiKey,
          ...(this.baseUrl ? { ANTHROPIC_BASE_URL: this.baseUrl } : {}),
          IS_SANDBOX: "1",
          DISABLE_AUTOUPDATER: "1",
          GIT_EDITOR: "true",
          GIT_SEQUENCE_EDITOR: "true",
          GIT_MERGE_AUTOEDIT: "no",
          GIT_TERMINAL_PROMPT: "0",
        },
      });

      // Check if process died immediately (exitCode is non-null if already exited)
      if (proc.exitCode !== null) {
        const stderr = await new Response(proc.stderr).text();
        log.error(
          { childPid: proc.pid, exitCode: proc.exitCode, stderr: stderr.slice(0, 1000) },
          "claude_cli_process_died_immediately",
        );
        return {
          success: false,
          output: "",
          exitCode: proc.exitCode,
        };
      }

      let timedOut = false;
      const timer = setTimeout(async () => {
        try {
          timedOut = true;
          log.warn(
            { workDir, childPid: proc.pid, elapsedMs: Date.now() - startTime },
            "claude_cli_timeout_killing",
          );
          proc.kill("SIGTERM");

          const graceMs = 5_000;
          const exited = await Promise.race([
            proc.exited.then(() => true),
            new Promise<false>((r) => setTimeout(() => r(false), graceMs)),
          ]);
          if (!exited) {
            log.warn({ childPid: proc.pid }, "claude_cli_force_killing");
            proc.kill("SIGKILL");
          }
        } catch (killErr) {
          log.error({ err: killErr }, "claude_cli_kill_failed");
        }
      }, timeoutMs);

      let aborted = false;
      const onAbort = () => {
        aborted = true;
        log.warn({ childPid: proc.pid }, "claude_cli_aborted_killing");
        try {
          proc.kill("SIGTERM");
        } catch {
          /* may be gone */
        }
        setTimeout(() => {
          try {
            if (proc.exitCode === null) proc.kill("SIGKILL");
          } catch {
            /* best-effort */
          }
        }, 5_000).unref?.();
      };
      options?.signal?.addEventListener("abort", onAbort, { once: true });
      removeAbortListener = () => options?.signal?.removeEventListener("abort", onAbort);

      const fullOutput: string[] = [];
      const stderrChunks: string[] = [];
      let stdoutFirstChunk = true;
      let stderrFirstChunk = true;

      const streamStdout = async (stream: ReadableStream<Uint8Array>) => {
        const decoder = new TextDecoder();
        let buffer = "";
        const reader = stream.getReader();

        try {
          while (true) {
            const { done, value } = await reader.read();
            if (done) {
              log.info(
                { elapsed: elapsedTag(), totalLines: fullOutput.length },
                "claude_cli_stdout_stream_ended",
              );
              break;
            }

            if (stdoutFirstChunk) {
              stdoutFirstChunk = false;
              log.info(
                { elapsed: elapsedTag(), bytes: value.byteLength },
                "claude_cli_stdout_first_data",
              );
            }

            const text = decoder.decode(value, { stream: true });
            buffer += text;

            const lines = buffer.split("\n");
            buffer = lines.pop() ?? "";

            for (const line of lines) {
              const trimmed = line.trim();
              if (!trimmed) continue;
              fullOutput.push(line);
              this.handleStreamLine(trimmed, elapsedTag());
            }
          }

          if (buffer.trim()) {
            fullOutput.push(buffer);
            this.handleStreamLine(buffer.trim(), elapsedTag());
          }
        } catch (streamErr) {
          log.error({ err: streamErr, elapsed: elapsedTag() }, "claude_cli_stdout_stream_error");
          throw streamErr;
        } finally {
          reader.releaseLock();
        }
      };

      const streamStderr = async (stream: ReadableStream<Uint8Array>) => {
        const decoder = new TextDecoder();
        let buffer = "";
        const reader = stream.getReader();

        try {
          while (true) {
            const { done, value } = await reader.read();
            if (done) {
              log.info(
                { elapsed: elapsedTag(), totalLines: stderrChunks.length },
                "claude_cli_stderr_stream_ended",
              );
              break;
            }

            if (stderrFirstChunk) {
              stderrFirstChunk = false;
              log.info(
                { elapsed: elapsedTag(), bytes: value.byteLength },
                "claude_cli_stderr_first_data",
              );
            }

            const text = decoder.decode(value, { stream: true });
            buffer += text;

            const lines = buffer.split("\n");
            buffer = lines.pop() ?? "";

            for (const line of lines) {
              const trimmed = line.trim();
              if (!trimmed) continue;
              stderrChunks.push(line);
              this.logEvent(
                "stderr",
                { line: truncateStreamJsonForLog(trimmed) },
                elapsedTag(),
                "warn",
              );
            }
          }

          if (buffer.trim()) {
            stderrChunks.push(buffer);
            this.logEvent(
              "stderr",
              { line: truncateStreamJsonForLog(buffer.trim()) },
              elapsedTag(),
              "warn",
            );
          }
        } catch (streamErr) {
          log.error({ err: streamErr, elapsed: elapsedTag() }, "claude_cli_stderr_stream_error");
          throw streamErr;
        } finally {
          reader.releaseLock();
        }
      };

      await Promise.all([streamStdout(proc.stdout), streamStderr(proc.stderr)]);
      this.flushStream();

      const exitCode = await proc.exited;
      clearTimeout(timer);

      if (aborted) {
        throw new JobAbortedError(`Claude CLI aborted after ${Date.now() - startTime}ms`);
      }

      const elapsedMs = Date.now() - startTime;
      const output = ClaudeCliClient.extractAssistantText(fullOutput);

      log.info(
        {
          exitCode,
          elapsedMs,
          stdoutLines: fullOutput.length,
          stderrLines: stderrChunks.length,
          outputLength: output.length,
        },
        "claude_cli_exit_received",
      );

      if (timedOut) {
        throw new JobTimeoutError(`Claude CLI timed out after ${elapsedMs}ms`);
      }

      if (exitCode !== 0) {
        log.warn(
          {
            exitCode,
            elapsedMs,
            outputTail: output.slice(-500),
            stderrTail: stderrChunks.slice(-5).join("\n"),
          },
          "claude_cli_failed",
        );
      } else {
        log.info({ exitCode, elapsedMs, outputLength: output.length }, "claude_cli_finished");
      }

      return { success: exitCode === 0, output, exitCode };
    } catch (err) {
      if (err instanceof JobTimeoutError || err instanceof JobAbortedError) throw err;

      const message = err instanceof Error ? err.message : String(err);
      log.error(
        {
          err: message,
          elapsed: elapsedTag(),
          stack: err instanceof Error ? err.stack : undefined,
        },
        "claude_cli_unexpected_error",
      );
      throw new AgentError(`Claude CLI error: ${message}`, {
        cause: err,
      });
    } finally {
      removeAbortListener?.();
      this.flushStream();
      this._log = null;
      if (autoGeneratedDir && existsSync(autoGeneratedDir)) {
        try {
          rmSync(autoGeneratedDir, { recursive: true, force: true });
        } catch {
          /* best-effort cleanup */
        }
      }
    }
  }

  /** Active issueKey-scoped logger (or the base logger between runs). */
  private get activeLog(): Logger {
    return this._log ?? this.logger;
  }

  /** Buffer streamed assistant text/thinking; flushed in bounded chunks by `flushStream`. */
  private bufferStream(text: string, phase: "text" | "thinking"): void {
    if (this.streamPhase && this.streamPhase !== phase) this.flushStream();
    this.streamPhase = phase;
    this.streamBuffer += text;
    if (this.streamBuffer.length >= LOG_FIELD_MAX) this.flushStream();
  }

  /** Emit buffered stream text as structured lines, each ≤ LOG_FIELD_MAX (so Docker never splits a JSON log). */
  private flushStream(): void {
    if (!this.streamBuffer) return;
    const phase = this.streamPhase ?? "text";
    const buffered = this.streamBuffer;
    this.streamBuffer = "";
    this.streamPhase = null;
    const level = phase === "thinking" ? "debug" : "info";
    for (let i = 0; i < buffered.length; i += LOG_FIELD_MAX) {
      this.activeLog[level](
        { phase, chunk: buffered.slice(i, i + LOG_FIELD_MAX) },
        "claude_stream",
      );
    }
  }

  /** Log a discrete agent event as structured JSON, flushing any pending stream text first (preserves order). */
  private logEvent(
    phase: string,
    fields: Record<string, unknown>,
    elapsed: string,
    level: "info" | "debug" | "error" | "warn" = "info",
  ): void {
    this.flushStream();
    this.activeLog[level]({ phase, elapsed, ...fields }, "claude_event");
  }

  private handleStreamLine(raw: string, elapsed: string): void {
    let event: StreamJsonEvent;
    try {
      event = JSON.parse(raw) as StreamJsonEvent;
    } catch {
      this.logEvent("raw", { line: truncateStreamJsonForLog(raw) }, elapsed, "debug");
      return;
    }

    const t = event.type ?? "unknown";

    if (t === "system") {
      this.logEvent(
        "system",
        { event: truncateStreamJsonForLog(JSON.stringify(event)) },
        elapsed,
        "debug",
      );
      return;
    }

    if (t === "stream_event") {
      const ev = (event.event ?? {}) as Record<string, unknown>;
      const delta = (ev.delta ?? {}) as Record<string, unknown>;
      if (delta.type === "text_delta" && typeof delta.text === "string")
        this.bufferStream(delta.text, "text");
      else if (delta.type === "thinking_delta" && typeof delta.thinking === "string")
        this.bufferStream(delta.thinking, "thinking");
      return;
    }

    if (t === "assistant") {
      const msg = (event.message ?? {}) as Record<string, unknown>;
      const content = Array.isArray(msg.content)
        ? (msg.content as Array<Record<string, unknown>>)
        : [];
      for (const block of content) {
        if (block.type === "text" && typeof block.text === "string")
          this.bufferStream(block.text, "text");
        else if (block.type === "thinking" && typeof block.thinking === "string")
          this.bufferStream(block.thinking, "thinking");
        else if (block.type === "tool_use")
          this.logEvent("tool", { tool: truncateStreamJsonForLog(JSON.stringify(block)) }, elapsed);
      }
      return;
    }

    if (t === "user") {
      const msg = (event.message ?? {}) as Record<string, unknown>;
      const content = Array.isArray(msg.content)
        ? (msg.content as Array<Record<string, unknown>>)
        : [];
      for (const block of content) {
        if (block.type === "tool_result") {
          const rawContent =
            typeof block.content === "string" ? block.content : JSON.stringify(block.content ?? "");
          const out = rawContent.slice(0, 300).replace(/\n/g, " ");
          this.logEvent("tool_result", { result: out }, elapsed);
        }
      }
      return;
    }

    if (t === "result") {
      const isError = event.is_error === true || event.subtype === "error";
      this.logEvent(
        isError ? "result_error" : "result_final",
        { event: truncateStreamJsonForLog(JSON.stringify(event)) },
        elapsed,
        isError ? "error" : "info",
      );
      return;
    }

    this.logEvent(t, { event: truncateStreamJsonForLog(JSON.stringify(event)) }, elapsed, "debug");
  }

  /**
   * Reduce Claude Code stream-json lines to the final assistant text.
   * Preference order: terminal `result` event → last `assistant` message →
   * concatenated `text_delta` chunks from `stream_event`.
   */
  static extractAssistantText(rawLines: string[]): string {
    let resultText = "";
    const assistantTexts: string[] = [];
    const deltaBuffer: string[] = [];

    for (const line of rawLines) {
      let event: Record<string, unknown>;
      try {
        event = JSON.parse(line) as Record<string, unknown>;
      } catch {
        continue;
      }

      const t = String(event.type ?? "");

      if (t === "result") {
        const r = event.result;
        if (typeof r === "string" && r) resultText = r;
        continue;
      }

      if (t === "assistant") {
        const msg = event.message as Record<string, unknown> | undefined;
        if (msg && Array.isArray(msg.content)) {
          const texts: string[] = [];
          for (const block of msg.content as Array<Record<string, unknown>>) {
            if (block.type === "text" && typeof block.text === "string") texts.push(block.text);
          }
          if (texts.length > 0) assistantTexts.push(texts.join("\n"));
        }
        continue;
      }

      if (t === "stream_event") {
        const ev = event.event as Record<string, unknown> | undefined;
        if (ev && ev.type === "content_block_delta") {
          const delta = ev.delta as Record<string, unknown> | undefined;
          if (delta && delta.type === "text_delta" && typeof delta.text === "string") {
            deltaBuffer.push(delta.text);
          }
        }
        continue;
      }
    }

    if (resultText) return resultText.trim();
    if (assistantTexts.length > 0) return (assistantTexts[assistantTexts.length - 1] ?? "").trim();
    return deltaBuffer.join("").trim();
  }
}
