/**
 * Single source of truth for process.env, validated with zod at module load.
 *
 * Pattern mirrors messagepal-backend's src/config/index.js loadConfig(): collect
 * every missing/invalid key and fail the boot ONCE with a readable list, rather
 * than letting an `undefined` surface later inside a JWT signature or a DB DSN.
 *
 * PLANNING.md §3a lists the full env surface. For Step 1 only the vars the
 * foundation needs to boot are `.required` here; everything a later step owns is
 * `.optional()` with a TODO naming the step that must tighten it.
 */
import 'dotenv/config';
import { z } from 'zod';

/**
 * Wrap an optional string rule so a present-but-empty value (`FOO=` in .env,
 * which is how .env.example ships blanks) is treated as absent rather than
 * failing a `.min()` check. Use for optional vars that carry length rules.
 */
const optional = <T extends z.ZodType>(schema: T) =>
  z.preprocess((v) => (v === '' ? undefined : v), schema.optional());

/** Comma-separated origin list -> trimmed, non-empty string[]. */
const originList = z
  .string()
  .transform((raw) =>
    raw
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean),
  )
  .pipe(z.array(z.string().url()).min(1));

const envSchema = z.object({
  // ─── Runtime ─────────────────────────────────────────────────────────────
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  // Passenger sets PORT in production; a local default keeps `npm run dev` simple.
  // Never hardcode this in app code — always read env.PORT (PLANNING.md §3a).
  PORT: z.coerce.number().int().positive().max(65535).default(3000),
  // Populated by the build/deploy step; surfaced on /health.
  GIT_SHA: z.string().optional(),

  // ─── Database (Step 1 — required) ────────────────────────────────────────
  DB_HOST: z.string().min(1),
  DB_PORT: z.coerce.number().int().positive().max(65535).default(3306),
  DB_USER: z.string().min(1),
  DB_PASSWORD: z.string().default(''),
  DB_NAME: z.string().min(1),
  // Shared cPanel hosting caps concurrent connections; 10 (knex default) is often
  // too high. PLANNING.md §3a / §12.
  DB_POOL_MAX: z.coerce.number().int().positive().max(20).default(5),

  // ─── CORS (Step 1 — required) ────────────────────────────────────────────
  // Two policies, never merged (PLANNING.md §10). Public is credential-less;
  // admin is credentialed for the refresh cookie.
  PUBLIC_ORIGINS: originList,
  ADMIN_ORIGINS: originList,

  // ─── Media / asset serving (PLANNING.md §3a, §7) ───────────────────────
  // Every absolute URL in the gallery response is built from this (PLANNING.md §1).
  ASSET_BASE_URL: z.string().url(),
  // Where sharp writes the AVIF/WebP/JPEG derivatives. Apache serves this
  // directory directly in production; in dev the app static-serves it at /media.
  MEDIA_ROOT: z.string().min(1),
  // Uploaded originals + multer's temp dir. Outside the web root.
  STORAGE_ROOT: z.string().min(1),

  // ─── Auth (PLANNING.md §8) ─────────────────────────────────────────────
  // Distinct 32+ char random values. JWT_ACCESS_SECRET signs the short access
  // JWT; JWT_REFRESH_SECRET is the pepper mixed into every stored refresh- and
  // reset-token hash (see src/lib/hash.ts hashToken).
  JWT_ACCESS_SECRET: z.string().min(32),
  JWT_REFRESH_SECRET: z.string().min(32),
  ACCESS_TOKEN_TTL: z.string().default('15m'),
  REFRESH_TOKEN_TTL: z.string().default('30d'),
  // Base for password-reset links emailed to admins.
  ADMIN_PORTAL_URL: z.string().url(),

  // ─── Email ──────────────────────────────────────────────────────────────
  // TODO(step 8): make SMTP_* + ENQUIRY_NOTIFY_TO + MAIL_FROM required for prod.
  // Until then the mailer degrades to a logged no-op when SMTP_HOST is unset,
  // so local dev needs no mail server.
  SMTP_HOST: z.string().optional(),
  SMTP_PORT: z.coerce.number().int().positive().max(65535).optional(),
  SMTP_USER: z.string().optional(),
  SMTP_PASSWORD: z.string().optional(),
  ENQUIRY_NOTIFY_TO: z.string().optional(),
  MAIL_FROM: z.string().optional(),

  // ─── Spam / abuse ───────────────────────────────────────────────────────
  // Absent -> Turnstile verification is skipped, not failed (PLANNING.md §9).
  TURNSTILE_SECRET: z.string().optional(),
  // Pseudonymises stored IPs under PDPA — sha256(ip + salt) (PLANNING.md §9).
  IP_HASH_SALT: z.string().min(16),

  // ─── Bootstrap admin — consumed only by `npm run seed`
  //     (seeds/02_bootstrap_admin.ts). Step 4 owns real auth; there is no
  //     hardcoded password anywhere, so the first superadmin comes from here.
  BOOTSTRAP_ADMIN_NAME: optional(z.string().min(1)),
  BOOTSTRAP_ADMIN_PHONE: optional(z.string().min(1)), // E.164, e.g. +60123456789 — phone.ts normalisation is step 4
  BOOTSTRAP_ADMIN_EMAIL: optional(z.string().email()),
  BOOTSTRAP_ADMIN_PASSWORD: optional(z.string().min(8)),
});

/**
 * Step 8 hardening: mail delivery is best-effort in dev (the mailer degrades to a
 * logged no-op) but must be wired in production — an enquiry notification or a
 * password-reset link silently going nowhere on the live site is a real outage.
 * Enforced only when NODE_ENV=production so local/CI stay frictionless.
 */
const envSchemaChecked = envSchema.superRefine((v, ctx) => {
  if (v.NODE_ENV !== 'production') return;
  for (const key of ['SMTP_HOST', 'ENQUIRY_NOTIFY_TO', 'MAIL_FROM'] as const) {
    if (!v[key]) {
      ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: 'required in production' });
    }
  }
});

export type Env = z.infer<typeof envSchema>;

const parsed = envSchemaChecked.safeParse(process.env);

if (!parsed.success) {
  const issues = parsed.error.issues
    .map((i) => `  - ${i.path.join('.') || '(root)'}: ${i.message}`)
    .join('\n');
  // Plain console — the logger depends on nothing, but this runs before anything
  // is wired and must be legible in a Passenger start log.
  console.error(`\nInvalid environment configuration. Fix .env and restart:\n${issues}\n`);
  process.exit(1);
}

export const env: Readonly<Env> = Object.freeze(parsed.data);
