import type { CryptoProvider, ShadeEnvelope } from '@shade/core';
import { encodeEnvelope } from '@shade/proto';
import { InboxClient, type FetchedBlob } from './client.js';
import {
  MemoryOutgoingQueueStore,
  type OutgoingEntry,
  type OutgoingQueueStore,
} from './queue-store.js';
import { MemoryCursorStore, type CursorStore } from './cursor-store.js';
import { computeMsgId } from './msg-id.js';
import { InboxClientEvents, type InboxClientListener } from './events.js';

/**
 * Caller-supplied incoming-blob handler. Receives raw wire bytes; the app
 * is expected to call `decodeEnvelope` + `Shade.receive` (or whatever
 * decrypt path it owns) and either return a sender-hint for telemetry
 * (the address the SDK extracted, or `null`) or throw to keep the blob
 * on the server for a later retry.
 */
export type DecryptHandler = (
  raw: { msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number },
) => Promise<string | null | undefined> | string | null | undefined;

export interface InboxOptions {
  /** Inbox-server base URL (e.g. "https://inbox.example.com"). */
  baseUrl: string;
  /** Recipient address — the address that owns this queue. */
  ownAddress: string;
  /** Crypto provider — used for signing requests. */
  crypto: CryptoProvider;
  /** Identity signing private key. */
  signingPrivateKey: Uint8Array;
  /** Identity signing public key. */
  signingPublicKey: Uint8Array;
  /**
   * Default TTL for outgoing PUTs (seconds). The server clamps to its own
   * min/max. Defaults to 7 days as mandated by the V3.6 spec.
   */
  defaultTtlSeconds?: number;
  /** Polling interval (ms). Default: 30s. Set to 0 to disable auto-poll. */
  pollIntervalMs?: number;
  /** Outgoing queue persistence. Defaults to in-memory. */
  queueStore?: OutgoingQueueStore;
  /** Cursor persistence. Defaults to in-memory. */
  cursorStore?: CursorStore;
  /** Override the underlying fetch (tests). */
  fetch?: typeof fetch;
  /** Maximum delivery attempts before dropping a queued entry. Default: 10. */
  maxAttempts?: number;
}

const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
const DEFAULT_POLL_INTERVAL_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 10;

/**
 * High-level inbox orchestrator.
 *
 * Responsibilities:
 *   - Outgoing: serialize ciphertext → queue → flush to server with retry.
 *   - Incoming: poll → decrypt-handler → ack on success.
 *   - Push hook: `onMessageQueued(handler)` lets a transport vendor (FCM,
 *     APNs) wake the recipient out-of-band when a blob is enqueued for them
 *     here. The hook is called with the recipient address — the actual
 *     push-delivery wire is left to the integrator.
 *
 * The class never *encrypts* — that's `Shade.send`'s job. Apps wire it up
 * like:
 *
 *   const envelope = await shade.send(bob, "hi");
 *   await inbox.send(bob, envelope);
 *
 * On Bob's device:
 *
 *   inbox.onIncoming(async (env) => {
 *     await shade.receive(senderAddress, env);
 *   });
 *   inbox.start();
 */
export class Inbox {
  private readonly client: InboxClient;
  private readonly queueStore: OutgoingQueueStore;
  private readonly cursorStore: CursorStore;
  private readonly events = new InboxClientEvents();
  private readonly defaultTtlSeconds: number;
  private readonly pollIntervalMs: number;
  private readonly maxAttempts: number;

  private incomingHandler: DecryptHandler | null = null;
  private flushTimer: ReturnType<typeof setTimeout> | null = null;
  private pollTimer: ReturnType<typeof setTimeout> | null = null;
  private flushing = false;
  private polling = false;
  private started = false;
  private registered = false;

  constructor(private readonly options: InboxOptions) {
    const clientOptions: ConstructorParameters<typeof InboxClient>[0] = {
      baseUrl: options.baseUrl,
      crypto: options.crypto,
      signingPrivateKey: options.signingPrivateKey,
    };
    if (options.fetch !== undefined) clientOptions.fetch = options.fetch;
    this.client = new InboxClient(clientOptions);
    this.queueStore = options.queueStore ?? new MemoryOutgoingQueueStore();
    this.cursorStore = options.cursorStore ?? new MemoryCursorStore();
    this.defaultTtlSeconds = options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS;
    this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
    this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
  }

  /** Subscribe to client events (queued / delivered / received / failed). */
  on(listener: InboxClientListener): () => void {
    return this.events.on(listener);
  }

  /**
   * Push-hook: invoked once per new blob enqueued for delivery. Vendors
   * implement FCM/APNs/email wake-ups inside the handler. The handler
   * fires *after* the blob is durably in the local queue but before the
   * server has confirmed PUT.
   */
  onMessageQueued(handler: (recipientAddress: string, msgId: string) => void | Promise<void>): () => void {
    return this.events.on((e) => {
      if (e.name === 'inbox.message_queued') {
        Promise.resolve(handler(e.data.recipientAddress, e.data.msgId)).catch((err) => {
          console.error('[Shade] onMessageQueued handler threw:', err);
        });
      }
    });
  }

  /** Register a handler that processes incoming blobs. Replaces any prior. */
  onIncoming(handler: DecryptHandler): () => void {
    this.incomingHandler = handler;
    return () => {
      if (this.incomingHandler === handler) this.incomingHandler = null;
    };
  }

  /**
   * Idempotent TOFU registration with the server. Called automatically by
   * `start()`; can also be invoked directly e.g. during onboarding.
   */
  async register(): Promise<void> {
    if (this.registered) return;
    await this.client.register({
      address: this.options.ownAddress,
      signingKey: this.options.signingPublicKey,
    });
    this.registered = true;
  }

  /** Drop the address from the server. Local queue/cursor are preserved. */
  async unregister(): Promise<void> {
    await this.client.unregister(this.options.ownAddress);
    this.registered = false;
  }

  /**
   * Enqueue an envelope for delivery. The actual PUT happens
   * asynchronously — the call returns once the entry is durably in the
   * outgoing queue. Returns the deterministic msgId.
   *
   * `envelope` accepts a `ShadeEnvelope` (the type returned from
   * `Shade.send`) or pre-encoded wire bytes (`Uint8Array`).
   */
  async send(args: {
    recipientAddress: string;
    envelope: ShadeEnvelope | Uint8Array;
    ttlSeconds?: number;
  }): Promise<string> {
    const ciphertext =
      args.envelope instanceof Uint8Array ? args.envelope : encodeEnvelope(args.envelope);
    const msgId = await computeMsgId(ciphertext);
    const ttlSeconds = args.ttlSeconds ?? this.defaultTtlSeconds;

    const entry: OutgoingEntry = {
      recipientAddress: args.recipientAddress,
      msgId,
      ciphertext,
      ttlSeconds,
      queuedAt: Date.now(),
      attempts: 0,
    };
    await this.queueStore.enqueue(entry);

    this.events.emit('inbox.message_queued', {
      recipientAddress: args.recipientAddress,
      msgId,
      bytes: ciphertext.length,
      ttlSeconds,
    });

    // Kick the flush loop. Don't await — caller doesn't need to block on
    // network for a "queued" return.
    this.scheduleFlush();
    return msgId;
  }

  /**
   * Force a flush + poll cycle now (useful right after registering or
   * after a push-trigger arrives). Does not throw on transient errors.
   */
  async tick(): Promise<{ flushed: number; received: number }> {
    const flushed = await this.flushOnce();
    const received = await this.pollOnce();
    return { flushed, received };
  }

  /** Start background flush + poll timers. Idempotent. */
  start(): void {
    if (this.started) return;
    this.started = true;
    this.register().catch((err) => {
      console.warn('[Shade] Inbox register failed (will retry):', (err as Error).message);
      this.scheduleRegisterRetry();
    });
    this.scheduleFlush();
    this.schedulePoll(0);
  }

  /** Stop background timers. Pending entries remain in the queue. */
  stop(): void {
    this.started = false;
    if (this.flushTimer) {
      clearTimeout(this.flushTimer);
      this.flushTimer = null;
    }
    if (this.pollTimer) {
      clearTimeout(this.pollTimer);
      this.pollTimer = null;
    }
  }

  /** Number of entries currently waiting to be flushed. */
  async pendingCount(): Promise<number> {
    return this.queueStore.size();
  }

  // ─── internals ──────────────────────────────────────────────

  private scheduleRegisterRetry(): void {
    if (!this.started) return;
    setTimeout(() => {
      if (!this.started) return;
      this.register().catch(() => this.scheduleRegisterRetry());
    }, 5000);
  }

  private scheduleFlush(delayMs = 0): void {
    if (this.flushTimer) return;
    this.flushTimer = setTimeout(() => {
      this.flushTimer = null;
      this.flushOnce()
        .then(() => {
          // If anything is still queued, retry with backoff.
          this.queueStore.size().then((n) => {
            if (n > 0 && this.started) this.scheduleFlush(15_000);
          });
        })
        .catch(() => {
          if (this.started) this.scheduleFlush(15_000);
        });
    }, delayMs);
  }

  private schedulePoll(delayMs: number): void {
    if (!this.started || this.pollIntervalMs === 0) return;
    if (this.pollTimer) clearTimeout(this.pollTimer);
    this.pollTimer = setTimeout(() => {
      this.pollTimer = null;
      this.pollOnce()
        .catch(() => {})
        .finally(() => this.schedulePoll(this.pollIntervalMs));
    }, delayMs);
  }

  private async flushOnce(): Promise<number> {
    if (this.flushing) return 0;
    this.flushing = true;
    let delivered = 0;
    try {
      const entries = await this.queueStore.list();
      for (const entry of entries) {
        try {
          const result = await this.client.put({
            recipientAddress: entry.recipientAddress,
            senderSigningKey: this.options.signingPublicKey,
            envelope: entry.ciphertext,
            ttlSeconds: entry.ttlSeconds,
          });
          await this.queueStore.remove(entry.recipientAddress, entry.msgId);
          delivered++;
          this.events.emit('inbox.message_delivered', {
            recipientAddress: entry.recipientAddress,
            msgId: result.msgId,
            idempotent: result.idempotent,
          });
        } catch (err) {
          await this.queueStore.bumpAttempts(entry.recipientAddress, entry.msgId);
          const attempts = entry.attempts + 1;
          this.events.emit('inbox.message_failed', {
            recipientAddress: entry.recipientAddress,
            msgId: entry.msgId,
            attempts,
            error: (err as Error).message,
          });
          if (attempts >= this.maxAttempts) {
            await this.queueStore.remove(entry.recipientAddress, entry.msgId);
          }
        }
      }
    } finally {
      this.flushing = false;
    }
    return delivered;
  }

  private async pollOnce(): Promise<number> {
    if (this.polling) return 0;
    if (!this.incomingHandler) return 0;
    this.polling = true;
    let total = 0;
    try {
      let cursor = await this.cursorStore.load(this.options.ownAddress);
      // Pull all pages.
      // eslint-disable-next-line no-constant-condition
      while (true) {
        let page;
        try {
          page = await this.client.fetch({
            address: this.options.ownAddress,
            sinceCursor: cursor,
          });
        } catch (err) {
          // Surface but don't crash the loop.
          console.warn('[Shade] Inbox fetch failed:', (err as Error).message);
          break;
        }
        for (const blob of page.blobs) {
          const handled = await this.handleBlob(blob);
          if (handled) total++;
          if (blob.receivedAt > cursor) cursor = blob.receivedAt;
        }
        await this.cursorStore.save(this.options.ownAddress, cursor);
        this.events.emit('inbox.poll_completed', {
          ownAddress: this.options.ownAddress,
          count: page.blobs.length,
          cursor,
        });
        if (!page.hasMore) break;
      }
    } finally {
      this.polling = false;
    }
    return total;
  }

  private async handleBlob(blob: FetchedBlob): Promise<boolean> {
    if (!this.incomingHandler) return false;

    // Defense-in-depth: verify msgId ↔ ciphertext at the client too. A
    // server bug or malicious operator can't sneak a different blob past
    // the client's hash check.
    const recomputed = await computeMsgId(blob.ciphertext);
    if (recomputed !== blob.msgId) {
      this.events.emit('inbox.message_decrypt_failed', {
        msgId: blob.msgId,
        error: 'msgId/ciphertext mismatch',
      });
      // Don't ack — let the operator notice the divergence.
      return false;
    }

    let senderHint: string | null = null;
    try {
      const result = await this.incomingHandler({
        msgId: blob.msgId,
        ciphertext: blob.ciphertext,
        receivedAt: blob.receivedAt,
        expiresAt: blob.expiresAt,
      });
      senderHint = result ?? null;
    } catch (err) {
      this.events.emit('inbox.message_decrypt_failed', {
        msgId: blob.msgId,
        error: (err as Error).message,
      });
      // Don't ack — caller can retry on next poll.
      return false;
    }

    try {
      await this.client.ack({ address: this.options.ownAddress, msgId: blob.msgId });
    } catch (err) {
      // Decryption succeeded; ack just failed. Will be retried later, and
      // the duplicate-message ratchet check on `Shade.receive` will dedupe.
      console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
    }
    this.events.emit('inbox.message_received', {
      senderHint,
      msgId: blob.msgId,
    });
    return true;
  }
}
