import { createHash } from "node:crypto";
import { Readable, Writable } from "node:stream";
import { describe, expect, test } from "bun:test";
import {
  ApplyPatchV2Error,
  runApplyPatchV2,
  type ApplyPatchV2BulkReplacementWritePlan,
  type ApplyPatchV2FileSystem,
} from "./apply-patch-v2";

class CaptureWritable extends Writable {
  chunks: Buffer[] = [];

  _write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
    this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
    callback();
  }

  text(): string {
    return Buffer.concat(this.chunks).toString("utf8");
  }
}

function sha256Hex(text: string): string {
  return createHash("sha256").update(text, "utf8").digest("hex");
}

function applyReplacementPlan(original: string, plan: ApplyPatchV2BulkReplacementWritePlan): string {
  const lines = original.split("\n");
  if (lines.at(-1) === "") lines.pop();
  for (const [start, oldLength, newLines] of [...plan.replacements].sort((left, right) => right[0] - left[0])) {
    lines.splice(start, oldLength, ...newLines);
  }
  return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
}

describe("apply-patch v2 fs bulk update path", () => {
  test("single fs update uses readFiles and applyReplacementsBulk instead of whole-file write", async () => {
    const files = new Map<string, string>([["docs/test.md", "alpha\nold\nomega\n"]]);
    const calls: string[] = [];
    const fs: ApplyPatchV2FileSystem = {
      async stat(path) {
        calls.push(`stat:${path}`);
        throw new Error("stat should not be used by the single-file bulk path");
      },
      async readBlock(path) {
        calls.push(`readBlock:${path}`);
        throw new Error("readBlock should not be used by the single-file bulk path");
      },
      async writeFile(path) {
        calls.push(`writeFile:${path}`);
        throw new Error("writeFile should not be used for update hunks with replacement bulk");
      },
      async deleteFile(path) {
        calls.push(`deleteFile:${path}`);
      },
      async readFiles(paths) {
        calls.push(`readFiles:${paths.join(",")}`);
        return new Map(paths.map((path) => [path, files.get(path) ?? ""]));
      },
      async applyReplacementsBulk(paths, plans) {
        const targets = Array.from(paths);
        calls.push(`applyReplacementsBulk:${targets.join(",")}`);
        for (const target of targets) {
          const plan = plans.get(target);
          if (plan === undefined) throw new Error(`missing plan for ${target}`);
          const next = applyReplacementPlan(files.get(target) ?? "", plan);
          expect(Buffer.byteLength(next, "utf8")).toBe(plan.finalBytes);
          expect(sha256Hex(next)).toBe(plan.finalSha256);
          files.set(target, next);
        }
      },
    };
    const stdout = new CaptureWritable();
    const stderr = new CaptureWritable();

    const exitCode = await runApplyPatchV2({
      executor: { fs },
      stdin: Readable.from([[
        "*** Begin Patch",
        "*** Update File: docs/test.md",
        "@@",
        " alpha",
        "-old",
        "+new",
        " omega",
        "*** End Patch",
      ].join("\n")]),
      stdout,
      stderr,
      timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
    });

    expect(exitCode).toBe(0);
    expect(files.get("docs/test.md")).toBe("alpha\nnew\nomega\n");
    expect(calls).toEqual(["readFiles:docs/test.md", "applyReplacementsBulk:docs/test.md"]);
    expect(stdout.text()).toContain("M docs/test.md");
    expect(stderr.text()).toContain("\"remoteOperationCounts\":{\"fs.readFiles\":1,\"fs.applyReplacementsBulk\":1}");
  });

  test("bulk read failure falls back to block reads but still writes through replacement bulk", async () => {
    const files = new Map<string, string>([
      ["a.md", "first\nold-a\nlast\n"],
      ["b.md", "first\nold-b\nlast\n"],
    ]);
    const calls: string[] = [];
    const fs: ApplyPatchV2FileSystem = {
      async stat(path) {
        calls.push(`stat:${path}`);
        const text = files.get(path);
        if (text === undefined) throw new Error(`missing ${path}`);
        return { bytes: Buffer.byteLength(text, "utf8"), sha256: sha256Hex(text) };
      },
      async readBlock(path, blockIndex, blockBytes) {
        calls.push(`readBlock:${path}:${blockIndex}:${blockBytes}`);
        const text = files.get(path);
        if (text === undefined) throw new Error(`missing ${path}`);
        return blockIndex === 0 ? Buffer.from(text, "utf8") : Buffer.alloc(0);
      },
      async writeFile(path) {
        calls.push(`writeFile:${path}`);
        throw new Error("writeFile should not be used after bulk read fallback");
      },
      async deleteFile(path) {
        calls.push(`deleteFile:${path}`);
      },
      async readFiles(paths) {
        calls.push(`readFiles:${paths.join(",")}`);
        throw new ApplyPatchV2Error("windows apply-patch fs operation failed: read-bulk-b64", {
          operation: "read-bulk-b64",
          targetCount: paths.length,
          remoteElapsedMs: 60000,
          landed: false,
        });
      },
      async applyReplacementsBulk(paths, plans) {
        const targets = Array.from(paths);
        calls.push(`applyReplacementsBulk:${targets.join(",")}`);
        for (const target of targets) {
          const plan = plans.get(target);
          if (plan === undefined) throw new Error(`missing plan for ${target}`);
          files.set(target, applyReplacementPlan(files.get(target) ?? "", plan));
        }
      },
    };
    const stdout = new CaptureWritable();
    const stderr = new CaptureWritable();

    const exitCode = await runApplyPatchV2({
      executor: { fs },
      stdin: Readable.from([[
        "*** Begin Patch",
        "*** Update File: a.md",
        "@@",
        " first",
        "-old-a",
        "+new-a",
        " last",
        "*** Update File: b.md",
        "@@",
        " first",
        "-old-b",
        "+new-b",
        " last",
        "*** End Patch",
      ].join("\n")]),
      stdout,
      stderr,
      timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
    });

    expect(exitCode).toBe(0);
    expect(files.get("a.md")).toBe("first\nnew-a\nlast\n");
    expect(files.get("b.md")).toBe("first\nnew-b\nlast\n");
    expect(calls).toEqual([
      "readFiles:a.md,b.md",
      "stat:a.md",
      "readBlock:a.md:0:45000",
      "stat:b.md",
      "readBlock:b.md:0:45000",
      "applyReplacementsBulk:a.md,b.md",
    ]);
    expect(stdout.text()).toContain("M a.md");
    expect(stdout.text()).toContain("M b.md");
    expect(stderr.text()).toContain("\"remoteFailureCount\":1");
  });

  test("remote fs failure prints operation details", async () => {
    const fs: ApplyPatchV2FileSystem = {
      async stat() {
        throw new Error("stat should not be used");
      },
      async readBlock() {
        throw new Error("readBlock should not be used");
      },
      async writeFile() {
        throw new Error("writeFile should not be used");
      },
      async deleteFile() {
        throw new Error("deleteFile should not be used");
      },
      async readFiles(paths) {
        return new Map(paths.map((path) => [path, "alpha\nold\nomega\n"]));
      },
      async applyReplacementsBulk() {
        throw new ApplyPatchV2Error("windows apply-patch fs operation failed: apply-replacements-bulk-stdin", {
          operation: "apply-replacements-bulk-stdin",
          route: "D601:win/F/Work/ConStart",
          targetCount: 1,
          inputBytes: 190,
          expectedBytes: "22",
          expectedSha256: "abc123",
          remoteElapsedMs: 60000,
          landed: false,
          exitCode: 124,
          stderrTail: "UNIDESK_SSH_RUNTIME_TIMEOUT timeoutSeconds=60",
        });
      },
    };
    const stdout = new CaptureWritable();
    const stderr = new CaptureWritable();

    const exitCode = await runApplyPatchV2({
      executor: { fs },
      stdin: Readable.from([[
        "*** Begin Patch",
        "*** Update File: docs/test.md",
        "@@",
        " alpha",
        "-old",
        "+new",
        " omega",
        "*** End Patch",
      ].join("\n")]),
      stdout,
      stderr,
      timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
    });

    expect(exitCode).toBe(1);
    expect(stdout.text()).toBe("");
    const err = stderr.text();
    expect(err).toContain("Remote operation: operation=apply-replacements-bulk-stdin route=D601:win/F/Work/ConStart targetCount=1 inputBytes=190 expectedBytes=22 sha256=abc123 elapsedMs=60000 landed=false exitCode=124");
    expect(err).toContain("Remote stderr tail:");
    expect(err).toContain("UNIDESK_SSH_RUNTIME_TIMEOUT timeoutSeconds=60");
  });
});
