import { Client } from "ssh2";
import { createLogger } from "../lib/logger";

const log = createLogger("SSH");

export interface SshSession {
  exec(command: string): Promise<{ stdout: string; stderr: string; code: number }>;
  // Run a long-lived command (e.g. `tail -f`) and receive its stdout/stderr as
  // it arrives. Unlike exec(), this never buffers or times out — the channel
  // stays open until the returned handle's close() is called (or the connection
  // ends). Used to stream live app logs off a running instance.
  execStream(command: string, onData: (chunk: string) => void): { close(): void };
  close(): void;
  aborted: boolean;
  abort(): void;
}

export async function connectSsh(host: string, privateKey: string, maxWaitMs = 300_000): Promise<SshSession> {
  const start = Date.now();

  while (Date.now() - start < maxWaitMs) {
    try {
      const conn = await tryConnect(host, privateKey, 10000);
      let aborted = false;

      return {
        get aborted() { return aborted; },
        abort() {
          aborted = true;
          try { conn.end(); } catch {}
        },
        async exec(command: string) {
          if (aborted) throw new Error("SSH session aborted");
          return execOnConn(conn, command);
        },
        execStream(command: string, onData: (chunk: string) => void) {
          let stream: any = null;
          let closed = false;
          conn.exec(command, (err, s) => {
            if (err) {
              if (!closed) onData(`[app-logs] stream error: ${err.message}\n`);
              return;
            }
            if (closed) { try { s.close(); } catch {} return; }
            stream = s;
            s.on("data", (d: Buffer) => onData(d.toString()));
            s.stderr.on("data", (d: Buffer) => onData(d.toString()));
          });
          return {
            close() {
              closed = true;
              try { stream?.close(); } catch {}
            },
          };
        },
        close() {
          try { conn.end(); } catch {}
        },
      };
    } catch (e: any) {
      if (Date.now() - start > maxWaitMs) throw e;
      log.debug({ host, error: e.message }, "SSH connect retry");
      await sleep(5000);
    }
  }
  throw new Error(`Could not SSH into ${host} within timeout`);
}

function tryConnect(host: string, privateKey: string, timeoutMs: number): Promise<Client> {
  return new Promise((resolve, reject) => {
    const conn = new Client();
    const timer = setTimeout(() => {
      conn.end();
      reject(new Error(`SSH connection to ${host} timed out`));
    }, timeoutMs);

    conn.on("ready", () => {
      clearTimeout(timer);
      resolve(conn);
    });
    conn.on("error", (e) => {
      clearTimeout(timer);
      reject(e);
    });
    conn.connect({
      host,
      port: 22,
      username: "ubuntu",
      privateKey,
      readyTimeout: timeoutMs,
      keepaliveInterval: 10000,
      keepaliveCountMax: 3,
      algorithms: {
        serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ssh-ed25519"],
      },
    });
  });
}

const EXEC_TIMEOUT_MS = 30_000;

function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error(`SSH exec timed out: ${command.slice(0, 60)}`)), EXEC_TIMEOUT_MS);

    conn.exec(command, (err, stream) => {
      if (err) {
        clearTimeout(timer);
        return reject(err);
      }
      let stdout = "";
      let stderr = "";
      stream.on("data", (d: Buffer) => { stdout += d.toString(); });
      stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
      stream.on("close", (code: number) => {
        clearTimeout(timer);
        resolve({ stdout, stderr, code: code ?? 0 });
      });
    });
  });
}

// The `ubuntu` user accepts SSH almost immediately at boot — long before the
// cloud-init UserData bootstrap has finished disabling apt-daily and installing
// the small base dependency set PP needs before SSH-driven setup begins.
// Proceeding early can still cause apt-lock failures in the deploy steps. Block until the bootstrap writes its
// done marker; bail out fast (with the tail of its log) if it writes the failure
// marker instead. Uses short one-shot execs so a stale channel just retries.
export async function waitForBootstrap(session: SshSession, maxWaitMs = 600_000): Promise<void> {
  const start = Date.now();
  while (Date.now() - start < maxWaitMs) {
    if (session.aborted) throw new Error("SSH session aborted");
    try {
      const res = await session.exec(
        "if [ -f /var/lib/pp-bootstrap-failed ]; then echo failed; " +
        "elif [ -f /var/lib/pp-bootstrap-done ]; then echo done; else echo waiting; fi"
      );
      const state = res.stdout.trim();
      if (state === "done") return;
      if (state === "failed") {
        let tail = "";
        try {
          const logRes = await session.exec("tail -n 30 /var/log/pp-bootstrap.log 2>/dev/null || true");
          tail = logRes.stdout.trim();
        } catch {
          // best effort — the failure marker alone is enough to abort
        }
        throw new Error(`EC2 bootstrap failed${tail ? `:\n${tail}` : ""}`);
      }
    } catch (e: any) {
      // A bootstrap failure we detected above must propagate; only swallow
      // transient SSH exec errors (timeout, channel reset) and retry.
      if (e?.message?.startsWith("EC2 bootstrap failed")) throw e;
    }
    await sleep(10_000);
  }
  throw new Error("EC2 bootstrap did not complete within timeout");
}

function sleep(ms: number) {
  return new Promise(r => setTimeout(r, ms));
}
