/**
 * Minimal in-process ssh2 server for Phase 3 session tests.
 *
 * Capabilities (just what session.test.ts needs):
 *   - publickey auth: any client key that produces a valid signature is accepted
 *     (we verify the signature with ssh2.utils.parseKey of the OpenSSH-format
 *     pubkey reconstructed from `ctx.key.algo` + `ctx.key.data`).
 *   - exec: server runs a caller-provided handler that returns
 *     `{ stdout, stderr, exit }`, optionally with a delay. Default: echo command.
 *   - sftp: single in-memory file map. Supports OPEN / READ / WRITE / CLOSE /
 *     STAT / FSTAT / REALPATH. No directory ops. Big enough to cover
 *     sftp.createReadStream / createWriteStream paths.
 *
 * Two host-key shapes are useful in tests:
 *   1. Stable host key — the test fixture generates it once, you can read the
 *      OpenSSH wire-format b64 to seed a ResolvedConnection.hostKeyB64.
 *   2. Rotating host key — start a *second* server on the same port with a
 *      different key to drive the mismatch path.
 *
 * Not intended for production use. The acceptance policy is intentionally
 * permissive (any pubkey with a valid signature) so tests can vary the client
 * key per scenario.
 */
import { Server, utils as sshUtils, type Connection, type Session, type PublicKey, type SFTPWrapper, type Attributes, type FileEntry, type ServerChannel } from 'ssh2';

// ssh2 exposes SFTP constants on `utils.sftp` at runtime; the .d.ts puts them
// under the `utils.sftp` namespace, but they're easier to alias here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SFTP = (sshUtils as unknown as { sftp: { OPEN_MODE: Record<string, number>; STATUS_CODE: Record<string, number> } }).sftp;
import { createHash, randomBytes } from 'node:crypto';
import { generateKeyPairSync } from 'node:crypto';

export type ExecHandler = (
  command: string,
) => Promise<{ stdout?: string; stderr?: string; exit?: number; delayMs?: number }>;

/**
 * Server-side shell handler. Called when the client requests `shell()` after
 * a successful `pty()`. The handler is given the granted PTY geometry, a
 * write-back hook (push bytes to the client), and a small `onData` /
 * `onResize` registration API. Return a `close` function the server can
 * call when the client ends the channel.
 *
 * The handler is intentionally minimal: it lets a test fake a
 * line-discipline shell without depending on a real OS pty. Used for
 * SSH Console e2e tests.
 */
export interface ShellHandlerArgs {
  cols: number;
  rows: number;
  writeOut: (data: Buffer | string) => void;
  onData: (cb: (data: Buffer) => void) => void;
  onResize: (cb: (cols: number, rows: number) => void) => void;
}
export type ShellHandler = (args: ShellHandlerArgs) => Promise<() => void> | (() => void);

export interface StartTestServerArgs {
  exec?: ExecHandler;
  /** Optional shell handler — required for SSH Console tests. */
  shell?: ShellHandler;
  /** Optional pre-populated files for sftp tests (path → contents). */
  files?: Record<string, Buffer>;
  /** If provided, server uses this PEM/OpenSSH host key (must be ssh2-parseable). */
  hostKeyPem?: string | Buffer;
}

export interface RunningTestServer {
  port: number;
  hostKeyOpenSshB64: string;
  hostKeyFingerprint: string;
  /** Get a snapshot of the in-memory file map (for assertions). */
  getFile(path: string): Buffer | undefined;
  setFile(path: string, data: Buffer): void;
  /**
   * Close the server. Hangs if a long-lived client (SSH Console shell) is
   * still attached; tests should call `forceClose()` instead in that case
   * (or close their channels before invoking `close`).
   */
  close(): Promise<void>;
  /** Hard-shutdown: destroy every live client, then close the listener. */
  forceClose(): Promise<void>;
}

/** Generate a fresh ed25519 keypair in OpenSSH format (parseable by ssh2 both sides). */
export function generateEd25519Pair(): { privatePem: string; publicSsh: string } {
  const kp = sshUtils.generateKeyPairSync('ed25519');
  return { privatePem: kp.private, publicSsh: kp.public };
}

/** Generate an RSA-2048 PKCS#1 PEM keypair (parseable by ssh2.utils.parseKey). */
export function generateRsaPair(): { privatePem: Buffer; publicSsh: string } {
  const { privateKey, publicKey } = generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: { type: 'spki', format: 'pem' },
    privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
  });
  // Reformat the spki public key into OpenSSH wire format using ssh2.utils.
  const parsed = sshUtils.parseKey(Buffer.from(privateKey as string, 'utf-8'));
  if (parsed instanceof Error) throw parsed;
  const pubSsh = Array.isArray(parsed)
    ? parsed[0].getPublicSSH().toString('base64')
    : parsed.getPublicSSH().toString('base64');
  const algo = Array.isArray(parsed) ? parsed[0].type : parsed.type;
  return {
    privatePem: Buffer.from(privateKey as string, 'utf-8'),
    publicSsh: `${algo} ${pubSsh}`,
  };
}

/** OpenSSH-style sha256 fingerprint of an OpenSSH wire-format key (Buffer). */
export function sha256FingerprintFromRaw(raw: Buffer): string {
  return 'SHA256:' + createHash('sha256').update(raw).digest('base64').replace(/=+$/, '');
}

/** Convert a server-side PublicKey (algo + data) back to ssh2.utils.parseKey-friendly form. */
function reconstructPubKey(key: PublicKey): ReturnType<typeof sshUtils.parseKey> {
  // utils.parseKey accepts the OpenSSH single-line format: "<algo> <base64>".
  const line = `${key.algo} ${key.data.toString('base64')}`;
  return sshUtils.parseKey(line);
}

function defaultEcho(): ExecHandler {
  return async (cmd: string) => ({ stdout: `echo: ${cmd}\n`, stderr: '', exit: 0 });
}

export async function startTestServer(args: StartTestServerArgs = {}): Promise<RunningTestServer> {
  const hostKeyPem = args.hostKeyPem ?? generateEd25519Pair().privatePem;
  const execHandler = args.exec ?? defaultEcho();
  const shellHandler = args.shell;
  const files = new Map<string, Buffer>();
  for (const [k, v] of Object.entries(args.files ?? {})) files.set(k, Buffer.from(v));

  // Compute host key OpenSSH b64 + fingerprint for the caller.
  const parsedHost = sshUtils.parseKey(hostKeyPem);
  if (parsedHost instanceof Error) throw parsedHost;
  const hostPubSsh = Array.isArray(parsedHost) ? parsedHost[0].getPublicSSH() : parsedHost.getPublicSSH();
  const hostB64 = hostPubSsh.toString('base64');
  const hostFp = sha256FingerprintFromRaw(hostPubSsh);

  const liveClients = new Set<Connection>();
  const server = new Server({ hostKeys: [hostKeyPem] }, (client: Connection) => {
    liveClients.add(client);
    client.on('close', () => { liveClients.delete(client); });
    client.on('end', () => { liveClients.delete(client); });
    client.on('authentication', (ctx) => {
      if (ctx.method !== 'publickey') return ctx.reject(['publickey'], true);
      if (ctx.signature === undefined) {
        // Probe — client is asking whether the server would accept this key
        // without signing. Tell it yes; the real signed attempt follows.
        return ctx.accept();
      }
      const pub = reconstructPubKey(ctx.key);
      if (pub instanceof Error) return ctx.reject();
      const key = Array.isArray(pub) ? pub[0] : pub;
      if (!ctx.blob || !ctx.signature) return ctx.reject();
      if (key.verify(ctx.blob, ctx.signature, ctx.hashAlgo) !== true) return ctx.reject();
      ctx.accept();
    });
    client.on('ready', () => {
      client.on('session', (acceptSession) => {
        const session: Session = acceptSession();
        // Track the granted PTY geometry per session so a subsequent
        // 'shell' request can use it. ssh2's session events are
        // request-ordered, so pty arrives before shell when the client
        // calls client.shell({...}).
        let ptyCols = 80;
        let ptyRows = 24;
        const dataListeners = new Set<(data: Buffer) => void>();
        const resizeListeners = new Set<(cols: number, rows: number) => void>();
        session.on('pty', (acceptPty, _rejPty, info) => {
          ptyCols = info.cols;
          ptyRows = info.rows;
          acceptPty();
        });
        session.on('window-change', (acceptW, _rejW, info) => {
          ptyCols = info.cols;
          ptyRows = info.rows;
          // window-change may pass no accept callback in some ssh2 builds.
          if (typeof acceptW === 'function') {
            try { acceptW(); } catch { /* tolerated */ }
          }
          for (const cb of resizeListeners) {
            try { cb(info.cols, info.rows); } catch { /* tolerated */ }
          }
        });
        session.on('shell', (acceptShell) => {
          const stream: ServerChannel = acceptShell();
          if (!shellHandler) {
            // No handler — write a tiny banner and end. Tests that don't
            // wire a shell handler will see a closed channel quickly.
            stream.write('test-shell: no handler configured\r\n');
            stream.exit(0);
            stream.end();
            return;
          }
          stream.on('data', (data: Buffer) => {
            for (const cb of dataListeners) {
              try { cb(data); } catch { /* tolerated */ }
            }
          });
          Promise.resolve(shellHandler({
            cols: ptyCols,
            rows: ptyRows,
            writeOut: (data) => { stream.write(data); },
            onData: (cb) => { dataListeners.add(cb); },
            onResize: (cb) => { resizeListeners.add(cb); },
          })).then((close) => {
            stream.on('close', () => {
              try { close(); } catch { /* tolerated */ }
              dataListeners.clear();
              resizeListeners.clear();
            });
          }).catch((e: Error) => {
            stream.write(`shell error: ${e.message}\r\n`);
            stream.exit(1);
            stream.end();
          });
        });
        session.on('exec', (acceptExec, _rej, info) => {
          const stream = acceptExec();
          execHandler(info.command).then(async (r) => {
            if (r.delayMs && r.delayMs > 0) await new Promise((res) => setTimeout(res, r.delayMs));
            if (r.stdout) stream.write(r.stdout);
            if (r.stderr) stream.stderr.write(r.stderr);
            stream.exit(r.exit ?? 0);
            stream.end();
          }).catch((e: Error) => {
            stream.stderr.write(`server error: ${e.message}\n`);
            stream.exit(1);
            stream.end();
          });
        });
        session.on('sftp', (acceptSftp) => {
          const sftp: SFTPWrapper = acceptSftp();
          installSftpHandlers(sftp, files);
        });
      });
    });
    client.on('error', () => { /* ignore — tests inspect client side */ });
  });

  const port = await new Promise<number>((resolve) => {
    server.listen(0, '127.0.0.1', () => {
      const addr = server.address();
      if (typeof addr === 'object' && addr !== null) resolve(addr.port);
      else resolve(0);
    });
  });

  return {
    port,
    hostKeyOpenSshB64: hostB64,
    hostKeyFingerprint: hostFp,
    getFile: (p) => files.get(p),
    setFile: (p, d) => { files.set(p, Buffer.from(d)); },
    close: () => new Promise<void>((resolve) => server.close(() => resolve())),
    forceClose: () => new Promise<void>((resolve) => {
      // Destroy each live client so server.close() returns promptly.
      // The ssh2 Connection's `end()` is graceful (waits for FIN/ACK);
      // for tests we want the listener gone immediately, so we go for
      // the harder hammer first and tolerate already-closed sockets.
      for (const c of liveClients) {
        try {
          // ssh2 exposes `end()` always; the underlying socket has
          // `destroy()` reachable via `_sock` in current builds. Try
          // end() first; if the listener still has refs after a tick
          // the destroy fallback below kicks in.
          c.end();
        } catch { /* tolerated */ }
      }
      liveClients.clear();
      server.close(() => resolve());
      // Backstop: if the listener doesn't release in 250ms, force
      // a destroy by recursing into the underlying server.unref().
      setTimeout(() => resolve(), 250).unref();
    }),
  };
}

interface OpenHandle {
  handle: Buffer;
  path: string;
  flags: number;
  position: number;
}

function defaultAttributes(size: number): Attributes {
  const nowSec = Math.floor(Date.now() / 1000);
  return {
    mode: 0o100644,
    uid: 1000,
    gid: 1000,
    size,
    atime: nowSec,
    mtime: nowSec,
  };
}

function installSftpHandlers(sftp: SFTPWrapper, files: Map<string, Buffer>): void {
  const handles = new Map<string, OpenHandle>();
  const keyOf = (h: Buffer) => h.toString('hex');
  const allocHandle = (path: string, flags: number): OpenHandle => {
    const handle = randomBytes(8);
    const oh: OpenHandle = { handle, path, flags, position: 0 };
    handles.set(keyOf(handle), oh);
    return oh;
  };

  sftp.on('OPEN', (reqId, filename, flags) => {
    const isWrite = (flags & SFTP.OPEN_MODE.WRITE) !== 0;
    const isCreat = (flags & SFTP.OPEN_MODE.CREAT) !== 0;
    const isTrunc = (flags & SFTP.OPEN_MODE.TRUNC) !== 0;
    if (isWrite) {
      if (!files.has(filename)) {
        if (isCreat) files.set(filename, Buffer.alloc(0));
        else return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
      } else if (isTrunc) {
        files.set(filename, Buffer.alloc(0));
      }
    } else {
      if (!files.has(filename)) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
    }
    const oh = allocHandle(filename, flags);
    sftp.handle(reqId, oh.handle);
  });

  sftp.on('READ', (reqId, handle, offset, len) => {
    const oh = handles.get(keyOf(handle));
    if (!oh) return sftp.status(reqId, SFTP.STATUS_CODE.FAILURE);
    const buf = files.get(oh.path);
    if (!buf) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
    if (offset >= buf.length) return sftp.status(reqId, SFTP.STATUS_CODE.EOF);
    const end = Math.min(buf.length, offset + len);
    sftp.data(reqId, buf.subarray(offset, end));
  });

  sftp.on('WRITE', (reqId, handle, offset, data) => {
    const oh = handles.get(keyOf(handle));
    if (!oh) return sftp.status(reqId, SFTP.STATUS_CODE.FAILURE);
    const cur = files.get(oh.path) ?? Buffer.alloc(0);
    const required = offset + data.length;
    const next = required > cur.length ? Buffer.concat([cur, Buffer.alloc(required - cur.length)]) : Buffer.from(cur);
    data.copy(next, offset);
    files.set(oh.path, next);
    sftp.status(reqId, SFTP.STATUS_CODE.OK);
  });

  sftp.on('CLOSE', (reqId, handle) => {
    handles.delete(keyOf(handle));
    sftp.status(reqId, SFTP.STATUS_CODE.OK);
  });

  sftp.on('FSTAT', (reqId, handle) => {
    const oh = handles.get(keyOf(handle));
    if (!oh) return sftp.status(reqId, SFTP.STATUS_CODE.FAILURE);
    const buf = files.get(oh.path);
    if (!buf) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
    sftp.attrs(reqId, defaultAttributes(buf.length));
  });

  sftp.on('STAT', (reqId, p) => {
    const buf = files.get(p);
    if (!buf) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
    sftp.attrs(reqId, defaultAttributes(buf.length));
  });

  sftp.on('LSTAT', (reqId, p) => {
    const buf = files.get(p);
    if (!buf) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
    sftp.attrs(reqId, defaultAttributes(buf.length));
  });

  sftp.on('REALPATH', (reqId, p) => {
    const entries: FileEntry[] = [{
      filename: p,
      longname: p,
      attrs: defaultAttributes(files.get(p)?.length ?? 0),
    }];
    sftp.name(reqId, entries);
  });

  sftp.on('REMOVE', (reqId, p) => {
    const removed = files.delete(p);
    sftp.status(reqId, removed ? SFTP.STATUS_CODE.OK : SFTP.STATUS_CODE.NO_SUCH_FILE);
  });
}
