import { Hono } from 'hono';
import type { CryptoProvider } from '@shade/core';
import {
  errorToHttpStatus,
  ShadeError,
  ValidationError,
  RateLimitError,
  UnauthorizedError,
  fromBase64,
  toBase64,
  constantTimeEqual,
} from '@shade/core';
import {
  verifyPayload,
  validateAddress,
  RateLimiter,
  MemoryRateLimitStore,
  type RateLimitConfig,
} from '@shade/server';
import {
  ATTR_ERROR_CODE,
  ATTR_HTTP_STATUS,
  ATTR_ROUTE,
  NOOP_HOOK,
  type ObservabilityHook,
} from '@shade/observability';
import type { InboxStore } from './store.js';
import { InboxServerEvents, shortHash } from './events.js';
import { computeMsgId, isValidMsgId, constantTimeStringEqual } from './msg-id.js';
import { clampTtl, DEFAULT_INBOX_QUOTA, type InboxQuotaConfig } from './quota.js';
import type { BridgeDeliveryLog } from './bridge-delivery-log.js';

/** Max metadata-only body size (no ciphertext). 64 KB is enough headroom. */
const MAX_META_BODY_SIZE = 64 * 1024;

/**
 * Per-route token-bucket presets. PUT is intentionally generous (senders
 * may burst) but bound on the recipient side (per-address quota in the
 * store). FETCH and DELETE are per-address.
 */
const INBOX_PUT_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
const INBOX_FETCH_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
const INBOX_DELETE_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
const INBOX_REGISTER_LIMIT: RateLimitConfig = {
  capacity: 5,
  refillPerSecond: 5 / 3600,
};

export interface InboxRoutesOptions {
  /** Disable rate limiting (used in tests). */
  disableRateLimit?: boolean;
  /** Optional event emitter. */
  events?: InboxServerEvents;
  /** OTel observability hook. */
  observability?: ObservabilityHook;
  /** Override quota policy. */
  quota?: Partial<InboxQuotaConfig>;
  /**
   * V4.8.4 — shared bridge delivery log. When provided (and the same
   * instance is wired into `createBridgeRoutes`), the inbox-fetch route
   * filters out blobs already pushed via bridge within the log's grace
   * window. Without this, a recipient that runs both a bridge
   * subscription and inbox-poll receives the same envelope twice.
   * Optional — leaving it unset preserves the pre-V4.8.4 behavior of
   * always returning every blob the cursor matches.
   */
  bridgeDeliveryLog?: BridgeDeliveryLog;
}

export function createInboxRoutes(
  store: InboxStore,
  crypto: CryptoProvider,
  options: InboxRoutesOptions = {},
): Hono {
  const app = new Hono();
  const events = options.events;
  const observability = options.observability ?? NOOP_HOOK;
  const quota: InboxQuotaConfig = { ...DEFAULT_INBOX_QUOTA, ...(options.quota ?? {}) };

  app.use('*', async (c, next) => {
    const route = c.req.routePath ?? c.req.path ?? '<unknown>';
    const span = observability.startSpan('shade.inbox.request', {
      [ATTR_ROUTE]: route,
    });
    try {
      await next();
      span.setAttribute(ATTR_HTTP_STATUS, c.res.status);
      span.setStatus(c.res.status >= 500 ? 'error' : 'ok');
    } catch (err) {
      const code =
        err instanceof ShadeError ? err.code ?? 'SHADE_ERROR' : 'SHADE_INTERNAL';
      span.setAttribute(ATTR_ERROR_CODE, code);
      span.recordException(err);
      span.setStatus('error', code);
      throw err;
    } finally {
      span.end();
    }
  });

  const rlStore = new MemoryRateLimitStore();
  const putRL = new RateLimiter(rlStore, INBOX_PUT_LIMIT);
  const fetchRL = new RateLimiter(rlStore, INBOX_FETCH_LIMIT);
  const deleteRL = new RateLimiter(rlStore, INBOX_DELETE_LIMIT);
  const registerRL = new RateLimiter(rlStore, INBOX_REGISTER_LIMIT);
  const rateLimitEnabled = !options.disableRateLimit;

  const getClientIp = (c: any): string =>
    c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
    c.req.header('x-real-ip') ??
    'unknown';

  app.get('/health', (c) => c.json({ status: 'ok', service: 'shade-inbox-server' }));

  app.onError((err, c) => {
    if (err instanceof RateLimitError) {
      events?.emit('inbox.rate_limited', {
        route: c.req.routePath ?? c.req.path,
        key: getClientIp(c),
      });
    }
    if (err instanceof ShadeError) {
      const status = errorToHttpStatus(err);
      const body: any = err.toJSON();
      if ((err as any).retryAfterSeconds) {
        c.header('Retry-After', String((err as any).retryAfterSeconds));
      }
      return c.json(body, status as any);
    }
    console.error('[Shade] Unhandled inbox error:', err);
    return c.json({ error: 'Internal server error' }, 500);
  });

  // ─── Register address (TOFU) ───────────────────────────────
  // Recipient claims an address by uploading its signing key and a
  // signature over the canonical body. Subsequent PUT/GET/DELETE for the
  // address are authenticated against this key. Idempotent if the same key
  // re-registers; rejects if a different key tries to take an existing slot.
  app.post('/v1/inbox/register', async (c) => {
    if (rateLimitEnabled) await registerRL.consume(`inbox-register:${getClientIp(c)}`);

    const rawBody = await c.req.text();
    if (rawBody.length > MAX_META_BODY_SIZE) {
      throw new ValidationError(`Request body too large (max ${MAX_META_BODY_SIZE} bytes)`);
    }
    const body = JSON.parse(rawBody);
    const { address, signingKey } = body;
    const addr = validateAddress(address);
    if (typeof signingKey !== 'string') {
      throw new ValidationError('Missing signingKey', 'signingKey');
    }
    const key = b64ToBytes(signingKey);

    // Verify signature against the asserted key (TOFU).
    await verifyPayload(crypto, key, body);

    const existing = await store.getAddressOwner(addr);
    if (existing && !constantTimeEqual(existing, key)) {
      throw new UnauthorizedError(`Address already claimed by a different key`);
    }
    await store.saveAddressOwner(addr, key);

    if (events) {
      events.emit('inbox.address_registered', {
        address: addr,
        signingKeyHash: await shortHash(key),
      });
    }
    return c.json({ ok: true });
  });

  // ─── Unregister (signed) ───────────────────────────────────
  app.delete('/v1/inbox/register/:address', async (c) => {
    const address = validateAddress(c.req.param('address'));
    if (rateLimitEnabled) await registerRL.consume(`inbox-unregister:${address}`);

    const owner = await store.getAddressOwner(address);
    if (!owner) {
      return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
    }
    const body = await c.req.json();
    await verifyPayload(crypto, owner, { ...body, address });

    await store.deleteAddress(address);
    options.bridgeDeliveryLog?.forgetAddress(address);
    events?.emit('inbox.address_deleted', { address });
    return c.json({ ok: true });
  });

  // ─── PUT a blob (signed by sender) ─────────────────────────
  // Body format:
  //   {
  //     senderSigningKey: b64,
  //     msgId: hex(sha256(ciphertext)),
  //     ciphertext: b64,
  //     ttlSeconds?: number,
  //     signedAt: number,
  //     signature: b64,    // over the canonical body sans signature
  //   }
  // The recipient address is the path parameter. The sender authenticates
  // itself via `senderSigningKey` (TOFU per request — the *recipient*
  // determines whether to accept the sender, via the encrypted envelope).
  app.post('/v1/inbox/:address', async (c) => {
    if (rateLimitEnabled) await putRL.consume(`inbox-put:${getClientIp(c)}`);

    const address = validateAddress(c.req.param('address'));

    const rawBody = await c.req.text();
    // Allow up to (maxBlobBytes * 4/3) for base64 + JSON overhead.
    const hardLimit = Math.ceil(quota.maxBlobBytes * 1.4) + MAX_META_BODY_SIZE;
    if (rawBody.length > hardLimit) {
      events?.emit('inbox.quota_rejected', { address, reason: 'body-too-large' });
      throw new ValidationError(`Request body too large`);
    }

    const body = JSON.parse(rawBody);
    const { senderSigningKey, msgId, ciphertext, ttlSeconds } = body;

    if (typeof senderSigningKey !== 'string') {
      throw new ValidationError('Missing senderSigningKey', 'senderSigningKey');
    }
    if (typeof ciphertext !== 'string') {
      throw new ValidationError('Missing ciphertext', 'ciphertext');
    }
    if (!isValidMsgId(msgId)) {
      throw new ValidationError('msgId must be 64 lowercase hex chars', 'msgId');
    }

    const senderKey = b64ToBytes(senderSigningKey);
    const ctBytes = b64ToBytes(ciphertext);

    if (ctBytes.length === 0) {
      throw new ValidationError('ciphertext is empty', 'ciphertext');
    }
    if (ctBytes.length > quota.maxBlobBytes) {
      events?.emit('inbox.quota_rejected', { address, reason: 'body-too-large' });
      throw new ValidationError(
        `ciphertext exceeds maxBlobBytes (${ctBytes.length} > ${quota.maxBlobBytes})`,
      );
    }

    // Verify the claimed msgId matches the actual ciphertext digest.
    const recomputed = await computeMsgId(ctBytes);
    if (!constantTimeStringEqual(recomputed, msgId)) {
      throw new ValidationError('msgId does not match sha256(ciphertext)', 'msgId');
    }

    // Verify sender signature.
    await verifyPayload(crypto, senderKey, body);

    // Recipient address must be registered (avoids DoS against unclaimed
    // slots — see THREAT-MODEL).
    const recipient = await store.getAddressOwner(address);
    if (!recipient) {
      return c.json({ error: 'Recipient not registered', code: 'SHADE_NOT_FOUND' }, 404);
    }

    const ttl = clampTtl(typeof ttlSeconds === 'number' ? ttlSeconds : undefined, quota);
    const expiresAt = Date.now() + ttl * 1000;

    // Per-address quota check before the write so the cap is enforced.
    const currentCount = await store.countBlobs(address, Date.now());
    if (currentCount >= quota.maxBlobsPerAddress) {
      events?.emit('inbox.quota_rejected', { address, reason: 'address-quota' });
      throw new ValidationError(
        `Recipient inbox is full (${currentCount} >= ${quota.maxBlobsPerAddress})`,
      );
    }

    // V4.8: capture sender fingerprint at PUT time. The sender's
    // signing key was just verified for this request, so the fingerprint
    // is bound to the same authentication path that authorized the
    // store. Surfaced on bridge push + inbox-fetch responses to
    // bootstrap unknown-sender first-contact (X3DH pair handshake).
    const senderFp = await shortHash(senderKey);

    const result = await store.putBlob({
      address,
      msgId,
      ciphertext: ctBytes,
      expiresAt,
      senderFp,
    });
    if (result.created) {
      events?.emit('inbox.blob_stored', {
        address,
        msgId,
        bytes: ctBytes.length,
        ttlSeconds: ttl,
      });
    } else {
      events?.emit('inbox.blob_idempotent_replay', { address, msgId });
    }

    return c.json({
      ok: true,
      msgId,
      receivedAt: result.receivedAt,
      idempotent: !result.created,
    });
  });

  // ─── GET blobs (signed challenge by recipient) ─────────────
  // Auth model: recipient signs the canonical (address, sinceCursor,
  // signedAt) tuple. Server verifies against the address's registered
  // signing key. Cursor is opaque — clients pass back the highest
  // `receivedAt` seen so far.
  app.post('/v1/inbox/:address/fetch', async (c) => {
    const address = validateAddress(c.req.param('address'));
    if (rateLimitEnabled) await fetchRL.consume(`inbox-fetch:${address}`);

    const owner = await store.getAddressOwner(address);
    if (!owner) {
      return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
    }

    const rawBody = await c.req.text();
    if (rawBody.length > MAX_META_BODY_SIZE) {
      throw new ValidationError(`Request body too large`);
    }
    const body = JSON.parse(rawBody);
    let { sinceCursor } = body;
    if (sinceCursor === undefined || sinceCursor === null) sinceCursor = 0;
    if (typeof sinceCursor !== 'number' || !Number.isFinite(sinceCursor) || sinceCursor < 0) {
      throw new ValidationError('sinceCursor must be a non-negative number', 'sinceCursor');
    }

    // Bind the address to the signed payload to prevent cross-address replay.
    await verifyPayload(crypto, owner, { ...body, address });

    const now = Date.now();
    const rawRows = await store.fetchBlobs({
      address,
      sinceCursor,
      now,
      limit: quota.fetchPageLimit,
    });

    // V4.8.4 — drop blobs the bridge has already pushed to this address
    // within the grace window. This is the cross-channel dedup gate that
    // makes "one inbox.send ⇒ one observable delivery" hold even when
    // the recipient runs both a bridge subscription and inbox-poll. The
    // cursor still advances over the whole `rawRows` window so the
    // client doesn't get stuck behind suppressed blobs — pollOnce uses
    // `nextCursor` (max receivedAt seen by the server, suppressed or
    // not) for the next fetch.
    const rows = options.bridgeDeliveryLog
      ? options.bridgeDeliveryLog.filterRecent(address, rawRows, now)
      : rawRows;

    let bytes = 0;
    const blobs = rows.map((r) => {
      bytes += r.ciphertext.length;
      const out: {
        msgId: string;
        ciphertext: string;
        receivedAt: number;
        expiresAt: number;
        from?: string;
      } = {
        msgId: r.msgId,
        ciphertext: toBase64(r.ciphertext),
        receivedAt: r.receivedAt,
        expiresAt: r.expiresAt,
      };
      // V4.8: surface sender fingerprint when present. Empty for blobs
      // persisted by a pre-4.8 relay that didn't track sender provenance.
      if (r.senderFp) out.from = r.senderFp;
      return out;
    });
    // Advance the cursor past the FULL rawRows window — including blobs
    // we suppressed because the bridge already pushed them. If we
    // anchored the cursor on `rows` only, suppressed blobs in the
    // middle of the window would block all subsequent fetches forever
    // (re-fetched on every poll, re-suppressed, no progress). The
    // bridge-delivery contract is "the bridge frame is the canonical
    // delivery"; if the recipient missed processing it, they fall back
    // to ack-via-DELETE or the blob ages out at TTL — same as a
    // recipient that crashes mid-handler in the no-bridge case.
    const cursorAnchor = rawRows.length > 0 ? rawRows[rawRows.length - 1]!.receivedAt : sinceCursor;
    const nextCursor = cursorAnchor;

    events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes });

    return c.json({
      blobs,
      cursor: nextCursor,
      hasMore: rawRows.length === quota.fetchPageLimit,
    });
  });

  // ─── DELETE a single blob (signed challenge by recipient) ──
  app.delete('/v1/inbox/:address/:msgId', async (c) => {
    const address = validateAddress(c.req.param('address'));
    if (rateLimitEnabled) await deleteRL.consume(`inbox-delete:${address}`);

    const msgId = c.req.param('msgId');
    if (!isValidMsgId(msgId)) {
      throw new ValidationError('msgId must be 64 lowercase hex chars', 'msgId');
    }

    const owner = await store.getAddressOwner(address);
    if (!owner) {
      return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
    }

    const body = await c.req.json();
    await verifyPayload(crypto, owner, { ...body, address, msgId });

    const removed = await store.deleteBlob(address, msgId);
    if (removed) {
      events?.emit('inbox.blob_acked', { address, msgId });
    }
    // Drop any bridge-delivery mark — keeps the log bounded under
    // sustained traffic (otherwise long-lived addresses accumulate
    // entries even after the underlying blob is gone).
    options.bridgeDeliveryLog?.forget(address, msgId);
    return c.json({ ok: removed });
  });

  return app;
}

// ─── Base64 helpers ──────────────────────────────────────────

function b64ToBytes(s: string): Uint8Array {
  return fromBase64(s);
}
