// Copyright (c) 2026 Hellmai Ltd
// SPDX-License-Identifier: AGPL-3.0-only

/**
 * WU-2737 (INIT-060 WU-7b, ADR-013 §ChannelBridge):
 * Local outbox for queue-kind envelopes when the cloud is unreachable.
 *
 * Backing layout:
 *   <outboxDir>/<channel-id>/<timestamp>-<envelope-id>.json
 *
 * Each file is a JSON blob `{ channel_id, envelope }` matching the request
 * body shape published in `channel-bridge-cloud-stub.md`. FIFO ordering comes
 * from the sorted filename (lexicographic on timestamp + envelope id).
 *
 * The outbox is intentionally dumb: no retry state, no backoff, no watcher.
 * The adapter drives drains explicitly via `flush()` or reactively on the
 * next successful POST; ADR-013 §4 treats the outbox as a passive spool.
 */

import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';

import type { ChannelEnvelope } from '../domain/channel.types.js';

export interface OutboxRecord {
  channel_id: string;
  envelope: ChannelEnvelope;
  /** Internal filename so the adapter can remove the entry after a successful replay. */
  _filename: string;
}

/**
 * Seconds-precision epoch plus a short random suffix keeps filenames sortable
 * FIFO while avoiding collisions when two envelopes enqueue in the same
 * millisecond.
 */
function mintFilename(envelopeId: string): string {
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
  const safeId = envelopeId.replace(/[^a-zA-Z0-9_-]/g, '_');
  const suffix = Math.random().toString(36).slice(2, 8);
  return `${ts}-${safeId}-${suffix}.json`;
}

export class CloudOutbox {
  constructor(private readonly outboxDir: string) {}

  async enqueue(channelId: string, envelope: ChannelEnvelope): Promise<void> {
    const dir = path.resolve(this.outboxDir);
    await mkdir(dir, { recursive: true });
    const filename = mintFilename(envelope.id);
    const tmpPath = path.join(dir, `.tmp-${process.pid}-${filename}`);
    const finalPath = path.join(dir, filename);
    const payload = JSON.stringify({ channel_id: channelId, envelope });
    await writeFile(tmpPath, payload, 'utf8');
    await rename(tmpPath, finalPath);
  }

  /**
   * Lists outbox records in FIFO order. The returned filename is opaque; pass
   * it back to `remove()` after a successful replay.
   */
  async list(): Promise<OutboxRecord[]> {
    const dir = path.resolve(this.outboxDir);
    let entries: string[];
    try {
      entries = await readdir(dir);
    } catch (error) {
      const err = error as NodeJS.ErrnoException;
      if (err.code === 'ENOENT') {
        return [];
      }
      throw error;
    }
    const records: OutboxRecord[] = [];
    const sortedEntries = entries
      .filter((e) => e.endsWith('.json') && !e.startsWith('.tmp-'))
      .sort();
    for (const name of sortedEntries) {
      const full = path.join(dir, name);
      try {
        const raw = await readFile(full, 'utf8');
        const parsed = JSON.parse(raw) as { channel_id: string; envelope: ChannelEnvelope };
        records.push({ ...parsed, _filename: name });
      } catch {
        // Corrupt or partial file — skip (will not block FIFO drain).
        continue;
      }
    }
    return records;
  }

  async remove(filename: string): Promise<void> {
    const full = path.join(path.resolve(this.outboxDir), filename);
    await rm(full, { force: true });
  }

  async isEmpty(): Promise<boolean> {
    const records = await this.list();
    return records.length === 0;
  }
}
