import { readFileSync } from "node:fs";
import { join } from "node:path";
import { z } from "zod";

/**
 * Every environment variable this repo reads, checked once, at boot.
 *
 * Nothing here runs at import. `next build` evaluates route modules on a machine
 * that has no secrets and must keep building, so validation happens when a
 * process that will actually serve traffic asks for it: `instrumentation.ts` for
 * the dashboard, the first line of the worker.
 *
 * The point is not only to catch what is missing. It is to say, out loud and at
 * startup, which database we are about to talk to — because a variable exported
 * in a shell silently outranks every `.env` file, in Next and in bun alike, and
 * a wrong one costs an afternoon of reading the innocent file.
 */

/** An empty variable is an unset one: `.min(1)` and `Number("")` both disagree. */
const blank = (value: unknown) => (value === "" ? undefined : value);

/**
 * `Number("trois")` is NaN, and NaN compares false against everything — a
 * mistyped ceiling would not raise a limit, it would remove it. Refuse instead.
 */
const count = (fallback: number) =>
  z.preprocess(blank, z.coerce.number().int().positive().default(fallback));

const optional = (inner: z.ZodTypeAny) => z.preprocess(blank, inner.optional());

const schema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),

  DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "must be a postgres:// URL"),
  PGPOOL_MAX: count(10),

  BETTER_AUTH_SECRET: optional(z.string().min(32, "32 chars — `openssl rand -base64 32`")),
  BETTER_AUTH_URL: optional(z.url()),
  RESEND_API_KEY: optional(z.string().startsWith("re_")),
  RESEND_FROM: optional(z.string()),
  TURNSTILE_SECRET_KEY: optional(z.string()),
  NEXT_PUBLIC_TURNSTILE_SITE_KEY: optional(z.string()),
  AUDIT_JOBS_PER_HOUR: count(3),
  ALLOWED_ORIGINS: optional(
    z.string().refine(
      (list) => list.split(",").every((entry) => isExactOrigin(entry.trim())),
      "comma-separated exact origins, scheme included, no trailing slash",
    ),
  ),

  HSC_BIN: z.preprocess(blank, z.string().default("nextjs-seo-prerender")),
});

function isExactOrigin(value: string): boolean {
  try {
    return new URL(value).origin === value;
  } catch {
    return false;
  }
}

export type Env = z.infer<typeof schema>;
export type Role = "dashboard" | "worker";

/** Without these, that role cannot serve a single request. */
const NEEDED: Record<Role, ReadonlyArray<keyof Env>> = {
  dashboard: ["BETTER_AUTH_SECRET", "TURNSTILE_SECRET_KEY"],
  worker: [],
};

/**
 * These have defaults that work on a laptop and lie in production: magic links
 * to localhost, Resend's sandbox sender which delivers to nobody, an empty site
 * key. In production a default is the bug, so refuse to start on one.
 */
const NEEDED_IN_PRODUCTION: Record<Role, ReadonlyArray<keyof Env>> = {
  dashboard: ["BETTER_AUTH_URL", "RESEND_API_KEY", "RESEND_FROM", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"],
  worker: [],
};

/** Host, port and database. A boot line must never print a password. */
function where(url: string): string {
  try {
    const parsed = new URL(url);
    return `${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}${parsed.pathname}`;
  } catch {
    return "(unparseable)";
  }
}

/** The check itself, over any set of variables. Pure, so it can be tested. */
export function checkEnv(source: Record<string, string | undefined>, role: Role): Env {
  const parsed = schema.safeParse(source);
  if (!parsed.success) {
    const problems = parsed.error.issues.map((issue) => `  ${issue.path.join(".")}: ${issue.message}`);
    throw new Error(`environment is invalid — see .env.example\n${problems.join("\n")}`);
  }
  const env = parsed.data;

  const missing = [
    ...NEEDED[role],
    ...(env.NODE_ENV === "production" ? NEEDED_IN_PRODUCTION[role] : []),
  ].filter((key) => env[key] === undefined);
  if (missing.length > 0) {
    throw new Error(
      `environment is incomplete for the ${role} — see .env.example\n` +
        missing.map((key) => `  ${key} is not set`).join("\n"),
    );
  }

  return env;
}

/**
 * What the project's `.env` files say about one variable, and which one said it.
 *
 * Next and bun both load these files, and both leave a variable already present
 * in the environment alone — so a `DATABASE_URL` exported in the terminal that
 * launched the dev server wins over the file, silently, and the error it causes
 * points at the file. Reading them back is the only way to say so.
 *
 * Development only, and one variable: this exists to name a specific afternoon,
 * not to reimplement dotenv.
 */
function fromDotenv(name: string): { file: string; value: string } | null {
  // Next's development order. bun skips the `.local` variants of the middle
  // two, which is one more reason for them not to exist here.
  for (const file of [".env.development.local", ".env.local", ".env.development", ".env"]) {
    let text: string;
    try {
      text = readFileSync(join(process.cwd(), file), "utf8");
    } catch {
      continue;
    }
    for (const line of text.split("\n")) {
      const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
      if (match?.[1] !== name) continue;
      const raw = match[2] ?? "";
      const quoted =
        raw.length >= 2 &&
        ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")));
      return { file, value: quoted ? raw.slice(1, -1) : raw };
    }
  }
  return null;
}

/**
 * Which variables may have their two values printed side by side.
 *
 * The rest are secrets, and a mismatch is reported without either value: what
 * the reader needs is the name and the fix, not the key.
 */
const SHOWABLE = new Set<string>([
  "DATABASE_URL",
  "BETTER_AUTH_URL",
  "ALLOWED_ORIGINS",
  "AUDIT_JOBS_PER_HOUR",
  "NEXT_PUBLIC_TURNSTILE_SITE_KEY",
  "HSC_BIN",
  "PGPOOL_MAX",
]);

function warnIfShellWins(names: readonly string[]): void {
  for (const name of names) {
    const file = fromDotenv(name);
    const live = process.env[name];
    if (!file || live === undefined || file.value === live) continue;

    const detail = SHOWABLE.has(name)
      ? `\n  shell: ${redact(name, live)}\n  ${file.file}: ${redact(name, file.value)}`
      : "";
    console.error(
      `env: ${name} comes from the shell, not from ${file.file}.${detail}\n` +
        `  fix: \`set -e ${name}\` in that shell, or export the value you meant.`,
    );
  }
}

/** A database URL carries a password; nothing else here does. */
function redact(name: string, value: string): string {
  return name === "DATABASE_URL" ? where(value) : value;
}

let checked: Env | null = null;

/** The same check, over this process, once, with the boot line. */
export function assertEnv(role: Role): Env {
  if (checked) return checked;
  const env = checkEnv(process.env, role);

  if (env.NODE_ENV !== "production") {
    warnIfShellWins(Object.keys(schema.shape));
    for (const key of NEEDED_IN_PRODUCTION[role]) {
      if (env[key] === undefined) console.warn(`env: ${key} is unset — fine here, fatal in production`);
    }
  }

  // The line that ends the argument about which file won.
  console.log(`env: ok (${role}) — database ${where(env.DATABASE_URL)}`);
  return (checked = env);
}
