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

/**
 * WU-2737 (INIT-060 WU-7b, ADR-013 §ChannelBridge):
 * Control-plane ChannelBridge adapter.
 *
 * Cloud-backed implementation of the ChannelBridge port. Codes directly
 * against the contract published in
 * `docs/operations/coordination/channel-bridge-cloud-stub.md` (WU-2736):
 *
 *   POST /api/v1/workspaces/{workspace_id}/sidekick/channel
 *
 * Responsibilities:
 *   - Outbound `send()` POSTs each envelope with `Bearer <enrollment_token>`.
 *   - At-least-once delivery scoped by `(channel_id, envelope.id)` dedup key;
 *     safe to replay on transport failure (cloud returns `deduped: true`).
 *   - Backpressure split (ADR-013 §4):
 *       - `kind: "ephemeral"` fails silently on 5xx / 429 / network errors
 *         (no outbox, no retry, no elevated logging).
 *       - `kind: "queue"` parks in the local outbox on any non-2xx and drains
 *         FIFO on reconnect / explicit `flush()` / `disconnect()`.
 *   - Inbound `receive()` drains a loopback-friendly poll endpoint. The stub
 *     contract (WU-2736) does not yet freeze the inbound wire shape; the
 *     adapter exposes `pollIntervalMs` + a minimal GET fetcher so a mock
 *     server or future coord-stub-signed inbound path can plug in without
 *     changing the port.
 *   - `register()` is idempotent on BridgeConfig identity (deterministic id
 *     from provider/name/options hash — matches the filesystem adapter pattern).
 *   - `disconnect()` flushes queued envelopes before resolving; subsequent
 *     `send()` on the same channel id rejects (port contract).
 */

import { createHash } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';

import type {
  BridgeConfig,
  ChannelEnvelope,
  ChannelId,
  SendResult,
} from '../domain/channel.types.js';
import type { ChannelBridge } from '../ports/channel-bridge.port.js';

import { CloudOutbox } from './cloud-queue.js';

// ---------------------------------------------------------------------------
// Configuration surface
// ---------------------------------------------------------------------------

export interface ControlPlaneChannelBridgeOptions {
  /** Cloud API base URL, e.g. `https://app.lumenflow.dev` (no trailing slash). */
  baseUrl: string;
  /** Workspace id — path parameter on the POST route. */
  workspaceId: string;
  /**
   * Returns the current enrollment token. Called on every request so token
   * rotation (re-auth) is transparent to callers.
   */
  tokenProvider: () => Promise<string>;
  /** Local outbox root for queue-kind envelopes. */
  outboxDir: string;
  /** Polling interval for `receive()`. Defaults to 1000ms. */
  pollIntervalMs?: number;
  /**
   * Optional override for `fetch`. Primarily a test seam; production wiring
   * uses the platform global `fetch`.
   */
  fetchImpl?: typeof fetch;
}

/**
 * `ChannelBridge` extended with `flush()` so operators (and the disconnect
 * path) can explicitly drain the outbox. The port contract itself does NOT
 * require `flush`; it's an adapter affordance.
 */
export interface ControlPlaneChannelBridge extends ChannelBridge {
  flush(): Promise<void>;
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

const DEFAULT_POLL_INTERVAL_MS = 1000;
const QUEUED_REASON = 'queued_for_replay';
const DROPPED_EPHEMERAL_REASON = 'dropped_ephemeral';
const INVALID_ENVELOPE_REASON = 'invalid_envelope';
const DISCONNECTED_REASON = 'disconnected';

interface CloudSuccessBody {
  accepted?: boolean;
  delivery_id?: string;
  deduped?: boolean;
  trace_id?: string;
  reason?: string;
}

interface CloudErrorBody {
  error?: {
    code?: string;
    message?: string;
    retryable?: boolean;
    trace_id?: string;
  };
}

function hashOptions(options: Record<string, unknown> | undefined): string {
  const payload = JSON.stringify(options ?? {}, Object.keys(options ?? {}).sort());
  return createHash('sha256').update(payload).digest('hex').slice(0, 16);
}

function mintChannelId(config: BridgeConfig): ChannelId {
  const payload = `${config.provider}::${config.name}::${hashOptions(
    config.options as Record<string, unknown> | undefined,
  )}`;
  const digest = createHash('sha256').update(payload).digest('hex').slice(0, 24);
  return `chan-${digest}` as ChannelId;
}

function safeParseJson<T>(text: string): T | null {
  try {
    return JSON.parse(text) as T;
  } catch {
    return null;
  }
}

// ---------------------------------------------------------------------------
// Adapter factory
// ---------------------------------------------------------------------------

export function createControlPlaneChannelBridge(
  options: ControlPlaneChannelBridgeOptions,
): ControlPlaneChannelBridge {
  const baseUrl = options.baseUrl.replace(/\/+$/, '');
  const workspaceId = options.workspaceId;
  const tokenProvider = options.tokenProvider;
  const outbox = new CloudOutbox(options.outboxDir);
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
  const fetchImpl = options.fetchImpl ?? fetch;

  const registry = new Map<string, ChannelId>();
  const disconnected = new Set<ChannelId>();

  function postUrl(): string {
    return `${baseUrl}/api/v1/workspaces/${encodeURIComponent(workspaceId)}/sidekick/channel`;
  }

  function inboundUrl(channelId: ChannelId): string {
    return `${postUrl()}?channel_id=${encodeURIComponent(channelId)}`;
  }

  async function authHeaders(): Promise<Record<string, string>> {
    const token = await tokenProvider();
    return {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    };
  }

  /**
   * One attempt at a single POST. Returns a discriminated result so the
   * caller decides what to do with retryable / non-retryable outcomes.
   */
  type PostOutcome =
    | { kind: 'accepted'; body: CloudSuccessBody }
    | {
        kind: 'retryable';
        status: number;
        error: CloudErrorBody['error'];
        retryAfterSeconds?: number;
      }
    | { kind: 'non_retryable'; status: number; error: CloudErrorBody['error'] }
    | { kind: 'network_error'; cause: unknown };

  async function postOnce(channelId: ChannelId, envelope: ChannelEnvelope): Promise<PostOutcome> {
    let response: Response;
    try {
      response = await fetchImpl(postUrl(), {
        method: 'POST',
        headers: await authHeaders(),
        body: JSON.stringify({ channel_id: channelId, envelope }),
      });
    } catch (error) {
      return { kind: 'network_error', cause: error };
    }

    const text = await response.text();
    if (response.ok) {
      const body = safeParseJson<CloudSuccessBody>(text) ?? {};
      return { kind: 'accepted', body };
    }

    const errorBody = safeParseJson<CloudErrorBody>(text) ?? {};
    const retryAfterHeader = response.headers.get('retry-after');
    const retryAfterSeconds = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : undefined;

    // 4xx non-retryable per contract §Error Response Shape — everything
    // other than 429 is a correctness bug / auth failure we don't retry.
    if (response.status >= 400 && response.status < 500 && response.status !== 429) {
      return { kind: 'non_retryable', status: response.status, error: errorBody.error };
    }

    return {
      kind: 'retryable',
      status: response.status,
      error: errorBody.error,
      retryAfterSeconds: Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
    };
  }

  function nonRetryableReason(error: CloudErrorBody['error']): string {
    // Contract codes map 1:1 onto SendResult.reason for observability. Unknown
    // codes collapse into a stable catch-all so audit logs stay readable.
    const code = error?.code;
    if (code === 'invalid_envelope') return INVALID_ENVELOPE_REASON;
    if (code) return code;
    return 'rejected_non_retryable';
  }

  async function drainOutbox(): Promise<void> {
    const pending = await outbox.list();
    for (const record of pending) {
      // Cast: outbox entries are keyed by the same branded ids we minted.
      const cid = record.channel_id as ChannelId;
      const outcome = await postOnce(cid, record.envelope);
      if (outcome.kind === 'accepted') {
        await outbox.remove(record._filename);
        continue;
      }
      if (outcome.kind === 'non_retryable') {
        // Correctness bug on a parked envelope — drop it so the outbox does
        // not grow unbounded on permanent errors (matches contract table:
        // `invalid_envelope` / `workspace_mismatch` are log-and-drop).
        await outbox.remove(record._filename);
        continue;
      }
      // Retryable / network error — stop the drain; caller retries later.
      return;
    }
  }

  async function sendInternal(
    channelId: ChannelId,
    envelope: ChannelEnvelope,
  ): Promise<SendResult> {
    // Opportunistic drain: a successful path clears any previous parked
    // envelopes before the new one POSTs, preserving FIFO order under the
    // "replay-on-reconnect" rule (§4).
    if (envelope.kind === 'queue') {
      await drainOutbox();
    }

    const outcome = await postOnce(channelId, envelope);

    if (outcome.kind === 'accepted') {
      const body = outcome.body;
      const result: SendResult = {
        accepted: body.accepted ?? true,
      };
      if (body.delivery_id !== undefined) {
        result.delivery_id = body.delivery_id;
      }
      if (body.deduped === true) {
        result.deduped = true;
      }
      if (body.reason !== undefined) {
        result.reason = body.reason;
      }
      return result;
    }

    if (outcome.kind === 'non_retryable') {
      // Both kinds: log-and-drop per §Error Response Shape.
      return {
        accepted: false,
        reason: nonRetryableReason(outcome.error),
      };
    }

    // Retryable (5xx / 429) OR network error: split by envelope.kind.
    if (envelope.kind === 'ephemeral') {
      // Fail-silent per §4; no outbox, no throw.
      return { accepted: false, reason: DROPPED_EPHEMERAL_REASON };
    }

    // queue: park for replay.
    await outbox.enqueue(channelId, envelope);
    return { accepted: false, reason: QUEUED_REASON };
  }

  // -------------------------------------------------------------------------
  // Port methods
  // -------------------------------------------------------------------------

  return {
    async register(bridgeConfig: BridgeConfig): Promise<ChannelId> {
      const key = `${bridgeConfig.provider}::${bridgeConfig.name}::${hashOptions(
        bridgeConfig.options as Record<string, unknown> | undefined,
      )}`;
      const existing = registry.get(key);
      if (existing) {
        return existing;
      }
      const id = mintChannelId(bridgeConfig);
      registry.set(key, id);
      return id;
    },

    async send(channelId: ChannelId, envelope: ChannelEnvelope): Promise<SendResult> {
      if (disconnected.has(channelId)) {
        throw new Error(
          `ControlPlaneChannelBridge: channel ${channelId} is disconnected; send rejected (reason=${DISCONNECTED_REASON}).`,
        );
      }
      return sendInternal(channelId, envelope);
    },

    async *receive(channelId: ChannelId): AsyncIterable<ChannelEnvelope> {
      // Poll-based inbound. One pass drains whatever is currently queued on
      // the cloud side; the loop continues until `disconnect()` flips the
      // flag so the iterator terminates without hanging (port contract).
      while (!disconnected.has(channelId)) {
        let response: Response;
        try {
          response = await fetchImpl(inboundUrl(channelId), {
            method: 'GET',
            headers: await authHeaders(),
          });
        } catch {
          // Transient network error — back off and retry.
          await delay(pollIntervalMs);
          continue;
        }

        if (!response.ok) {
          // 4xx/5xx on poll: back off. Errors here do NOT terminate the
          // iterator; the port contract treats the iterator as long-lived
          // until `disconnect()`.
          await delay(pollIntervalMs);
          continue;
        }

        const text = await response.text();
        const body = safeParseJson<{ envelopes?: ChannelEnvelope[] }>(text) ?? {};
        const envelopes = body.envelopes ?? [];

        for (const env of envelopes) {
          if (disconnected.has(channelId)) {
            return;
          }
          yield env;
        }

        if (envelopes.length === 0) {
          await delay(pollIntervalMs);
        }
      }
    },

    async disconnect(channelId: ChannelId): Promise<void> {
      // Flush outbox before closing so queued envelopes for any channel have
      // a chance to drain (port contract: "queued envelopes flush before
      // this resolves").
      try {
        await drainOutbox();
      } catch {
        // Drain best-effort; disconnect must always resolve.
      }
      disconnected.add(channelId);
    },

    async flush(): Promise<void> {
      await drainOutbox();
    },
  };
}
