import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
import type { SshHostConfig } from "../src/config.ts";
import { fingerprintHostKey, probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";

function fixtureKey(label = "fixture"): Buffer {
  const algorithm = Buffer.from("ssh-ed25519");
  const length = Buffer.alloc(4);
  length.writeUInt32BE(algorithm.length);
  return Buffer.concat([length, algorithm, Buffer.from(label)]);
}

class FakeChannel extends EventEmitter {
  stderr = new EventEmitter();
  closeCalls = 0;
  signals: string[] = [];
  close(): void { this.closeCalls += 1; }
  signal(value: string): void { this.signals.push(value); }
}

type ConnectAction = (config: ConnectConfig, client: FakeClient) => void;
type ExecAction = (command: string, callback: (error: Error | undefined, channel: ClientChannel) => void) => void;

class FakeClient extends EventEmitter {
  connectConfig?: ConnectConfig;
  command?: string;
  channel?: FakeChannel;
  sftpCalls = 0;
  ended = false;
  destroyed = false;
  sftpValue?: SFTPWrapper;
  connectAction?: ConnectAction;
  execAction?: ExecAction;

  connect(config: ConnectConfig): this {
    this.connectConfig = config;
    queueMicrotask(() => {
      if (this.connectAction) this.connectAction(config, this);
      else this.emit("ready");
    });
    return this;
  }

  exec(command: string, callback: (error: Error | undefined, channel: ClientChannel) => void): void {
    this.command = command;
    if (this.execAction) {
      this.execAction(command, callback);
      return;
    }
    const channel = new FakeChannel();
    this.channel = channel;
    callback(undefined, channel as ClientChannel);
    queueMicrotask(() => {
      channel.emit("data", Buffer.from("stdout\n"));
      channel.stderr.emit("data", Buffer.from("stderr\n"));
      channel.emit("close", 7);
    });
  }

  sftp(callback: (error: Error | undefined, sftp: SFTPWrapper) => void): void {
    this.sftpCalls += 1;
    if (!this.sftpValue) callback(new Error("SFTP unavailable"), undefined as unknown as SFTPWrapper);
    else callback(undefined, this.sftpValue);
  }

  end(): this {
    this.ended = true;
    this.emit("close");
    return this;
  }

  destroy(): this {
    this.destroyed = true;
    return this;
  }
}

class FakeSftp {
  readonly files = new Map<string, Buffer>();
  readonly directories = new Set<string>(["/"]);
  readonly openCalls: Array<{ path: string; flags: string }> = [];
  readonly writes: string[] = [];
  readonly renames: Array<{ from: string; to: string; atomic: boolean }> = [];
  readonly unlinks: string[] = [];
  failStandardRename = false;
  ext_openssh_rename?: (from: string, to: string, callback: (error?: Error) => void) => void;

  constructor(atomicRename = true) {
    if (atomicRename) {
      this.ext_openssh_rename = (from, to, callback) => {
        this.renames.push({ from, to, atomic: true });
        const value = this.files.get(from);
        if (!value) { callback(new Error("source missing")); return; }
        this.files.set(to, value);
        this.files.delete(from);
        callback();
      };
    }
  }

  readFile(path: string, callback: (error: Error | undefined, data: Buffer) => void): void {
    const value = this.files.get(path);
    if (!value) callback(new Error(`missing file: ${path}`), Buffer.alloc(0));
    else callback(undefined, Buffer.from(value));
  }

  writeFile(path: string, data: Buffer, callback: (error?: Error) => void): void {
    this.writes.push(path);
    this.files.set(path, Buffer.from(data));
    callback();
  }

  open(path: string, flags: string, callback: (error: Error | undefined, handle: Buffer) => void): void {
    this.openCalls.push({ path, flags });
    if (!this.files.has(path)) callback(new Error(`cannot open: ${path}`), Buffer.alloc(0));
    else callback(undefined, Buffer.from(path));
  }

  close(_handle: Buffer, callback: (error?: Error) => void): void { callback(); }

  mkdir(path: string, callback: (error?: Error) => void): void {
    if (this.directories.has(path)) { callback(new Error("already exists")); return; }
    this.directories.add(path);
    callback();
  }

  stat(path: string, callback: (error: Error | undefined, stats: object) => void): void {
    if (this.directories.has(path)) callback(undefined, {});
    else callback(new Error(`missing directory: ${path}`), {});
  }

  rename(from: string, to: string, callback: (error?: Error) => void): void {
    this.renames.push({ from, to, atomic: false });
    if (this.failStandardRename) { callback(new Error("replace unsupported")); return; }
    const value = this.files.get(from);
    if (!value) { callback(new Error("source missing")); return; }
    this.files.set(to, value);
    this.files.delete(from);
    callback();
  }

  unlink(path: string, callback: (error?: Error) => void): void {
    this.unlinks.push(path);
    this.files.delete(path);
    callback();
  }
}

function passwordHost(method: "auto" | "password" | "keyboard-interactive" = "auto"): SshHostConfig {
  return {
    hostName: "host.example.test",
    user: "builder",
    port: 2222,
    auth: { type: "password", password: "secret", method },
    hostKey: fingerprintHostKey(fixtureKey()),
  };
}

async function connectedTransport(
  sftp?: FakeSftp,
  host: SshHostConfig = passwordHost(),
): Promise<{ fake: FakeClient; transport: Ssh2Transport }> {
  const fake = new FakeClient();
  if (sftp) fake.sftpValue = sftp as unknown as SFTPWrapper;
  const transport = new Ssh2Transport(host, fake as unknown as Client);
  await transport.connect();
  return { fake, transport };
}

test("connects with password auth, pins the host key, and streams exec output", async () => {
  const { fake, transport } = await connectedTransport();
  assert.equal(fake.connectConfig?.host, "host.example.test");
  assert.equal(fake.connectConfig?.username, "builder");
  assert.equal(fake.connectConfig?.password, "secret");
  assert.equal(fake.connectConfig?.tryKeyboard, true);
  assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey()), true);
  assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);

  const output: Buffer[] = [];
  const result = await transport.exec("printf ok", "/srv/build", {
    onData: (data) => output.push(data),
    timeout: 5,
  });
  assert.equal(result.exitCode, 7);
  assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\n");
  assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
  await transport.dispose();
  assert.equal(fake.ended, true);
});

test("supports password-only and keyboard-interactive authentication", async () => {
  const passwordOnly = await connectedTransport(undefined, passwordHost("password"));
  assert.equal(passwordOnly.fake.connectConfig?.tryKeyboard, false);
  await passwordOnly.transport.dispose();

  const interactive = await connectedTransport(undefined, passwordHost("keyboard-interactive"));
  let answers: string[] = [];
  interactive.fake.emit(
    "keyboard-interactive",
    "name",
    "instructions",
    "",
    [{ prompt: "Password:" }, { prompt: "Again:" }],
    (values: string[]) => { answers = values; },
  );
  assert.deepEqual(answers, ["secret", "secret"]);
  await interactive.transport.dispose();
});

test("loads private-key authentication and forwards the passphrase", async () => {
  const root = mkdtempSync(join(tmpdir(), "pi-ssh-key-"));
  try {
    const identityFile = join(root, "id_test");
    writeFileSync(identityFile, "PRIVATE KEY FIXTURE", { mode: 0o600 });
    const host: SshHostConfig = {
      ...passwordHost(),
      auth: { type: "private-key", identityFile, passphrase: "key-secret" },
    };
    const { fake, transport } = await connectedTransport(undefined, host);
    assert.equal(Buffer.from(fake.connectConfig?.privateKey ?? "").toString("utf8"), "PRIVATE KEY FIXTURE");
    assert.equal(fake.connectConfig?.passphrase, "key-secret");
    assert.equal(fake.connectConfig?.password, undefined);
    assert.equal(fake.connectConfig?.tryKeyboard, undefined);
    await transport.dispose();
  } finally {
    rmSync(root, { recursive: true, force: true });
  }
});

test("reads files, checks access modes, detects images, and reuses SFTP", async () => {
  const sftp = new FakeSftp();
  sftp.files.set("/srv/app/a.txt", Buffer.from("hello"));
  sftp.files.set("/srv/app/image.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
  const { fake, transport } = await connectedTransport(sftp);
  assert.equal((await transport.readFile("/srv/app/a.txt")).toString("utf8"), "hello");
  await transport.ensureReadable("/srv/app/a.txt");
  await transport.ensureReadableWritable("/srv/app/a.txt");
  assert.deepEqual(sftp.openCalls, [
    { path: "/srv/app/a.txt", flags: "r" },
    { path: "/srv/app/a.txt", flags: "r+" },
  ]);
  assert.equal(await transport.detectImageMimeType("/srv/app/image.png"), "image/png");
  assert.equal(await transport.detectImageMimeType("/srv/app/a.txt"), null);
  assert.equal(fake.sftpCalls, 1);
  await transport.dispose();
});

test("creates parent directories and atomically renames remote writes", async () => {
  const sftp = new FakeSftp(true);
  const { transport } = await connectedTransport(sftp);
  await transport.writeFile("/srv/app/output.txt", Buffer.from("new content"));
  assert.equal(sftp.directories.has("/srv"), true);
  assert.equal(sftp.directories.has("/srv/app"), true);
  assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "new content");
  assert.equal(sftp.renames.length, 1);
  assert.equal(sftp.renames[0]?.atomic, true);
  assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
  await transport.dispose();
});

test("falls back to direct overwrite when SFTP v3 rename cannot replace", async () => {
  const sftp = new FakeSftp(false);
  sftp.directories.add("/srv");
  sftp.directories.add("/srv/app");
  sftp.files.set("/srv/app/output.txt", Buffer.from("old"));
  sftp.failStandardRename = true;
  const { transport } = await connectedTransport(sftp);
  await transport.writeFile("/srv/app/output.txt", Buffer.from("replacement"));
  assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "replacement");
  assert.equal(sftp.renames.length, 1);
  assert.equal(sftp.renames[0]?.atomic, false);
  assert.equal(sftp.unlinks.length, 1);
  assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
  await transport.dispose();
});

test("aborts and times out commands by closing the active channel", async () => {
  const abortClient = new FakeClient();
  const abortChannel = new FakeChannel();
  abortClient.execAction = (_command, callback) => {
    abortClient.channel = abortChannel;
    callback(undefined, abortChannel as ClientChannel);
  };
  const abortTransport = new Ssh2Transport(passwordHost(), abortClient as unknown as Client);
  await abortTransport.connect();
  const controller = new AbortController();
  const aborted = abortTransport.exec("sleep 10", "/srv", { onData() {}, signal: controller.signal, timeout: 0 });
  await new Promise<void>((resolve) => setImmediate(resolve));
  controller.abort();
  await assert.rejects(aborted, /SSH command aborted/);
  assert.deepEqual(abortChannel.signals, ["KILL"]);
  assert.equal(abortChannel.closeCalls, 1);
  await abortTransport.dispose();

  const timeoutClient = new FakeClient();
  const timeoutChannel = new FakeChannel();
  timeoutClient.execAction = (_command, callback) => {
    timeoutClient.channel = timeoutChannel;
    callback(undefined, timeoutChannel as ClientChannel);
  };
  const timeoutTransport = new Ssh2Transport(passwordHost(), timeoutClient as unknown as Client);
  await timeoutTransport.connect();
  await assert.rejects(
    timeoutTransport.exec("sleep 10", "/srv", { onData() {}, timeout: 0.01 }),
    /timed out after 0.01s/,
  );
  assert.deepEqual(timeoutChannel.signals, ["KILL"]);
  assert.equal(timeoutChannel.closeCalls, 1);
  await timeoutTransport.dispose();
});

test("fails closed after the SSH connection closes", async () => {
  const sftp = new FakeSftp();
  sftp.files.set("/srv/a.txt", Buffer.from("a"));
  const { fake, transport } = await connectedTransport(sftp);
  fake.emit("close");
  await assert.rejects(transport.readFile("/srv/a.txt"), /SSH2 connection closed unexpectedly/);
  await transport.dispose();
});

test("probes and fingerprints a host key without authenticating", async () => {
  const fake = new FakeClient();
  const key = fixtureKey("probe");
  fake.connectAction = (config, client) => {
    assert.equal(config.host, "probe.example.test");
    assert.equal(config.port, 2200);
    assert.equal(config.username, "probe-user");
    assert.equal(config.hostVerifier?.(key), false);
    client.emit("close");
  };
  const result = await probeHostKey("probe.example.test", 2200, "probe-user", fake as unknown as Client);
  assert.deepEqual(result, fingerprintHostKey(key));
});

test("rejects host-key probing errors before a key is observed", async () => {
  const fake = new FakeClient();
  fake.connectAction = (_config, client) => client.emit("error", new Error("network unavailable"));
  await assert.rejects(
    probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client),
    /network unavailable/,
  );
  assert.equal(fake.destroyed, true);
});

test("times out host-key probing when the server never responds", async () => {
  const fake = new FakeClient();
  fake.connectAction = () => {};
  await assert.rejects(
    probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client, 5),
    /timed out while obtaining SSH host key/,
  );
  assert.equal(fake.destroyed, true);
  assert.equal(fake.connectConfig?.readyTimeout, 5);
});
