/**
 * Direct PostgreSQL escape hatch for setting up states the public API doesn't
 * expose — e.g. forcing a user into the locked-PIN state to assert the 429
 * recovery path, or expiring sessions for chaos tests.
 *
 * Most tests should NOT use this: prefer `ApiClient` so the tests exercise
 * the same code paths real users do. Reach for direct SQL only when the API
 * can't get you where you need to go.
 */
import { Client } from 'pg';

const CONN = {
  host: process.env.E2E_DB_HOST ?? 'localhost',
  port: Number(process.env.E2E_DB_PORT ?? '55432'),
  user: process.env.E2E_DB_USER ?? 'eventsnap_test',
  password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
  database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
};

async function withClient<T>(fn: (c: Client) => Promise<T>): Promise<T> {
  const client = new Client(CONN);
  await client.connect();
  try {
    return await fn(client);
  } finally {
    await client.end();
  }
}

export const db = {
  async lockUserPin(userId: string, minutesFromNow = 15) {
    await withClient((c) =>
      c.query(
        `UPDATE "user" SET pin_locked_until = NOW() + ($2 || ' minutes')::interval, failed_pin_attempts = 3 WHERE id = $1`,
        [userId, String(minutesFromNow)]
      )
    );
  },

  /**
   * Is this user's account currently PIN-locked?
   *
   * Distinguishes the two ways /recover can answer 429 — the per-(IP, name) throttle, which
   * costs the attacker, and the account lock, which costs the VICTIM. Only the second one is
   * weaponizable, so a test asserting "a single IP cannot lock a guest out" has to look at the
   * row, not at the status code.
   */
  async isPinLocked(userId: string): Promise<boolean> {
    return withClient(async (c) => {
      const r = await c.query<{ locked: boolean }>(
        `SELECT (pin_locked_until IS NOT NULL AND pin_locked_until > NOW()) AS locked
           FROM "user" WHERE id = $1`,
        [userId]
      );
      return r.rows[0]?.locked ?? false;
    });
  },

  /**
   * Preload the wrong-PIN streak, standing in for failures that arrived from other IPs.
   *
   * The account lock is deliberately out of reach of any single source, so a test that wants to
   * exercise it has to simulate the distributed case rather than hammer from one address.
   * `last_failed_pin_at` is set to now so the 15-minute decay does not immediately reset it.
   */
  async setFailedPinAttempts(userId: string, attempts: number) {
    await withClient((c) =>
      c.query(
        `UPDATE "user" SET failed_pin_attempts = $2, last_failed_pin_at = NOW() WHERE id = $1`,
        [userId, attempts]
      )
    );
  },

  /** Current wrong-PIN streak. Decays after 15 minutes — see User::increment_failed_pin. */
  async failedPinAttempts(userId: string): Promise<number> {
    return withClient(async (c) => {
      const r = await c.query<{ failed_pin_attempts: number }>(
        `SELECT failed_pin_attempts FROM "user" WHERE id = $1`,
        [userId]
      );
      return r.rows[0]?.failed_pin_attempts ?? 0;
    });
  },

  async expireSession(userId: string) {
    await withClient((c) =>
      c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
        userId,
      ])
    );
  },

  async setUploadCompressionStatus(
    uploadId: string,
    status: 'pending' | 'processing' | 'done' | 'failed'
  ) {
    await withClient((c) =>
      c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
    );
  },

  async compressionStatus(uploadId: string): Promise<string | null> {
    return withClient(async (c) => {
      const r = await c.query<{ compression_status: string }>(
        `SELECT compression_status FROM upload WHERE id = $1`,
        [uploadId]
      );
      return r.rows[0]?.compression_status ?? null;
    });
  },

  /** Which revision of the derivative pipeline produced this row's preview/display. */
  async derivativesRev(uploadId: string): Promise<number | null> {
    return withClient(async (c) => {
      const r = await c.query<{ derivatives_rev: number }>(
        `SELECT derivatives_rev FROM upload WHERE id = $1`,
        [uploadId]
      );
      return r.rows[0]?.derivatives_rev ?? null;
    });
  },

  async countUploadsForUser(userId: string): Promise<number> {
    return withClient(async (c) => {
      const r = await c.query<{ count: string }>(
        `SELECT COUNT(*)::text AS count FROM upload WHERE user_id = $1 AND deleted_at IS NULL`,
        [userId]
      );
      return Number(r.rows[0].count);
    });
  },

  async countSessionsForUser(userId: string): Promise<number> {
    return withClient(async (c) => {
      const r = await c.query<{ count: string }>(
        `SELECT COUNT(*)::text AS count FROM session WHERE user_id = $1`,
        [userId]
      );
      return Number(r.rows[0].count);
    });
  },

  async countPinResetRequestsForUser(userId: string): Promise<number> {
    return withClient(async (c) => {
      const r = await c.query<{ count: string }>(
        `SELECT COUNT(*)::text AS count FROM pin_reset_request WHERE user_id = $1`,
        [userId]
      );
      return Number(r.rows[0].count);
    });
  },

  /**
   * Overstate an upload's recorded size.
   *
   * The keepsake size estimate and the low-disk threshold are pure SQL over
   * `original_size_bytes` — no file is read — so this is the lever for driving "the keepsake
   * would not fit" without a genuinely full disk. The bytes on disk are unchanged; only the
   * accounting the warning reads from moves.
   */
  async setUploadSizeBytes(uploadId: string, bytes: number) {
    await withClient((c) =>
      c.query(`UPDATE upload SET original_size_bytes = $2 WHERE id = $1`, [uploadId, bytes])
    );
  },

  async setExportReleased(slug: string, released: boolean) {
    await withClient((c) =>
      c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
        slug,
        released ? new Date() : null,
      ])
    );
  },

  /**
   * Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any
   * more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`),
   * so a job is ready exactly when its row carries the event's live epoch. To make a `done` job NOT
   * ready we retire it to a dead epoch (-1), which is what a reopen effectively does.
   *
   * `file_path` is deliberately left NULL, so a "ready" job with no file on disk still exercises
   * the download's missing-file 404 branch.
   */
  async setExportZipReady(slug: string, ready: boolean) {
    await withClient((c) =>
      c.query(
        `UPDATE export_job ej
            SET epoch = CASE WHEN $2 THEN e.export_epoch ELSE -1 END
           FROM event e
          WHERE e.id = ej.event_id AND e.slug = $1 AND ej.type = 'zip'`,
        [slug, ready]
      )
    );
  },

  /**
   * Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the
   * event's CURRENT epoch so it counts as the live generation.
   */
  async fakeExportJob(
    eventSlug: string,
    type: 'zip' | 'html',
    status: 'pending' | 'running' | 'done' | 'failed',
    errorMessage: string | null = null
  ) {
    await withClient(async (c) => {
      const ev = await c.query<{ id: string; export_epoch: string }>(
        `SELECT id, export_epoch FROM event WHERE slug = $1`,
        [eventSlug]
      );
      if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
      await c.query(
        `INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch,
                                 error_message)
         VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6, $7)
         ON CONFLICT (event_id, type) DO UPDATE
           SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
               epoch = EXCLUDED.epoch, error_message = EXCLUDED.error_message`,
        [
          ev.rows[0].id,
          type,
          status,
          status === 'done' ? 100 : 0,
          status === 'done' ? new Date() : null,
          ev.rows[0].export_epoch,
          errorMessage,
        ]
      );
    });
  },
};
