import { Hono } from 'hono';
import type { CryptoProvider } from '@shade/core';
import {
  errorToHttpStatus,
  ShadeError,
  ValidationError,
  UnauthorizedError,
  fromBase64,
  toBase64,
  constantTimeEqual,
} from '@shade/core';
import {
  verifyPayload,
  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 { BlobStore } from './blob-store.js';

/**
 * Wire-level wrapper around the V4.9 BlobStore primitive.
 *
 * Endpoints:
 *   GET    /v1/blob/:slotId              → { blob, etag } | 404
 *   PUT    /v1/blob/:slotId              → { etag, created } | 409 | 412
 *   DELETE /v1/blob/:slotId              → { ok }
 *
 * SlotId is 64 lowercase hex chars (the HKDF output, 32 bytes). Payloads
 * are base64-encoded ciphertext; the relay never decrypts. Auth uses
 * `signPayload` / `verifyPayload` (same canonical-JSON-and-Ed25519
 * scheme as the inbox routes), keyed off the per-slot pubkey stored
 * TOFU on the first PUT.
 *
 * Quota: a single slot holds one blob. `MAX_BLOB_BYTES` (64 KiB) is
 * sized for Prism's profile use-case (a few hundred host entries) with
 * plenty of headroom; future apps can override via `BlobRoutesOptions`.
 */
const SLOT_ID_REGEX = /^[0-9a-f]{64}$/;
const MAX_META_BODY_SIZE = 64 * 1024;
/** Default per-slot blob ceiling. Sized for ~500 host entries in JSON form. */
export const DEFAULT_MAX_BLOB_BYTES = 64 * 1024;

const PUT_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
const GET_LIMIT: RateLimitConfig = { capacity: 120, refillPerSecond: 2 };
const DELETE_LIMIT: RateLimitConfig = { capacity: 30, refillPerSecond: 1 };

export interface BlobRoutesOptions {
  disableRateLimit?: boolean;
  observability?: ObservabilityHook;
  /** Per-blob byte ceiling. Defaults to 64 KiB. */
  maxBlobBytes?: number;
}

export function createBlobRoutes(
  store: BlobStore,
  crypto: CryptoProvider,
  options: BlobRoutesOptions = {},
): Hono {
  const app = new Hono();
  const observability = options.observability ?? NOOP_HOOK;
  const maxBlobBytes = options.maxBlobBytes ?? DEFAULT_MAX_BLOB_BYTES;

  app.use('*', async (c, next) => {
    const route = c.req.routePath ?? c.req.path ?? '<unknown>';
    const span = observability.startSpan('shade.blob.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, PUT_LIMIT);
  const getRL = new RateLimiter(rlStore, GET_LIMIT);
  const deleteRL = new RateLimiter(rlStore, DELETE_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.onError((err, 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 blob error:', err);
    return c.json({ error: 'Internal server error' }, 500);
  });

  function validateSlotId(raw: string | undefined): string {
    if (typeof raw !== 'string' || !SLOT_ID_REGEX.test(raw)) {
      throw new ValidationError(
        'slotId must be 64 lowercase hex chars (32 bytes)',
        'slotId',
      );
    }
    return raw;
  }

  // ─── GET ─────────────────────────────────────────────────────
  // Unauthenticated. SlotId is itself a 256-bit secret derived from the
  // master key — knowing it implies you derived the master, which is
  // equivalent to holding the credentials. The blob is AEAD-sealed, so
  // a relay-side leak of slotId still cannot decrypt the contents.
  app.get('/v1/blob/:slotId', async (c) => {
    const slotId = validateSlotId(c.req.param('slotId'));
    if (rateLimitEnabled) await getRL.consume(`blob-get:${getClientIp(c)}`);

    const row = await store.get(slotId);
    if (!row) {
      return c.json({ error: 'Slot not found', code: 'SHADE_NOT_FOUND' }, 404);
    }
    return c.json({
      blob: toBase64(row.blob),
      etag: String(row.etag),
      updatedAt: row.updatedAt,
    });
  });

  // ─── PUT ─────────────────────────────────────────────────────
  // Body format:
  //   {
  //     ownerPubkey: b64,        // Ed25519 pubkey deterministically
  //                              // derived from the master via HKDF.
  //     blob: b64,
  //     ifMatch?: string,        // "<etag>" | "*" | undefined
  //     signedAt: number,
  //     signature: b64           // over the canonical body sans signature
  //   }
  //
  // First write to a slot is TOFU: we record `ownerPubkey` and require
  // any future write to verify against it. A different key trying to
  // overwrite an existing slot is rejected with UnauthorizedError.
  app.put('/v1/blob/:slotId', async (c) => {
    const slotId = validateSlotId(c.req.param('slotId'));
    if (rateLimitEnabled) await putRL.consume(`blob-put:${getClientIp(c)}`);

    const rawBody = await c.req.text();
    const hardLimit = Math.ceil(maxBlobBytes * 1.4) + MAX_META_BODY_SIZE;
    if (rawBody.length > hardLimit) {
      throw new ValidationError(`Request body too large`);
    }
    const body = JSON.parse(rawBody);
    const { ownerPubkey, blob, ifMatch } = body;

    if (typeof ownerPubkey !== 'string') {
      throw new ValidationError('Missing ownerPubkey', 'ownerPubkey');
    }
    if (typeof blob !== 'string') {
      throw new ValidationError('Missing blob', 'blob');
    }
    const claimedKey = fromBase64(ownerPubkey);
    if (claimedKey.length !== 32) {
      throw new ValidationError('ownerPubkey must be 32 bytes (Ed25519)', 'ownerPubkey');
    }
    const blobBytes = fromBase64(blob);
    if (blobBytes.length === 0) {
      throw new ValidationError('blob is empty', 'blob');
    }
    if (blobBytes.length > maxBlobBytes) {
      throw new ValidationError(
        `blob exceeds maxBlobBytes (${blobBytes.length} > ${maxBlobBytes})`,
        'blob',
      );
    }

    let expectedEtag: number | '*' | undefined;
    if (ifMatch === undefined) {
      expectedEtag = undefined;
    } else if (typeof ifMatch !== 'string') {
      throw new ValidationError('ifMatch must be a string when present', 'ifMatch');
    } else if (ifMatch === '*') {
      expectedEtag = '*';
    } else {
      const n = Number(ifMatch);
      if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
        throw new ValidationError('ifMatch must be a non-negative integer or "*"', 'ifMatch');
      }
      expectedEtag = n;
    }

    // Existing slot: caller must sign with the original owner key. Use
    // the stored pubkey for verification. The body's `ownerPubkey` is
    // bound by the signature too, so an attacker cannot trick us into
    // verifying with a key they control — the canonicalization includes
    // every field but `signature`.
    const existing = await store.get(slotId);
    const verifyKey = existing ? existing.ownerPubkey : claimedKey;

    // Bind slotId into the signed payload so a signature for slot A
    // can't be replayed against slot B (the URL is otherwise outside
    // the signed bytes).
    await verifyPayload(crypto, verifyKey, { ...body, slotId });

    if (existing && !constantTimeEqual(existing.ownerPubkey, claimedKey)) {
      throw new UnauthorizedError(
        `Slot ${slotId} is owned by a different signing key`,
      );
    }

    const result = await store.put({
      slotId,
      blob: blobBytes,
      ownerPubkey: claimedKey,
      expectedEtag,
      now: Date.now(),
    });

    return c.json({
      ok: true,
      created: result.created,
      etag: String(result.etag),
      updatedAt: result.updatedAt,
    });
  });

  // ─── DELETE ──────────────────────────────────────────────────
  // Body format: { signedAt, signature }. Signed by the owner pubkey
  // recorded on the first PUT. After deletion, the slot is fully gone —
  // the next PUT TOFU-claims it again (potentially under a different
  // signing key, e.g. after a rotation).
  app.delete('/v1/blob/:slotId', async (c) => {
    const slotId = validateSlotId(c.req.param('slotId'));
    if (rateLimitEnabled) await deleteRL.consume(`blob-delete:${getClientIp(c)}`);

    const existing = await store.get(slotId);
    if (!existing) {
      return c.json({ error: 'Slot not found', 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);
    await verifyPayload(crypto, existing.ownerPubkey, { ...body, slotId });

    const removed = await store.delete(slotId);
    return c.json({ ok: removed });
  });

  return app;
}
