import { createHash } from "node:crypto";
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { Readable, Writable } from "node:stream";
import { describe, expect, test } from "bun:test";
import {
  ApplyPatchV2Error,
  applyPatchV2HelpPayload,
  applyPatchV2RemoteCommand,
  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 stdin preflight", () => {
  test("POSIX replacement writers preserve existing file modes", () => {
    const workspace = mkdtempSync(join(tmpdir(), "unidesk-apply-patch-mode-"));
    try {
      const executablePath = join(workspace, "run.sh");
      const regularPath = join(workspace, "notes.txt");
      const originalExecutable = Buffer.from("#!/bin/sh\nprintf 'old\\n'\n");
      const updatedExecutable = Buffer.from("#!/bin/sh\nprintf 'updated\\n'\n");
      const updatedRegular = Buffer.from("updated\n");
      writeFileSync(executablePath, originalExecutable);
      writeFileSync(regularPath, "old\n");
      chmodSync(executablePath, 0o755);
      chmodSync(regularPath, 0o644);

      const replacement = [
        Buffer.from(executablePath).toString("base64"),
        String(originalExecutable.length),
        createHash("sha256").update(originalExecutable).digest("hex"),
        String(updatedExecutable.length),
        createHash("sha256").update(updatedExecutable).digest("hex"),
        "utf8",
        `0,2,${updatedExecutable.toString("base64")}`,
      ].join(" ") + "\n";
      const bulkCommand = applyPatchV2RemoteCommand("apply-replacements-bulk-stdin", ["1"]);
      const bulkResult = spawnSync(bulkCommand[0]!, bulkCommand.slice(1), { input: replacement, encoding: "utf8" });
      expect(bulkResult.status).toBe(0);
      expect(readFileSync(executablePath)).toEqual(updatedExecutable);
      expect(statSync(executablePath).mode & 0o777).toBe(0o755);
      expect(spawnSync(executablePath, { encoding: "utf8" }).stdout).toBe("updated\n");

      const regularSha256 = createHash("sha256").update(updatedRegular).digest("hex");
      const fallbackCommand = applyPatchV2RemoteCommand("write-b64-stdin", [regularPath, String(updatedRegular.length), regularSha256]);
      const fallbackResult = spawnSync(fallbackCommand[0]!, fallbackCommand.slice(1), { input: updatedRegular.toString("base64"), encoding: "utf8" });
      expect(fallbackResult.status).toBe(0);
      expect(readFileSync(regularPath)).toEqual(updatedRegular);
      expect(statSync(regularPath).mode & 0o777).toBe(0o644);
    } finally {
      rmSync(workspace, { recursive: true, force: true });
    }
  });

  test("documents quoted heredoc input without a temporary patch file", () => {
    const help = applyPatchV2HelpPayload();
    const serialized = JSON.stringify(help);

    expect(help.command).toContain("<<'PATCH'");
    expect(help.usage.every((example) => example.includes("<<'PATCH'"))).toBe(true);
    expect(serialized).not.toMatch(/<\s+patch[.]diff/);
    expect(serialized).not.toMatch(/\/tmp\/patch[.]diff/);
  });

  test("malformed input hint points directly to a quoted heredoc", async () => {
    const stdout = new CaptureWritable();
    const stderr = new CaptureWritable();

    const exitCode = await runApplyPatchV2({
      executor: {},
      stdin: Readable.from(["not a patch\n"]),
      stdout,
      stderr,
    });

    expect(exitCode).toBe(1);
    expect(stdout.text()).toBe("");
    expect(stderr.text()).toContain("trans <route> apply-patch <<'PATCH'");
    expect(stderr.text()).toContain("without a temporary patch file");
    expect(stderr.text()).not.toMatch(/\/tmp\/patch[.]diff/);
  });

  test("fails immediately on an interactive TTY and points to a quoted heredoc", async () => {
    const stdin = Readable.from([]) as Readable & { isTTY?: boolean };
    Object.defineProperty(stdin, "isTTY", { value: true });
    const stdout = new CaptureWritable();
    const stderr = new CaptureWritable();
    const startedAtMs = Date.now();

    const exitCode = await runApplyPatchV2({ executor: {}, stdin, stdout, stderr });

    expect(exitCode).toBe(2);
    expect(Date.now() - startedAtMs).toBeLessThan(1000);
    expect(stdout.text()).toBe("");
    expect(stderr.text()).toContain("UNIDESK_APPLY_PATCH_INPUT");
    expect(stderr.text()).toContain("\"code\":\"apply-patch-stdin-required\"");
    expect(stderr.text()).toContain("trans <route> apply-patch <<'PATCH'");
    expect(stderr.text()).toContain("\"temporaryFileRequired\":false");
  });
});

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("preserves CRLF for single and multi-chunk localized replacements", async () => {
    const cases = [
      {
        name: "single",
        patch: [
          "*** Begin Patch",
          "*** Update File: src/test.c",
          "@@",
          " alpha",
          "-old-a",
          "+new-a",
          " middle",
          "*** End Patch",
        ],
        expected: "alpha\r\nnew-a\r\nmiddle\r\nold-b\r\nomega\r\n",
      },
      {
        name: "multi",
        patch: [
          "*** Begin Patch",
          "*** Update File: src/test.c",
          "@@",
          " alpha",
          "-old-a",
          "+new-a",
          " middle",
          "@@",
          "-old-b",
          "+new-b",
          " omega",
          "*** End Patch",
        ],
        expected: "alpha\r\nnew-a\r\nmiddle\r\nnew-b\r\nomega\r\n",
      },
    ];

    for (const item of cases) {
      const files = new Map<string, string>([["src/test.c", "alpha\r\nold-a\r\nmiddle\r\nold-b\r\nomega\r\n"]]);
      let capturedPlan: ApplyPatchV2BulkReplacementWritePlan | undefined;
      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, files.get(path) ?? ""])); },
        async applyReplacementsBulk(paths, plans) {
          for (const target of paths) {
            const plan = plans.get(target);
            if (plan === undefined) throw new Error(`missing plan for ${target}`);
            capturedPlan = plan;
            files.set(target, applyReplacementPlan(files.get(target) ?? "", plan));
          }
        },
      };
      const exitCode = await runApplyPatchV2({
        executor: { fs },
        stdin: Readable.from([item.patch.join("\n")]),
        stdout: new CaptureWritable(),
        stderr: new CaptureWritable(),
      });

      expect(exitCode, item.name).toBe(0);
      expect(files.get("src/test.c"), item.name).toBe(item.expected);
      expect(files.get("src/test.c")?.match(/(?<!\r)\n/u), item.name).toBeNull();
      expect(capturedPlan?.newlineStyle, item.name).toBe("crlf");
      expect(capturedPlan?.encodingStyle, item.name).toBe("utf8");
    }
  });

  test("preserves legacy single-byte content during ASCII localized replacement", async () => {
    const original = Buffer.concat([
      Buffer.from("static int old_value = 1;\r\n// legacy ", "ascii"),
      Buffer.from([0xca]),
      Buffer.from(" comment\r\n", "ascii"),
    ]);
    let captured: ApplyPatchV2BulkReplacementWritePlan | undefined;
    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, {
          content: original.toString("latin1"),
          bytes: original,
          encodingStyle: "legacy-single-byte" as const,
        }]));
      },
      async applyReplacementsBulk(_paths, plans) {
        captured = plans.get("src/legacy.c");
      },
    };
    const exitCode = await runApplyPatchV2({
      executor: { fs },
      stdin: Readable.from([[
        "*** Begin Patch",
        "*** Update File: src/legacy.c",
        "@@",
        "-static int old_value = 1;",
        "+static int new_value = 2;",
        "*** End Patch",
      ].join("\n")]),
      stdout: new CaptureWritable(),
      stderr: new CaptureWritable(),
    });

    expect(exitCode).toBe(0);
    expect(captured?.encodingStyle).toBe("legacy-single-byte");
    expect(captured?.newlineStyle).toBe("crlf");
    expect(captured?.originalBytes).toBe(original.length);
    expect(captured?.originalSha256).toBe(createHash("sha256").update(original).digest("hex"));
  });

  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");
  });

  test("integrity mismatch reports current fingerprint and text format without file content", async () => {
    let readCount = 0;
    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) {
        readCount += 1;
        const text = readCount === 1 ? "alpha\r\nold\r\nomega\r\n" : "alpha\r\nchanged-elsewhere\r\nomega\r\n";
        return new Map(paths.map((path) => [path, text]));
      },
      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,
          landed: false,
          exitCode: 23,
          stderrTail: "bulk replacement original integrity mismatch for F:\\Work\\ConStart\\src\\test.c",
        });
      },
    };
    const stdout = new CaptureWritable();
    const stderr = new CaptureWritable();
    const exitCode = await runApplyPatchV2({
      executor: { fs },
      stdin: Readable.from([[
        "*** Begin Patch",
        "*** Update File: src/test.c",
        "@@",
        " 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("");
    expect(readCount).toBe(2);
    const err = stderr.text();
    expect(err).toContain("Integrity: adapter=windows stage=original file=src/test.c");
    expect(err).toContain("expectedOriginalSha256=");
    expect(err).toContain("currentSha256=");
    expect(err).toContain("expectedNewline=crlf currentNewline=crlf");
    expect(err).toContain("expectedEncoding=utf8 currentEncoding=utf8");
    expect(err).not.toContain("changed-elsewhere");
  });
});
