import type { Pool } from 'pg';

import { pgAll, pgGet, pgRunReturning } from '@/shared/db';

import type {
  OutboxDraft,
  OutboxDueItem,
  OutboxRecord,
  OutboxResultInput,
  OutboxStatus,
} from './types';

interface Row {
  id: string;
  device_id: string;
  recipient: string;
  body: string;
  priority: number;
  channel_pref: string;
  channel_used: string | null;
  not_before: string;
  status: string;
  attempts: number;
  max_attempts: number;
  error: string | null;
  dedupe_key: string | null;
  schedule_reason: string | null;
  mark_read_on_send: boolean;
  created_at: string;
  updated_at: string;
  sent_at: string | null;
}

const hydrate = (r: Row): OutboxRecord => ({
  id: r.id,
  deviceId: r.device_id,
  recipient: r.recipient,
  body: r.body,
  priority: r.priority,
  channelPref: r.channel_pref as OutboxRecord['channelPref'],
  channelUsed: r.channel_used as OutboxRecord['channelUsed'],
  notBefore: r.not_before,
  status: r.status as OutboxStatus,
  attempts: r.attempts,
  maxAttempts: r.max_attempts,
  error: r.error,
  dedupeKey: r.dedupe_key,
  scheduleReason: r.schedule_reason,
  markReadOnSend: r.mark_read_on_send,
  createdAt: r.created_at,
  updatedAt: r.updated_at,
  sentAt: r.sent_at,
});

function wrap(err: unknown, op: string): Error {
  return new Error(`outbox/repo.${op} failed: ${err instanceof Error ? err.message : String(err)}`, {
    cause: err,
  });
}

/**
 * Enqueue, deduped on `(device_id, dedupe_key)`. A conflicting insert returns
 * the *existing* row id so a triage re-run is idempotent rather than a double
 * send. Rows without a dedupe_key always insert.
 */
export async function enqueueOutbox(pool: Pool, draft: OutboxDraft): Promise<{ id: string; deduped: boolean }> {
  try {
    const inserted = await pgGet<{ id: string }>(
      pool,
      `INSERT INTO macsync.outbox
         (device_id, recipient, body, priority, channel_pref, not_before, max_attempts, dedupe_key, schedule_reason, mark_read_on_send)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
       ON CONFLICT (device_id, dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING
       RETURNING id`,
      [
        draft.deviceId,
        draft.recipient,
        draft.body,
        draft.priority,
        draft.channelPref,
        draft.notBefore,
        draft.maxAttempts ?? 3,
        draft.dedupeKey ?? null,
        draft.scheduleReason ?? null,
        draft.markReadOnSend ?? false,
      ],
    );
    if (inserted) return { id: inserted.id, deduped: false };

    // Conflict (DO NOTHING returned no row): fetch the pre-existing row's id.
    const existing = await pgGet<{ id: string }>(
      pool,
      `SELECT id FROM macsync.outbox WHERE device_id = $1 AND dedupe_key = $2`,
      [draft.deviceId, draft.dedupeKey],
    );
    if (!existing) throw new Error('conflict on enqueue but no existing row found');
    return { id: existing.id, deduped: true };
  } catch (err) {
    throw wrap(err, 'enqueueOutbox');
  }
}

/** Items due to send now for a device: queued, `not_before <= now`, hottest first. */
export async function listDue(
  pool: Pool,
  deviceId: string,
  now: string,
  limit = 25,
): Promise<readonly OutboxDueItem[]> {
  try {
    const rows = await pgAll<Row>(
      pool,
      `SELECT * FROM macsync.outbox
       WHERE device_id = $1 AND status = 'queued' AND not_before <= $2
       ORDER BY priority DESC, not_before ASC
       LIMIT $3`,
      [deviceId, now, limit],
    );
    return rows.map((r) => ({
      id: r.id,
      recipient: r.recipient,
      body: r.body,
      channelPref: r.channel_pref as OutboxDueItem['channelPref'],
      attempts: r.attempts,
      maxAttempts: r.max_attempts,
    }));
  } catch (err) {
    throw wrap(err, 'listDue');
  }
}

/**
 * Claim an item for sending (queued → sending), bumping the attempt counter.
 * Scoped to `deviceId` so a device token can only mutate its own rows (IDOR
 * guard). Returns false when no owned row matched — the surface maps that to 404.
 */
export async function markSending(pool: Pool, id: string, deviceId: string): Promise<boolean> {
  try {
    const row = await pgRunReturning<{ id: string } | undefined>(
      pool,
      `UPDATE macsync.outbox
         SET status = 'sending', attempts = attempts + 1, updated_at = now()
       WHERE id = $1 AND device_id = $2
       RETURNING id`,
      [id, deviceId],
    );
    return Boolean(row);
  } catch (err) {
    throw wrap(err, 'markSending');
  }
}

/**
 * Record a send result. `sent`/`delivered` is terminal-success; `failed`
 * re-queues for retry while attempts remain, else stays failed (hard-fail).
 * Scoped to `deviceId` (IDOR guard); returns false when no owned row matched.
 */
export async function markResult(pool: Pool, input: OutboxResultInput): Promise<boolean> {
  try {
    if (input.status === 'failed') {
      const row = await pgRunReturning<{ id: string } | undefined>(
        pool,
        `UPDATE macsync.outbox
           SET status = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'queued' END,
               error = $3,
               channel_used = COALESCE($4, channel_used),
               updated_at = now()
         WHERE id = $1 AND device_id = $2
         RETURNING id`,
        [input.id, input.deviceId, input.error ?? null, input.channelUsed ?? null],
      );
      return Boolean(row);
    }
    const row = await pgRunReturning<{ id: string } | undefined>(
      pool,
      `UPDATE macsync.outbox
         SET status = $3, channel_used = COALESCE($4, channel_used), error = NULL,
             sent_at = now(), updated_at = now()
       WHERE id = $1 AND device_id = $2
       RETURNING id`,
      [input.id, input.deviceId, input.status, input.channelUsed ?? null],
    );
    return Boolean(row);
  } catch (err) {
    throw wrap(err, 'markResult');
  }
}

export async function getOutbox(pool: Pool, id: string): Promise<OutboxRecord | null> {
  try {
    const row = await pgGet<Row>(pool, `SELECT * FROM macsync.outbox WHERE id = $1`, [id]);
    return row ? hydrate(row) : null;
  } catch (err) {
    throw wrap(err, 'getOutbox');
  }
}

/**
 * Retry backstop: reclaim rows wedged in `sending` because the Mac client
 * claimed them (queued → sending, attempt counted) but never reported a result
 * — e.g. its result POST failed after the claim. `listDue` is queued-only so it
 * never refetches them on its own. Re-queue while attempts remain, else
 * hard-fail (same policy as `markResult`). `olderThan` is an ISO cutoff on
 * `updated_at`. Returns the affected ids + their new status.
 */
export async function requeueStaleSending(
  pool: Pool,
  olderThan: string,
): Promise<readonly { id: string; status: OutboxStatus }[]> {
  try {
    const rows = await pgAll<{ id: string; status: string }>(
      pool,
      `UPDATE macsync.outbox
         SET status = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'queued' END,
             error  = COALESCE(error, 'stale send reclaimed: no result reported'),
             updated_at = now()
       WHERE status = 'sending' AND updated_at < $1
       RETURNING id, status`,
      [olderThan],
    );
    return rows.map((r) => ({ id: r.id, status: r.status as OutboxStatus }));
  } catch (err) {
    throw wrap(err, 'requeueStaleSending');
  }
}

export async function countByStatus(pool: Pool, deviceId: string, status: OutboxStatus): Promise<number> {
  try {
    const row = await pgGet<{ n: string }>(
      pool,
      `SELECT COUNT(*)::text AS n FROM macsync.outbox WHERE device_id = $1 AND status = $2`,
      [deviceId, status],
    );
    return Number(row?.n ?? 0);
  } catch (err) {
    throw wrap(err, 'countByStatus');
  }
}
