/**
 * Broker-side ed25519 verification helpers.
 *
 * Used to authenticate the WS hello handshake: clients sign a canonical
 * byte string with their mesh.member.peerPubkey's secret key, broker
 * verifies with the claimed pubkey, then cross-checks the pubkey is a
 * current member of the claimed mesh.
 */

import { and, eq, isNull, lt, sql } from "drizzle-orm";
import sodium from "libsodium-wrappers";
import { db } from "./db";
import { invite as inviteTable, mesh, meshMember, meshTopic, meshTopicMember } from "@turbostarter/db/schema/mesh";

let ready = false;
async function ensureSodium(): Promise<typeof sodium> {
  if (!ready) {
    await sodium.ready;
    ready = true;
  }
  return sodium;
}

/** Canonical hello bytes: clients sign this, broker verifies this. */
export function canonicalHello(
  meshId: string,
  memberId: string,
  pubkey: string,
  timestamp: number,
): string {
  return `${meshId}|${memberId}|${pubkey}|${timestamp}`;
}

/** Canonical invite bytes — everything in the payload except the signature. */
export function canonicalInvite(fields: {
  v: number;
  mesh_id: string;
  mesh_slug: string;
  broker_url: string;
  expires_at: number;
  mesh_root_key: string;
  role: "admin" | "member";
  owner_pubkey: string;
}): string {
  return `${fields.v}|${fields.mesh_id}|${fields.mesh_slug}|${fields.broker_url}|${fields.expires_at}|${fields.mesh_root_key}|${fields.role}|${fields.owner_pubkey}`;
}

/**
 * Verify an ed25519 signature over arbitrary canonical bytes.
 * Used by invite verification + (future) any other signed payload.
 */
export async function verifyEd25519(
  canonicalText: string,
  signatureHex: string,
  pubkeyHex: string,
): Promise<boolean> {
  if (
    !/^[0-9a-f]{64}$/i.test(pubkeyHex) ||
    !/^[0-9a-f]{128}$/i.test(signatureHex)
  ) {
    return false;
  }
  const s = await ensureSodium();
  try {
    return s.crypto_sign_verify_detached(
      s.from_hex(signatureHex),
      s.from_string(canonicalText),
      s.from_hex(pubkeyHex),
    );
  } catch {
    return false;
  }
}

/**
 * Canonical v2 invite bytes — signed by the mesh owner's ed25519 secret key.
 * NOTE: deliberately does NOT include the root_key or broker_url; the v2
 * protocol moves the root_key out of the URL entirely. Format is locked:
 * `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` (no trailing newline).
 */
export function canonicalInviteV2(p: {
  mesh_id: string;
  invite_id: string;
  expires_at: number; // unix seconds
  role: "admin" | "member";
  owner_pubkey: string; // hex
}): string {
  return `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;
}

/**
 * Verify an ed25519 signature over the v2 canonical invite bytes against
 * the mesh owner's public key. Returns true on valid signature.
 */
export async function verifyInviteV2(params: {
  canonical: string;
  signatureHex: string;
  ownerPubkeyHex: string;
}): Promise<boolean> {
  return verifyEd25519(
    params.canonical,
    params.signatureHex,
    params.ownerPubkeyHex,
  );
}

/**
 * Seal the mesh root_key to a recipient-provided x25519 public key using
 * libsodium's sealed box (crypto_box_seal). Only the holder of the matching
 * x25519 secret key can unseal.
 *
 *   rootKeyBase64url is the mesh.root_key column value (base64url of 32 bytes).
 *   recipientX25519PubkeyBase64url is the 32-byte x25519 pubkey the recipient
 *     provided in its claim request. We do NOT convert an ed25519 pubkey here —
 *     the recipient generates a dedicated x25519 keypair and sends us the pubkey.
 *
 * Returns base64url of the sealed ciphertext.
 */
export async function sealRootKeyToRecipient(params: {
  rootKeyBase64url: string;
  recipientX25519PubkeyBase64url: string;
}): Promise<string> {
  const s = await ensureSodium();
  const rootKeyBytes = s.from_base64(
    params.rootKeyBase64url,
    s.base64_variants.URLSAFE_NO_PADDING,
  );
  const recipientPk = s.from_base64(
    params.recipientX25519PubkeyBase64url,
    s.base64_variants.URLSAFE_NO_PADDING,
  );
  if (recipientPk.length !== 32) {
    throw new Error("recipient_x25519_pubkey must decode to 32 bytes");
  }
  const sealed = s.crypto_box_seal(rootKeyBytes, recipientPk);
  return s.to_base64(sealed, s.base64_variants.URLSAFE_NO_PADDING);
}

export const HELLO_SKEW_MS = 60_000;

/** Maximum lifetime of a parent attestation (24h). */
export const SESSION_ATTESTATION_MAX_TTL_MS = 24 * 60 * 60 * 1000;

/**
 * Canonical bytes for a parent-vouches-session attestation.
 *
 * The parent member signs this with their stable ed25519 secret key when
 * minting an attestation in `claudemesh launch`. The broker recomputes
 * the same string at session_hello time and verifies the signature
 * against `parent_member_pubkey`.
 *
 * Format: `claudemesh-session-attest|<parent_pubkey>|<session_pubkey>|<expires_at_ms>`
 */
export function canonicalSessionAttestation(
  parentMemberPubkey: string,
  sessionPubkey: string,
  expiresAt: number,
): string {
  return `claudemesh-session-attest|${parentMemberPubkey}|${sessionPubkey}|${expiresAt}`;
}

/**
 * Canonical bytes for the session_hello signature.
 *
 * The session keypair (held by the daemon for the lifetime of the
 * registration) signs this fresh on every WS connect, proving liveness +
 * possession of the session secret key. Without this stage, an attacker
 * who captured an attestation could replay it from any machine.
 *
 * Format: `claudemesh-session-hello|<mesh_id>|<parent_pubkey>|<session_pubkey>|<timestamp_ms>`
 */
export function canonicalSessionHello(
  meshId: string,
  parentMemberPubkey: string,
  sessionPubkey: string,
  timestamp: number,
): string {
  return `claudemesh-session-hello|${meshId}|${parentMemberPubkey}|${sessionPubkey}|${timestamp}`;
}

/**
 * Validate a parent-vouches-session attestation: lifetime bound + signature.
 * Returns `{ ok: true }` on success or `{ ok: false, reason }` on failure.
 *
 * The TTL ceiling (24h) bounds replay damage if an attestation leaks; the
 * lower bound (already in the past) blocks reuse of expired ones.
 */
export async function verifySessionAttestation(args: {
  parentMemberPubkey: string;
  sessionPubkey: string;
  expiresAt: number;
  signature: string;
  now?: number;
}): Promise<
  | { ok: true }
  | { ok: false; reason: "expired" | "ttl_too_long" | "bad_signature" | "malformed" }
> {
  const now = args.now ?? Date.now();
  if (!Number.isFinite(args.expiresAt)) {
    return { ok: false, reason: "malformed" };
  }
  if (args.expiresAt <= now) {
    return { ok: false, reason: "expired" };
  }
  if (args.expiresAt > now + SESSION_ATTESTATION_MAX_TTL_MS) {
    return { ok: false, reason: "ttl_too_long" };
  }
  if (
    !/^[0-9a-f]{64}$/i.test(args.parentMemberPubkey) ||
    !/^[0-9a-f]{64}$/i.test(args.sessionPubkey) ||
    !/^[0-9a-f]{128}$/i.test(args.signature)
  ) {
    return { ok: false, reason: "malformed" };
  }
  const canonical = canonicalSessionAttestation(
    args.parentMemberPubkey,
    args.sessionPubkey,
    args.expiresAt,
  );
  const ok = await verifyEd25519(canonical, args.signature, args.parentMemberPubkey);
  return ok ? { ok: true } : { ok: false, reason: "bad_signature" };
}

/**
 * Validate the session-side hello signature: timestamp skew + signature
 * by the session keypair over canonical session-hello bytes.
 */
export async function verifySessionHelloSignature(args: {
  meshId: string;
  parentMemberPubkey: string;
  sessionPubkey: string;
  timestamp: number;
  signature: string;
  now?: number;
}): Promise<
  | { ok: true }
  | { ok: false; reason: "timestamp_skew" | "bad_signature" | "malformed" }
> {
  const now = args.now ?? Date.now();
  if (
    !Number.isFinite(args.timestamp) ||
    Math.abs(now - args.timestamp) > HELLO_SKEW_MS
  ) {
    return { ok: false, reason: "timestamp_skew" };
  }
  if (
    !/^[0-9a-f]{64}$/i.test(args.parentMemberPubkey) ||
    !/^[0-9a-f]{64}$/i.test(args.sessionPubkey) ||
    !/^[0-9a-f]{128}$/i.test(args.signature)
  ) {
    return { ok: false, reason: "malformed" };
  }
  const canonical = canonicalSessionHello(
    args.meshId,
    args.parentMemberPubkey,
    args.sessionPubkey,
    args.timestamp,
  );
  const ok = await verifyEd25519(canonical, args.signature, args.sessionPubkey);
  return ok ? { ok: true } : { ok: false, reason: "bad_signature" };
}

/**
 * Verify a hello's ed25519 signature + timestamp skew.
 * Returns { ok: true } on success, or { ok: false, reason } describing
 * which check failed (for structured error response).
 */
export async function verifyHelloSignature(args: {
  meshId: string;
  memberId: string;
  pubkey: string;
  timestamp: number;
  signature: string;
  now?: number;
}): Promise<
  | { ok: true }
  | { ok: false; reason: "timestamp_skew" | "bad_signature" | "malformed" }
> {
  const now = args.now ?? Date.now();
  if (
    !Number.isFinite(args.timestamp) ||
    Math.abs(now - args.timestamp) > HELLO_SKEW_MS
  ) {
    return { ok: false, reason: "timestamp_skew" };
  }
  if (
    !/^[0-9a-f]{64}$/i.test(args.pubkey) ||
    !/^[0-9a-f]{128}$/i.test(args.signature)
  ) {
    return { ok: false, reason: "malformed" };
  }
  const s = await ensureSodium();
  try {
    const canonical = canonicalHello(
      args.meshId,
      args.memberId,
      args.pubkey,
      args.timestamp,
    );
    const ok = s.crypto_sign_verify_detached(
      s.from_hex(args.signature),
      s.from_string(canonical),
      s.from_hex(args.pubkey),
    );
    return ok ? { ok: true } : { ok: false, reason: "bad_signature" };
  } catch {
    return { ok: false, reason: "malformed" };
  }
}

// ----------------------------------------------------------------------------
// v2 invite claim core — exported for the HTTP handler in index.ts AND for
// tests that need to exercise the logic without spinning up the broker server.
// ----------------------------------------------------------------------------
//
// capabilityV2 column is stored as JSON:
//   { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
//     "signature": "<hex ed25519 detached signature>" }
// The broker recomputes the canonical bytes from the invite row and verifies
// the signature against mesh.ownerPubkey. v1 rows (version === 1 OR
// capabilityV2 === null) skip verification — the legacy path still works
// during the deprecation window.

export type InviteClaimV2Result =
  | {
      ok: true;
      status: 200;
      body: {
        sealed_root_key: string;
        mesh_id: string;
        member_id: string;
        owner_pubkey: string;
        canonical_v2: string;
      };
    }
  | { ok: false; status: 400 | 404 | 410; body: { error: string } };

export async function claimInviteV2Core(params: {
  code: string;
  recipientX25519PubkeyBase64url: string;
  displayName?: string;
  now?: number;
}): Promise<InviteClaimV2Result> {
  const now = params.now ?? Date.now();
  const recipientPk = params.recipientX25519PubkeyBase64url;

  if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) {
    return { ok: false, status: 400, body: { error: "malformed" } };
  }

  // 1. Look up the invite by opaque code.
  const [inv] = await db
    .select()
    .from(inviteTable)
    .where(eq(inviteTable.code, params.code))
    .limit(1);
  if (!inv) return { ok: false, status: 404, body: { error: "not_found" } };

  // 2. Lifecycle checks: revoked → expired → exhausted.
  if (inv.revokedAt) {
    return { ok: false, status: 410, body: { error: "revoked" } };
  }
  if (inv.expiresAt.getTime() < now) {
    return { ok: false, status: 410, body: { error: "expired" } };
  }
  if (inv.usedCount >= inv.maxUses) {
    return { ok: false, status: 410, body: { error: "exhausted" } };
  }

  // 3. Load the mesh for owner_pubkey + root_key.
  const [m] = await db
    .select({
      id: mesh.id,
      ownerPubkey: mesh.ownerPubkey,
      rootKey: mesh.rootKey,
    })
    .from(mesh)
    .where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt)))
    .limit(1);
  if (!m) return { ok: false, status: 404, body: { error: "not_found" } };
  if (!m.ownerPubkey || !m.rootKey) {
    return { ok: false, status: 400, body: { error: "malformed" } };
  }

  // 4. Compute canonical_v2 from the row (used in the response either way).
  const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000);
  const canonical = canonicalInviteV2({
    mesh_id: inv.meshId,
    invite_id: inv.id,
    expires_at: expiresAtUnix,
    role: inv.role as "admin" | "member",
    owner_pubkey: m.ownerPubkey,
  });

  if (inv.version === 2 && inv.capabilityV2) {
    let storedCanonical: string | undefined;
    let signatureHex: string | undefined;
    try {
      const parsed = JSON.parse(inv.capabilityV2) as {
        canonical?: string;
        signature?: string;
      };
      storedCanonical = parsed.canonical;
      signatureHex = parsed.signature;
    } catch {
      return { ok: false, status: 400, body: { error: "malformed" } };
    }
    if (!storedCanonical || !signatureHex) {
      return { ok: false, status: 400, body: { error: "malformed" } };
    }
    // Broker-recomputed canonical must match the signed bytes exactly.
    if (storedCanonical !== canonical) {
      return { ok: false, status: 400, body: { error: "bad_signature" } };
    }
    const sigOk = await verifyInviteV2({
      canonical: storedCanonical,
      signatureHex,
      ownerPubkeyHex: m.ownerPubkey,
    });
    if (!sigOk) {
      return { ok: false, status: 400, body: { error: "bad_signature" } };
    }
  }
  // v1 rows: skip signature verification (legacy path during migration).

  // 5. Atomic consume: increment used_count iff still under max_uses.
  const [claimed] = await db
    .update(inviteTable)
    .set({
      usedCount: sql`${inviteTable.usedCount} + 1`,
      claimedByPubkey: recipientPk,
    })
    .where(
      and(
        eq(inviteTable.id, inv.id),
        lt(inviteTable.usedCount, inv.maxUses),
      ),
    )
    .returning({ id: inviteTable.id });
  if (!claimed) {
    return { ok: false, status: 410, body: { error: "exhausted" } };
  }

  // 6. Create a member row for the claimant.
  const preset = (inv.preset as {
    displayName?: string;
    roleTag?: string;
    groups?: Array<{ name: string; role?: string }>;
    messageMode?: string;
  } | null) ?? {};
  const displayName =
    preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`;
  const [row] = await db
    .insert(meshMember)
    .values({
      meshId: inv.meshId,
      peerPubkey: recipientPk,
      displayName,
      role: inv.role,
      roleTag: preset.roleTag ?? null,
      defaultGroups: preset.groups ?? [],
      messageMode: preset.messageMode ?? "push",
    })
    .returning({ id: meshMember.id });
  if (!row) {
    return { ok: false, status: 400, body: { error: "malformed" } };
  }

  // 6b. Auto-subscribe the new member to #general (the default mesh-wide
  // room). Idempotent via unique (topic_id, member_id). If the mesh was
  // created before #general auto-creation existed, ensure it now via a
  // best-effort INSERT … ON CONFLICT — backfill migration handles the
  // bulk case so this is just a safety net.
  await db
    .insert(meshTopic)
    .values({
      meshId: inv.meshId,
      name: "general",
      description: "Default mesh-wide channel. Every member can read and post.",
      visibility: "public",
    })
    .onConflictDoNothing();
  const [generalTopic] = await db
    .select({ id: meshTopic.id })
    .from(meshTopic)
    .where(and(eq(meshTopic.meshId, inv.meshId), eq(meshTopic.name, "general")))
    .limit(1);
  if (generalTopic) {
    await db
      .insert(meshTopicMember)
      .values({ topicId: generalTopic.id, memberId: row.id, role: "member" })
      .onConflictDoNothing();
  }

  // 7. Seal the mesh root_key to the recipient's x25519 pubkey.
  let sealed: string;
  try {
    sealed = await sealRootKeyToRecipient({
      rootKeyBase64url: m.rootKey,
      recipientX25519PubkeyBase64url: recipientPk,
    });
  } catch {
    return { ok: false, status: 400, body: { error: "malformed" } };
  }

  return {
    ok: true,
    status: 200,
    body: {
      sealed_root_key: sealed,
      mesh_id: inv.meshId,
      member_id: row.id,
      owner_pubkey: m.ownerPubkey,
      canonical_v2: canonical,
    },
  };
}
