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

/**
 * WU-2735 (INIT-060 WU-7a, ADR-013 §ChannelBridge):
 * Filesystem implementation of the ChannelBridge port.
 *
 * Backing layout:
 *
 *   <rootDir>/channels/<channel-id>/envelopes.jsonl
 *   <rootDir>/channels/<channel-id>/config.json
 *   <rootDir>/registry.json       (BridgeConfig identity → ChannelId)
 *
 * Writes are atomic: a new envelope is written to a per-pid tmp file and
 * `rename(2)`'d on top of a sibling, then append to the JSONL via
 * `appendFile` with `O_APPEND` semantics (Node delegates to the kernel, which
 * guarantees single-write atomicity for writes below PIPE_BUF; our JSON lines
 * sit well below that for realistic envelopes and we additionally bound the
 * write under a per-channel in-process mutex).
 *
 * The adapter is deliberately minimal: the sidekick pack's transport registry
 * (`channel-transports.ts`) remains the runtime dispatch path for the existing
 * `channel:send` tool; WU-2735 lands the port/adapter skeleton only.
 */

import { createHash } from 'node:crypto';
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';

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

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

export interface FilesystemChannelBridgeOptions {
  /**
   * Directory under which the bridge materialises channel state. Tests set
   * this to a tmpdir; in production the caller passes
   * `.lumenflow/state/packs/sidekick/` (or equivalent per ADR-013).
   */
  rootDir: string;
}

interface RegistryEntry {
  id: ChannelId;
  provider: string;
  name: string;
  options_hash: string;
}

interface Registry {
  entries: RegistryEntry[];
}

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

const REGISTRY_FILENAME = 'registry.json';
const CHANNELS_SUBDIR = 'channels';
const ENVELOPES_FILENAME = 'envelopes.jsonl';
const CONFIG_FILENAME = 'config.json';

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 {
  // Deterministic id per (provider, name, options) — stable across bridge
  // instances backed by the same rootDir so `register` is idempotent even on
  // cold start without reading the registry.
  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;
}

async function readRegistry(registryPath: string): Promise<Registry> {
  try {
    const raw = await readFile(registryPath, 'utf8');
    return JSON.parse(raw) as Registry;
  } catch (error) {
    const err = error as NodeJS.ErrnoException;
    if (err.code === 'ENOENT') {
      return { entries: [] };
    }
    throw error;
  }
}

async function writeRegistryAtomic(registryPath: string, registry: Registry): Promise<void> {
  await mkdir(path.dirname(registryPath), { recursive: true });
  const tmpPath = `${registryPath}.tmp-${process.pid}-${Date.now()}`;
  await writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf8');
  const { rename } = await import('node:fs/promises');
  await rename(tmpPath, registryPath);
}

// Per-bridge-instance lock map so concurrent send() calls on a single channel
// serialise their appends. Filesystem atomicity still holds under crash, but
// the mutex ensures emit order is preserved (ADR-013 §3).
function createLockGate(): {
  withLock<T>(key: string, fn: () => Promise<T>): Promise<T>;
} {
  const chains = new Map<string, Promise<unknown>>();
  return {
    async withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
      const previous = chains.get(key) ?? Promise.resolve();
      const next = previous.then(fn, fn);
      chains.set(
        key,
        next.then(
          () => undefined,
          () => undefined,
        ),
      );
      return next as Promise<T>;
    },
  };
}

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

export function createFilesystemChannelBridge(
  options: FilesystemChannelBridgeOptions,
): ChannelBridge {
  const rootDir = path.resolve(options.rootDir);
  const registryPath = path.join(rootDir, REGISTRY_FILENAME);
  const lockGate = createLockGate();
  const disconnected = new Set<ChannelId>();

  function channelDir(id: ChannelId): string {
    return path.join(rootDir, CHANNELS_SUBDIR, id);
  }

  async function ensureRegistered(config: BridgeConfig): Promise<ChannelId> {
    const registry = await readRegistry(registryPath);
    const id = mintChannelId(config);
    const optionsHash = hashOptions(config.options as Record<string, unknown> | undefined);
    const existing = registry.entries.find((entry) => entry.id === id);
    if (existing) {
      return existing.id;
    }

    registry.entries.push({
      id,
      provider: config.provider,
      name: config.name,
      options_hash: optionsHash,
    });
    await writeRegistryAtomic(registryPath, registry);

    const dir = channelDir(id);
    await mkdir(dir, { recursive: true });
    await writeFile(
      path.join(dir, CONFIG_FILENAME),
      JSON.stringify({ provider: config.provider, name: config.name }, null, 2),
      'utf8',
    );
    return id;
  }

  async function assertActive(channelId: ChannelId): Promise<void> {
    if (disconnected.has(channelId)) {
      throw new Error(`ChannelBridge: channel ${channelId} is disconnected; send rejected.`);
    }
    const registry = await readRegistry(registryPath);
    if (!registry.entries.some((entry) => entry.id === channelId)) {
      throw new Error(`ChannelBridge: channel ${channelId} is not registered.`);
    }
  }

  return {
    async register(bridgeConfig: BridgeConfig): Promise<ChannelId> {
      return ensureRegistered(bridgeConfig);
    },

    async send(channelId: ChannelId, envelope: ChannelEnvelope): Promise<SendResult> {
      await assertActive(channelId);
      const envelopesPath = path.join(channelDir(channelId), ENVELOPES_FILENAME);
      await lockGate.withLock(channelId, async () => {
        await mkdir(path.dirname(envelopesPath), { recursive: true });
        await appendFile(envelopesPath, `${JSON.stringify(envelope)}\n`, 'utf8');
      });
      return { accepted: true, delivery_id: envelope.id };
    },

    async *receive(channelId: ChannelId): AsyncIterable<ChannelEnvelope> {
      if (disconnected.has(channelId)) {
        return;
      }
      const envelopesPath = path.join(channelDir(channelId), ENVELOPES_FILENAME);
      let raw: string;
      try {
        raw = await readFile(envelopesPath, 'utf8');
      } catch (error) {
        const err = error as NodeJS.ErrnoException;
        if (err.code === 'ENOENT') {
          return;
        }
        throw error;
      }
      for (const line of raw.split('\n')) {
        if (line.length === 0) {
          continue;
        }
        yield JSON.parse(line) as ChannelEnvelope;
      }
    },

    async disconnect(channelId: ChannelId): Promise<void> {
      disconnected.add(channelId);
    },
  };
}
