import { spawn, type ChildProcess } from "node:child_process";
import { createServer } from "node:net";
import {
  existsSync,
  mkdtempSync,
  readdirSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { SMTPServer } from "smtp-server";

export const REPO_ROOT = resolve(__dirname, "..", "..");
export const STANDALONE_DIR = join(REPO_ROOT, ".next", "standalone");

export const SMTP_TEST_USER = "harness@clearfin.test";
export const SMTP_TEST_PASS = "harness-password";

/** Ask the OS for a port nothing is listening on. */
export async function freePort(): Promise<number> {
  return new Promise((res, rej) => {
    const probe = createServer();
    probe.once("error", rej);
    probe.listen(0, "127.0.0.1", () => {
      const { port } = probe.address() as { port: number };
      probe.close(() => res(port));
    });
  });
}

export type CapturedMail = {
  raw: string;
  from: string;
  to: string[];
  /** Header value, or "" when absent. Case-insensitive lookup. */
  header(name: string): string;
  /** Whole message with quoted-printable soft breaks and escapes decoded. */
  decoded(): string;
  /** Decoded body of the `text/html` MIME part. */
  html(): string;
  /** Decoded body of the `text/plain` MIME part. */
  text(): string;
};

export type FakeSmtp = {
  port: number;
  messages: CapturedMail[];
  /** Reject every AUTH attempt, to exercise the credentials-rejected path. */
  rejectAuth: boolean;
  close(): Promise<void>;
};

function decodeQuotedPrintable(input: string): string {
  return input
    .replace(/=\r?\n/g, "")
    .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
}

/** Pull one MIME part out of a multipart message and decode its body. */
function mimePart(raw: string, contentType: string): string {
  const boundary = raw.match(/boundary="?([^";\r\n]+)"?/i)?.[1];
  if (!boundary) return decodeQuotedPrintable(raw);

  const part = raw
    .split(`--${boundary}`)
    .find((candidate) => new RegExp(`content-type:\\s*${contentType}`, "i").test(candidate));
  if (!part) return "";

  const separator = part.search(/\r?\n\r?\n/);
  if (separator === -1) return "";
  const headers = part.slice(0, separator);
  const body = part.slice(separator).replace(/^\r?\n\r?\n/, "");

  return /quoted-printable/i.test(headers) ? decodeQuotedPrintable(body) : body;
}

function captureMail(raw: string, from: string, to: string[]): CapturedMail {
  return {
    raw,
    from,
    to,
    header(name) {
      // Unfold continuation lines before matching, so long subjects still match.
      const unfolded = raw.split(/\r?\n\r?\n/)[0].replace(/\r?\n[ \t]+/g, " ");
      const line = unfolded
        .split(/\r?\n/)
        .find((l) => l.toLowerCase().startsWith(`${name.toLowerCase()}:`));
      return line ? line.slice(line.indexOf(":") + 1).trim() : "";
    },
    decoded() {
      return decodeQuotedPrintable(raw);
    },
    html() {
      return mimePart(raw, "text/html");
    },
    text() {
      return mimePart(raw, "text/plain");
    },
  };
}

/** An in-process SMTP server that accepts mail and remembers it. */
export async function startFakeSmtp(): Promise<FakeSmtp> {
  const port = await freePort();
  const messages: CapturedMail[] = [];
  const state = { rejectAuth: false };

  const server = new SMTPServer({
    // Plain SMTP on loopback: the code under test connects with secure=false.
    secure: false,
    disabledCommands: ["STARTTLS"],
    authOptional: false,
    onAuth(auth, _session, callback) {
      if (state.rejectAuth || auth.username !== SMTP_TEST_USER || auth.password !== SMTP_TEST_PASS) {
        return callback(new Error("Invalid login"));
      }
      callback(null, { user: auth.username });
    },
    onData(stream, session, callback) {
      const chunks: Buffer[] = [];
      stream.on("data", (chunk: Buffer) => chunks.push(chunk));
      stream.on("end", () => {
        messages.push(
          captureMail(
            Buffer.concat(chunks).toString("utf8"),
            session.envelope.mailFrom ? session.envelope.mailFrom.address : "",
            session.envelope.rcptTo.map((r) => r.address),
          ),
        );
        callback();
      });
    },
  });

  await new Promise<void>((res, rej) => {
    server.once("error", rej);
    server.listen(port, "127.0.0.1", () => res());
  });

  return {
    port,
    messages,
    get rejectAuth() {
      return state.rejectAuth;
    },
    set rejectAuth(value: boolean) {
      state.rejectAuth = value;
    },
    close: () => new Promise<void>((res) => server.close(() => res())),
  };
}

export type FakeSendmail = {
  /** Path to hand to the app as SENDMAIL_PATH. */
  path: string;
  /** Every invocation: the argv it was called with and the message it was fed. */
  invocations(): { args: string[]; message: CapturedMail }[];
  reset(): void;
  cleanup(): void;
};

/**
 * A stand-in for msmtp: a script that records its arguments and stdin.
 *
 * The servers deliver through the local relay rather than talking to Titan, so
 * the pipe is what the suite has to exercise — including the `-i -f <from>`
 * argv nodemailer builds, which is where a mismatch with msmtp would show up.
 */
export function startFakeSendmail(): FakeSendmail {
  const dir = mkdtempSync(join(tmpdir(), "clearfin-sendmail-"));
  const binary = join(dir, "sendmail");

  writeFileSync(
    binary,
    [
      "#!/bin/sh",
      // Argv and message land in separate files: a mail body legitimately
      // contains blank lines, so no in-band separator is trustworthy.
      `printf '%s\\n' "$*" > "${dir}/$$.args"`,
      `cat > "${dir}/$$.msg"`,
      "exit 0",
    ].join("\n") + "\n",
    { mode: 0o755 },
  );

  const read = () =>
    readdirSync(dir)
      .filter((name) => name.endsWith(".args"))
      .sort()
      .map((name) => {
        const stem = name.slice(0, -".args".length);
        const messageFile = join(dir, `${stem}.msg`);
        return {
          args: readFileSync(join(dir, name), "utf8").trim().split(/\s+/).filter(Boolean),
          message: captureMail(
            existsSync(messageFile) ? readFileSync(messageFile, "utf8") : "",
            "",
            [],
          ),
        };
      });

  return {
    path: binary,
    invocations: read,
    reset() {
      for (const name of readdirSync(dir)) {
        if (name.endsWith(".args") || name.endsWith(".msg")) rmSync(join(dir, name));
      }
    },
    cleanup() {
      rmSync(dir, { recursive: true, force: true });
    },
  };
}

/** Settings pointing the app at a fake sendmail binary. */
export function sendmailEnvFor(sendmail: FakeSendmail): Record<string, string> {
  return {
    MAIL_TRANSPORT: "sendmail",
    SENDMAIL_PATH: sendmail.path,
    SMTP_FROM: "website@clearfin.test",
    CONTACT_TO: "inbox@clearfin.test",
  };
}

/** SMTP settings pointing the app at a fake server on `port`. */
export function smtpEnvFor(port: number): Record<string, string> {
  return {
    SMTP_HOST: "127.0.0.1",
    SMTP_PORT: String(port),
    SMTP_SECURE: "false",
    SMTP_USER: SMTP_TEST_USER,
    SMTP_PASS: SMTP_TEST_PASS,
    SMTP_FROM: "website@clearfin.test",
    CONTACT_TO: "inbox@clearfin.test",
  };
}

export type RunningApp = {
  baseUrl: string;
  /** Everything the server wrote to stdout/stderr so far. */
  output(): string;
  post(path: string, body: unknown): Promise<{ status: number; json: Record<string, unknown> }>;
  get(path: string): Promise<{ status: number; json: Record<string, unknown> }>;
  close(): Promise<void>;
};

export type StartAppOptions = {
  /** Extra environment for the server process. The base env is deliberately bare. */
  env?: Record<string, string>;
  /** Written to `.next/standalone/.env.local` before start, restored afterwards. */
  envFile?: Record<string, string> | null;
  basePath?: string;
};

/**
 * Boot the real `.next/standalone` bundle the way systemd does on the VPS.
 *
 * The environment handed to the child is built from scratch rather than
 * inherited, so a stray SMTP_* in the developer's shell cannot mask a config
 * problem the deployed server would hit.
 */
export async function startApp(options: StartAppOptions = {}): Promise<RunningApp> {
  const { env = {}, envFile, basePath = "" } = options;

  if (!existsSync(join(STANDALONE_DIR, "server.js"))) {
    throw new Error(
      `No standalone build at ${STANDALONE_DIR}. Run \`npm run build:standalone\` first.`,
    );
  }

  const envFilePath = join(STANDALONE_DIR, ".env.local");
  const hadEnvFile = existsSync(envFilePath);
  const previousEnvFile = hadEnvFile ? readFileSync(envFilePath, "utf8") : null;

  if (envFile !== undefined) {
    if (envFile === null) {
      if (hadEnvFile) rmSync(envFilePath);
    } else {
      writeFileSync(
        envFilePath,
        Object.entries(envFile)
          .map(([k, v]) => `${k}=${v}`)
          .join("\n") + "\n",
      );
    }
  }

  const restoreEnvFile = () => {
    if (envFile === undefined) return;
    if (previousEnvFile === null) {
      if (existsSync(envFilePath)) rmSync(envFilePath);
    } else {
      writeFileSync(envFilePath, previousEnvFile);
    }
  };

  const port = await freePort();
  const child: ChildProcess = spawn(process.execPath, [join(STANDALONE_DIR, "server.js")], {
    cwd: REPO_ROOT,
    env: {
      PATH: process.env.PATH ?? "",
      PORT: String(port),
      HOSTNAME: "127.0.0.1",
      // server.js sets this itself; declared here to match the systemd unit.
      NODE_ENV: "production",
      ...env,
    } as NodeJS.ProcessEnv,
    stdio: ["ignore", "pipe", "pipe"],
  });

  let output = "";
  child.stdout?.on("data", (c: Buffer) => (output += c.toString()));
  child.stderr?.on("data", (c: Buffer) => (output += c.toString()));

  const baseUrl = `http://127.0.0.1:${port}${basePath}`;
  const exited = new Promise<never>((_, rej) =>
    child.once("exit", (code) => rej(new Error(`Server exited early (code ${code}):\n${output}`))),
  );

  const ready = (async () => {
    const deadline = Date.now() + 30_000;
    while (Date.now() < deadline) {
      try {
        const res = await fetch(`${baseUrl}/contact`);
        if (res.status < 500) return;
      } catch {
        /* not listening yet */
      }
      await new Promise((r) => setTimeout(r, 200));
    }
    throw new Error(`Server did not become ready within 30s:\n${output}`);
  })();

  try {
    await Promise.race([ready, exited]);
  } catch (err) {
    child.kill("SIGKILL");
    restoreEnvFile();
    throw err;
  }

  const request = async (path: string, init?: RequestInit) => {
    const res = await fetch(`${baseUrl}${path}`, init);
    let json: Record<string, unknown> = {};
    try {
      json = (await res.json()) as Record<string, unknown>;
    } catch {
      /* non-JSON body */
    }
    return { status: res.status, json };
  };

  return {
    baseUrl,
    output: () => output,
    post: (path, body) =>
      request(path, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: typeof body === "string" ? body : JSON.stringify(body),
      }),
    get: (path) => request(path),
    close: async () => {
      restoreEnvFile();
      if (child.exitCode === null) {
        const dead = new Promise<void>((res) => child.once("exit", () => res()));
        child.kill("SIGKILL");
        await dead;
      }
    },
  };
}

/** A payload the validator accepts, with per-test overrides. */
export function validSubmission(overrides: Record<string, unknown> = {}) {
  return {
    name: "Test Runner",
    email: "runner@clearfin.test",
    phone: "+41 00 000 00 00",
    company: "Test Corp",
    subject: "General Inquiry",
    message: "Automated test - please ignore.",
    ...overrides,
  };
}
