/**
 * S3-compatible backend (MinIO / AWS S3 / Backblaze B2 / Cloudflare R2 /
 * Wasabi / Tigris). Wraps the existing `minio` JS client so we keep one
 * dependency for every S3-shaped service.
 *
 * Configuration is sourced from `system_settings` rows (passed in by the
 * factory in `index.ts`). When system_settings are missing we fall back to
 * the legacy `MINIO_*` env vars, so an upgrade path doesn't require admins
 * to flip the new switches before the storage_backend admin UI lands.
 */

import { createHash } from 'node:crypto';
import { Readable } from 'node:stream';

import { Client } from 'minio';

import { env } from '@/lib/env';
import { NotFoundError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { decrypt } from '@/lib/utils/encryption';

import type { PresignOpts, PutOpts, StorageBackend } from './index';

interface S3BackendConfig {
  endpoint?: string;
  region?: string;
  bucket?: string;
  accessKey?: string;
  secretKeyEncrypted?: string;
  forcePathStyle?: boolean;
  /**
   * Server-side-encryption algorithm to request on every PutObject, e.g.
   * `'AES256'` (SSE-S3) or `'aws:kms'` (SSE-KMS). LEAVE UNSET for backends
   * that don't have KMS/KES configured — a plain MinIO rejects ANY
   * `x-amz-server-side-encryption` header with `NotImplemented` ("KMS not
   * configured"), which previously broke every server-side upload. Default
   * (undefined) = no header sent.
   */
  sse?: string;
}

/**
 * Build the metadata map passed to MinIO's `putObject`. The
 * server-side-encryption header is included ONLY when an algorithm is
 * configured — sending it against a backend without KMS/KES fails the
 * whole upload (`NotImplemented`). Pure + exported so the SSE policy is
 * unit-testable without a live S3.
 */
export function buildPutObjectMetadata(contentType: string, sse?: string): Record<string, string> {
  const meta: Record<string, string> = { 'Content-Type': contentType };
  if (sse) meta['x-amz-server-side-encryption'] = sse;
  return meta;
}

/**
 * Socket timeout wrapper. The `minio` JS client does not propagate
 * `fetchWithTimeout` semantics into `putObject` / `getObject` / `statObject`
 * (its underlying `node:http(s)` agent has no default request-timeout), so a
 * TCP-blackhole between the worker and the storage host can stall a job
 * indefinitely. We race every call against a deadline and fail loud - the
 * caller's retry/error path is far better than a stuck queue worker.
 *
 * The MinIO client doesn't accept an AbortSignal on these methods, so the
 * underlying request keeps running in the background after timeout. That's
 * acceptable here: the alternative is keeping the worker hung forever; the
 * underlying socket is closed by Node's keep-alive timeouts on the next
 * idle cycle.
 */
const STORAGE_DEFAULT_TIMEOUT_MS = 30_000;

/**
 * Named timeout error so `error-classifier.ts` `ERROR_NAME_HINTS` can
 * distinguish "the storage call timed out" from a generic storage
 * misconfiguration. The plain-Error form was dropping into the path-
 * based classifier and losing the actionable hint.
 */
export class StorageTimeoutError extends Error {
  readonly label: string;
  readonly timeoutMs: number;
  constructor(label: string, ms: number) {
    super(`S3 ${label} timed out after ${ms}ms`);
    this.name = 'TimeoutError';
    this.label = label;
    this.timeoutMs = ms;
  }
}

function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
  let timer: NodeJS.Timeout | null = null;
  const timeout = new Promise<T>((_, reject) => {
    timer = setTimeout(() => reject(new StorageTimeoutError(label, ms)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => {
    if (timer) clearTimeout(timer);
  });
}

interface ResolvedConfig {
  endpoint: string;
  port: number;
  useSSL: boolean;
  region: string;
  bucket: string;
  accessKey: string;
  secretKey: string;
  sse?: string;
}

function decryptIfPresent(stored: string | undefined): string | undefined {
  if (!stored) return undefined;
  try {
    return decrypt(stored);
  } catch (err) {
    logger.error({ err }, 'Failed to decrypt S3 secret key');
    return undefined;
  }
}

/**
 * Convert a possible URL ("https://s3.amazonaws.com:443") to a {host, port,
 * useSSL} triple suitable for the minio Client.
 */
function parseEndpoint(endpoint: string | undefined): {
  endPoint: string;
  port: number;
  useSSL: boolean;
} {
  // MINIO_* env vars are now optional (admin settings are canonical).
  // Fall back to localhost defaults so the parser still returns a complete
  // shape; the storage backend will fail-fast on the actual connect attempt
  // when both env + admin settings are unset.
  if (!endpoint) {
    return {
      endPoint: env.MINIO_ENDPOINT ?? 'localhost',
      port: env.MINIO_PORT ?? 9000,
      useSSL: env.MINIO_USE_SSL ?? false,
    };
  }
  try {
    const url = new URL(endpoint);
    const useSSL = url.protocol === 'https:';
    const port = url.port ? Number(url.port) : useSSL ? 443 : 80;
    return { endPoint: url.hostname, port, useSSL };
  } catch {
    // Not a URL - treat as bare hostname and use defaults.
    return {
      endPoint: endpoint,
      port: env.MINIO_PORT ?? 9000,
      useSSL: env.MINIO_USE_SSL ?? false,
    };
  }
}

function resolveConfig(cfg: S3BackendConfig): ResolvedConfig {
  const ep = parseEndpoint(cfg.endpoint);
  return {
    endpoint: ep.endPoint,
    port: ep.port,
    useSSL: ep.useSSL,
    region: cfg.region ?? 'us-east-1',
    bucket: cfg.bucket ?? env.MINIO_BUCKET ?? '',
    accessKey: cfg.accessKey ?? env.MINIO_ACCESS_KEY ?? '',
    secretKey: decryptIfPresent(cfg.secretKeyEncrypted) ?? env.MINIO_SECRET_KEY ?? '',
    // Default OFF: only request server-side encryption when explicitly set
    // (per-port `storage_s3_sse` setting → cfg.sse, or the STORAGE_S3_SSE env
    // fallback). A backend without KMS rejects the header outright.
    sse: cfg.sse ?? env.STORAGE_S3_SSE ?? undefined,
  };
}

export class S3Backend implements StorageBackend {
  readonly name = 's3' as const;

  private client: Client;
  private bucket: string;
  private sse?: string;

  private constructor(client: Client, bucket: string, sse?: string) {
    this.client = client;
    this.bucket = bucket;
    this.sse = sse;
  }

  static async create(cfg: S3BackendConfig): Promise<S3Backend> {
    const resolved = resolveConfig(cfg);
    const client = new Client({
      endPoint: resolved.endpoint,
      port: resolved.port,
      useSSL: resolved.useSSL,
      accessKey: resolved.accessKey,
      secretKey: resolved.secretKey,
      region: resolved.region,
    });
    // Verify the bucket exists at boot so a typo / missing-bucket admin
    // error surfaces with a clear message instead of as a vague Minio
    // error inside the first user-facing request that touches storage.
    // Logged-not-thrown when MINIO_AUTO_CREATE_BUCKET=true and the bucket
    // is missing - we'll create it. Otherwise we throw so the boot fails
    // fast and the deployment-time misconfig is loud.
    try {
      const exists = await withTimeout(
        client.bucketExists(resolved.bucket),
        STORAGE_DEFAULT_TIMEOUT_MS,
        'bucketExists',
      );
      if (!exists) {
        if (process.env.MINIO_AUTO_CREATE_BUCKET === 'true') {
          await withTimeout(
            client.makeBucket(resolved.bucket, resolved.region),
            STORAGE_DEFAULT_TIMEOUT_MS,
            'makeBucket',
          );
          logger.info(
            { bucket: resolved.bucket, endpoint: resolved.endpoint },
            'S3 bucket auto-created (MINIO_AUTO_CREATE_BUCKET=true)',
          );
        } else {
          throw new Error(
            `S3 bucket "${resolved.bucket}" does not exist on ${resolved.endpoint}. ` +
              `Create it manually or set MINIO_AUTO_CREATE_BUCKET=true.`,
          );
        }
      }
    } catch (err) {
      if (err instanceof Error && err.message.includes('does not exist')) throw err;
      // Connection / auth errors get re-thrown with extra context.
      logger.error(
        { err, bucket: resolved.bucket, endpoint: resolved.endpoint },
        'S3 bucket existence check failed at backend boot',
      );
      throw err;
    }
    return new S3Backend(client, resolved.bucket, resolved.sse);
  }

  async put(
    key: string,
    body: Buffer | NodeJS.ReadableStream,
    opts: PutOpts,
  ): Promise<{ key: string; sizeBytes: number; sha256: string }> {
    // We need both upload + a sha256 of the bytes. For a Buffer this is trivial;
    // for a stream we pipe through a hash transform. We buffer streams up to
    // a reasonable cap (memory pressure is acceptable here - typical files
    // are under 50MB and the alternative is a temp-file dance).
    const buffer = Buffer.isBuffer(body) ? body : await streamToBuffer(body);
    const sha256 = opts.sha256 ?? createHash('sha256').update(buffer).digest('hex');

    await withTimeout(
      // SSE header is sent ONLY when `this.sse` is configured (default off).
      // A MinIO/S3 backend without KMS/KES rejects any
      // `x-amz-server-side-encryption` header with `NotImplemented`, which
      // previously broke every server-side upload. At-rest encryption, when
      // wanted, is enabled deliberately via the `storage_s3_sse` setting once
      // the backend's KMS is configured. See active-uat 2026-06-03 CRITICAL.
      this.client.putObject(
        this.bucket,
        key,
        buffer,
        buffer.length,
        buildPutObjectMetadata(opts.contentType, this.sse),
      ),
      STORAGE_DEFAULT_TIMEOUT_MS,
      `putObject(${key})`,
    );

    return { key, sizeBytes: buffer.length, sha256 };
  }

  async get(key: string): Promise<NodeJS.ReadableStream> {
    try {
      return await withTimeout(
        this.client.getObject(this.bucket, key),
        STORAGE_DEFAULT_TIMEOUT_MS,
        `getObject(${key})`,
      );
    } catch (err) {
      const code = (err as { code?: string } | null)?.code;
      if (code === 'NoSuchKey' || code === 'NotFound') {
        throw new NotFoundError(`Storage object ${key}`);
      }
      throw err;
    }
  }

  async head(key: string): Promise<{ sizeBytes: number; contentType: string } | null> {
    try {
      const stat = await withTimeout(
        this.client.statObject(this.bucket, key),
        STORAGE_DEFAULT_TIMEOUT_MS,
        `statObject(${key})`,
      );
      const meta = (stat.metaData ?? {}) as Record<string, string>;
      const contentType =
        meta['content-type'] ?? meta['Content-Type'] ?? 'application/octet-stream';
      return { sizeBytes: stat.size, contentType };
    } catch (err) {
      const code = (err as { code?: string } | null)?.code;
      if (code === 'NotFound' || code === 'NoSuchKey') return null;
      throw err;
    }
  }

  async delete(key: string): Promise<void> {
    try {
      await withTimeout(
        this.client.removeObject(this.bucket, key),
        STORAGE_DEFAULT_TIMEOUT_MS,
        `removeObject(${key})`,
      );
    } catch (err) {
      const code = (err as { code?: string } | null)?.code;
      if (code === 'NotFound' || code === 'NoSuchKey') return;
      throw err;
    }
  }

  /**
   * Mint a presigned PUT URL for a direct browser upload.
   *
   * IMPORTANT (audit M16): `presignedPutObject` signs ONLY the bucket+key+
   * expiry. It does NOT constrain the request's `Content-Type` or
   * `Content-Length`, so a holder of this URL can PUT any bytes, of any
   * type, up to the storage provider's own object-size ceiling for the
   * 15-minute window. The `opts.contentType` / `opts.maxBytes` hints are
   * advisory only on this path.
   *
   * Every consumer of an S3 presigned upload MUST re-validate the object
   * server-side after the browser PUT (HEAD for size + magic-byte probe)
   * and delete it on mismatch - the berth-PDF (`uploadBerthPdf`) and
   * brochure (`registerBrochureVersion`) register endpoints already do
   * this. Do NOT add a new presigned-upload consumer that trusts the
   * uploaded bytes without that re-check.
   *
   * A stricter alternative is `presignedPostPolicy`, which DOES sign a
   * `content-length-range` + `Content-Type` condition; it's deferred here
   * because it changes the upload from a PUT to a multipart POST and every
   * current caller is wired for PUT.
   */
  async presignUpload(
    key: string,
    opts: PresignOpts,
  ): Promise<{ url: string; method: 'PUT' | 'POST' }> {
    const expiry = opts.expirySeconds ?? 900;
    const url = await this.client.presignedPutObject(this.bucket, key, expiry);
    return { url, method: 'PUT' };
  }

  async presignDownload(key: string, opts: PresignOpts): Promise<{ url: string; expiresAt: Date }> {
    const expiry = opts.expirySeconds ?? 900;
    // Pass response-header overrides to minio-js's reqParams so the
    // browser sees the original filename / content-type instead of the
    // storage-key UUID. Without this every signed download lands with
    // a bare UUID and no extension. Filenames are escaped per RFC 5987
    // so a name like "Étude.pdf" survives the round-trip.
    const reqParams: Record<string, string> = {};
    if (opts.filename) {
      const ascii = opts.filename.replace(/[^\x20-\x7e]/g, '_');
      const encoded = encodeURIComponent(opts.filename);
      reqParams['response-content-disposition'] =
        `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
    }
    if (opts.contentType) {
      reqParams['response-content-type'] = opts.contentType;
    }
    const url = await this.client.presignedGetObject(
      this.bucket,
      key,
      expiry,
      Object.keys(reqParams).length > 0 ? reqParams : undefined,
    );
    return { url, expiresAt: new Date(Date.now() + expiry * 1000) };
  }

  /**
   * Recursive listObjectsV2 walk under `prefix`. The minio-js stream emits
   * one entry per object; we drain it into a flat key array sorted
   * alphabetically. Used by one-shot importers; not a hot path. Object
   * "directories" (zero-byte placeholders ending in `/`) are filtered out.
   */
  async listByPrefix(prefix: string): Promise<string[]> {
    const stream = this.client.listObjectsV2(this.bucket, prefix, true);
    const keys: string[] = [];
    for await (const obj of stream as AsyncIterable<{ name?: string }>) {
      if (obj.name && !obj.name.endsWith('/')) keys.push(obj.name);
    }
    keys.sort();
    return keys;
  }

  /** Used by the admin UI's "Test connection" button. */
  async healthCheck(): Promise<{ ok: true } | { ok: false; error: string }> {
    const sentinelKey = `_health/${Date.now()}.txt`;
    const payload = Buffer.from('ok', 'utf8');
    try {
      await withTimeout(
        this.client.putObject(this.bucket, sentinelKey, payload, payload.length, {
          'Content-Type': 'text/plain',
        }),
        STORAGE_DEFAULT_TIMEOUT_MS,
        `healthCheck:put(${sentinelKey})`,
      );
      const stat = await withTimeout(
        this.client.statObject(this.bucket, sentinelKey),
        STORAGE_DEFAULT_TIMEOUT_MS,
        `healthCheck:stat(${sentinelKey})`,
      );
      if (stat.size !== payload.length) {
        return { ok: false, error: 'sentinel size mismatch' };
      }
      await withTimeout(
        this.client.removeObject(this.bucket, sentinelKey),
        STORAGE_DEFAULT_TIMEOUT_MS,
        `healthCheck:remove(${sentinelKey})`,
      );
      return { ok: true };
    } catch (err) {
      return { ok: false, error: (err as Error).message };
    }
  }
}

async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of stream as Readable) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
  }
  return Buffer.concat(chunks);
}
