import { randomBytes } from "node:crypto";

import sodium from "libsodium-wrappers";

import { and, eq, isNull } from "@turbostarter/db";
import { invite, mesh, meshMember, pendingInvite } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";

import type {
  CreateEmailInviteInput,
  CreateMyInviteInput,
  CreateMyMeshInput,
} from "../../schema";

const BROKER_URL =
  process.env.NEXT_PUBLIC_BROKER_URL ?? "wss://ic.claudemesh.com/ws";
const APP_URL = process.env.NEXT_PUBLIC_URL ?? "https://claudemesh.com";

/**
 * Canonical invite bytes — MUST match the broker's canonicalInvite()
 * in apps/broker/src/crypto.ts exactly. Any delimiter/field change
 * between signer and verifier produces `invite_bad_signature`.
 */
const canonicalInvite = (p: {
  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 =>
  `${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`;

/**
 * v2 canonical invite bytes — format is LOCKED and MUST match
 * `canonicalInviteV2` in apps/broker/src/crypto.ts exactly. The broker
 * recomputes this on every claim and compares byte-for-byte against the
 * signed `capabilityV2.canonical` stored on the invite row. Any drift
 * between this string and the broker's version produces `bad_signature`.
 *
 * No root_key and no broker_url: the v2 protocol moves the root_key out
 * of the URL and the broker is the authority for where the key lives.
 */
const canonicalInviteV2 = (p: {
  mesh_id: string;
  invite_id: string;
  expires_at: number; // unix seconds
  role: "admin" | "member";
  owner_pubkey: string; // hex
}): string =>
  `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;

/**
 * Derive the broker's HTTP base URL from the configured WebSocket URL.
 * `wss://host/ws` → `https://host`, `ws://host/ws` → `http://host`.
 * The claim endpoint lives at `${base}/invites/:code/claim`.
 */
export const brokerHttpBase = (): string => {
  const wsUrl = BROKER_URL;
  const httpUrl = wsUrl
    .replace(/^wss:\/\//, "https://")
    .replace(/^ws:\/\//, "http://")
    .replace(/\/ws\/?$/, "")
    .replace(/\/$/, "");
  return httpUrl;
};

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

/**
 * Slugify a display name into a URL-safe token. Used only as cosmetic
 * metadata embedded in invite payloads for debugging/display — NOT as a
 * canonical identifier. `mesh.id` (opaque) is the canonical identity.
 */
const toSlug = (name: string): string =>
  name
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 40) || "mesh";

/**
 * Base62 alphabet excluding visually ambiguous characters (0, O, I, l, 1).
 * 57 symbols × 8 positions ≈ 1.1e14 combinations — birthday collision at
 * ~10M invites, fine for years. We retry-on-conflict at insert time anyway.
 */
const SHORTCODE_ALPHABET =
  "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
const generateShortCode = (len = 8): string => {
  const bytes = randomBytes(len);
  let out = "";
  for (let i = 0; i < len; i++) {
    out += SHORTCODE_ALPHABET[bytes[i]! % SHORTCODE_ALPHABET.length];
  }
  return out;
};

export const createMyMesh = async ({
  userId,
  input,
}: {
  userId: string;
  input: CreateMyMeshInput;
}) => {
  // Slug is derived from name and stored non-uniquely — meshes are identified
  // by `mesh.id` (opaque). Two users can freely name their meshes "platform".
  const slug = toSlug(input.name);

  // Generate the mesh owner's ed25519 keypair (signs invites) and a
  // 32-byte shared root key (channel encryption in later steps).
  // See mesh.ownerSecretKey comment re: plaintext-at-rest trade-off.
  const s = await ensureSodium();
  const kp = s.crypto_sign_keypair();
  const ownerPubkey = s.to_hex(kp.publicKey);
  const ownerSecretKey = s.to_hex(kp.privateKey);
  const rootKey = s.to_base64(
    s.randombytes_buf(32),
    s.base64_variants.URLSAFE_NO_PADDING,
  );

  const [created] = await db
    .insert(mesh)
    .values({
      name: input.name,
      slug,
      visibility: input.visibility,
      transport: input.transport,
      ownerUserId: userId,
      ownerPubkey,
      ownerSecretKey,
      rootKey,
    })
    .returning({ id: mesh.id, slug: mesh.slug });

  return created!;
};

export const archiveMyMesh = async ({
  userId,
  meshId,
}: {
  userId: string;
  meshId: string;
}) => {
  const [updated] = await db
    .update(mesh)
    .set({ archivedAt: new Date() })
    .where(and(eq(mesh.id, meshId), eq(mesh.ownerUserId, userId)))
    .returning({ id: mesh.id });

  if (!updated) {
    throw new Error("Mesh not found or you are not the owner.");
  }
  return updated;
};

export const leaveMyMesh = async ({
  userId,
  meshId,
}: {
  userId: string;
  meshId: string;
}) => {
  const [updated] = await db
    .update(meshMember)
    .set({ revokedAt: new Date() })
    .where(
      and(
        eq(meshMember.meshId, meshId),
        eq(meshMember.userId, userId),
        isNull(meshMember.revokedAt),
      ),
    )
    .returning({ id: meshMember.id });

  if (!updated) {
    throw new Error("You are not a member of this mesh.");
  }
  return updated;
};

export const createMyInvite = async ({
  userId,
  meshId,
  input,
}: {
  userId: string;
  meshId: string;
  input: CreateMyInviteInput;
}) => {
  // Authz: owner or admin member can invite.
  const [meshRow] = await db
    .select({
      id: mesh.id,
      slug: mesh.slug,
      ownerUserId: mesh.ownerUserId,
      ownerPubkey: mesh.ownerPubkey,
      ownerSecretKey: mesh.ownerSecretKey,
      rootKey: mesh.rootKey,
    })
    .from(mesh)
    .where(eq(mesh.id, meshId))
    .limit(1);

  if (!meshRow) {
    throw new Error("Mesh not found.");
  }
  if (
    !meshRow.ownerPubkey ||
    !meshRow.ownerSecretKey ||
    !meshRow.rootKey
  ) {
    throw new Error(
      "Mesh is missing owner keypair or root key — run backfill script.",
    );
  }

  const isOwner = meshRow.ownerUserId === userId;
  if (!isOwner) {
    const [membership] = await db
      .select({ role: meshMember.role })
      .from(meshMember)
      .where(
        and(
          eq(meshMember.meshId, meshId),
          eq(meshMember.userId, userId),
          isNull(meshMember.revokedAt),
        ),
      )
      .limit(1);
    if (!membership || membership.role !== "admin") {
      throw new Error("Only owners and admins can issue invites.");
    }
  }

  const expiresAt = new Date(
    Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000,
  );
  const expiresAtSec = Math.floor(expiresAt.getTime() / 1000);

  // Build the canonical signed payload. Signature covers every field
  // except `signature` itself; broker re-verifies identically.
  const payloadCore = {
    v: 1 as const,
    mesh_id: meshRow.id,
    mesh_slug: meshRow.slug,
    broker_url: BROKER_URL,
    expires_at: expiresAtSec,
    mesh_root_key: meshRow.rootKey,
    role: input.role,
    owner_pubkey: meshRow.ownerPubkey,
  };
  const canonical = canonicalInvite(payloadCore);
  const s = await ensureSodium();
  const signature = s.to_hex(
    s.crypto_sign_detached(
      s.from_string(canonical),
      s.from_hex(meshRow.ownerSecretKey),
    ),
  );
  const fullPayload = { ...payloadCore, signature };

  // The base64url(JSON) is BOTH the link payload AND the DB lookup
  // token — broker's /join resolves invites by this string.
  const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString(
    "base64url",
  );

  // Short URL shortener code. Retry on the (extremely unlikely) collision
  // against the unique index. 3 attempts is plenty given the keyspace.
  let code = generateShortCode();
  let created:
    | { id: string; token: string; code: string | null; expiresAt: Date }
    | undefined;
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const rows = await db
        .insert(invite)
        .values({
          meshId,
          token,
          tokenBytes: canonical,
          code,
          maxUses: input.maxUses,
          role: input.role,
          expiresAt,
          createdBy: userId,
          // v2 starts here — capabilityV2 is backfilled below in a second
          // UPDATE because the canonical bytes depend on invite.id which
          // we only know post-insert.
          version: 2,
        })
        .returning({
          id: invite.id,
          token: invite.token,
          code: invite.code,
          expiresAt: invite.expiresAt,
        });
      created = rows[0];
      break;
    } catch (e) {
      // Only retry on short-code collision; rethrow anything else.
      if (e instanceof Error && e.message.includes("invite_code_unique_idx")) {
        code = generateShortCode();
        continue;
      }
      throw e;
    }
  }
  if (!created) {
    throw new Error("Could not allocate a unique invite code — retry.");
  }

  // --- v2 capability: sign canonical bytes that include the invite id ---
  // The broker recomputes these exact bytes on claim and verifies the
  // signature against mesh.ownerPubkey. Stored shape is the JSON literal
  // the broker expects in `invite.capabilityV2`:
  //   { "canonical": "v=2|...", "signature": "<hex>" }
  // We reuse the existing `capabilityV2` text column — no schema change.
  const canonicalV2 = canonicalInviteV2({
    mesh_id: meshRow.id,
    invite_id: created.id,
    expires_at: expiresAtSec,
    role: input.role,
    owner_pubkey: meshRow.ownerPubkey,
  });
  const signatureV2 = s.to_hex(
    s.crypto_sign_detached(
      s.from_string(canonicalV2),
      s.from_hex(meshRow.ownerSecretKey),
    ),
  );
  const capabilityV2Json = JSON.stringify({
    canonical: canonicalV2,
    signature: signatureV2,
  });
  await db
    .update(invite)
    .set({ capabilityV2: capabilityV2Json })
    .where(eq(invite.id, created.id));

  const appBase = APP_URL.replace(/\/$/, "");
  return {
    id: created.id,
    token: created.token,
    code: created.code,
    expiresAt: created.expiresAt,
    inviteLink: `ic://join/${token}`,
    joinUrl: `${appBase}/join/${token}`,
    // The human-friendly short URL. Redirects to joinUrl server-side.
    // Prefer this when sharing. See spec for why this is NOT a capability
    // boundary (the long token still carries the root_key).
    shortUrl: created.code ? `${appBase}/i/${created.code}` : null,
    // v2 surface: safe to share (no root_key, no secrets).
    version: 2 as const,
    canonicalV2,
    ownerPubkey: meshRow.ownerPubkey,
  };
};

// ---------------------------------------------------------------------
// Email invites (v2 only)
// ---------------------------------------------------------------------

/**
 * Send a mesh invite by email. Mints a normal v2 invite (same short code
 * path as `createMyInvite`), then records a `pending_invite` row tying
 * `(mesh, email)` to the underlying invite code. Delivery goes through
 * the email provider if one is wired; otherwise we log a TODO and
 * return success so the rest of the flow is testable end-to-end.
 *
 * The email body contains `${APP_URL}/i/${code}` — the exact same short
 * URL that link-shares use. No new user-visible surface.
 */
export const createEmailInvite = async ({
  userId,
  meshId,
  input,
}: {
  userId: string;
  meshId: string;
  input: CreateEmailInviteInput;
}) => {
  // Reuse createMyInvite — all authz, signing, and short-code collision
  // logic lives there. We only add the pending_invite row + email send.
  const minted = await createMyInvite({
    userId,
    meshId,
    input: {
      role: input.role,
      maxUses: input.maxUses,
      expiresInDays: input.expiresInDays,
    },
  });

  if (!minted.code) {
    // Should never happen — createMyInvite always allocates a code now.
    throw new Error("Could not mint an email invite (no short code).");
  }

  const [pending] = await db
    .insert(pendingInvite)
    .values({
      meshId,
      email: input.email,
      code: minted.code,
      createdBy: userId,
    })
    .returning({ id: pendingInvite.id });

  if (!pending) {
    throw new Error("Could not record pending invite row.");
  }

  const appBase = APP_URL.replace(/\/$/, "");
  const shortUrl = `${appBase}/i/${minted.code}`;

  // Fire-and-forget-ish send. Failures are logged but do NOT roll back
  // the invite — the admin can copy the short URL from the dashboard.
  await sendEmailInvite({
    to: input.email,
    shortUrl,
    inviterUserId: userId,
    meshId,
  });

  return {
    pendingInviteId: pending.id,
    code: minted.code,
    email: input.email,
    shortUrl,
    expiresAt: minted.expiresAt,
  };
};

/**
 * Deliver the email that carries a `claudemesh.com/i/{code}` short URL.
 *
 * TODO: wire this to the turbostarter Postmark provider. The email
 * package exposes `sendEmail` via a template system; adding a new
 * template file lives in `packages/email/**` which is out of scope for
 * this wave. For now we log the intended send so the upstream mutation
 * resolves cleanly and the rest of the flow is integration-testable.
 */
const sendEmailInvite = async (params: {
  to: string;
  shortUrl: string;
  inviterUserId: string;
  meshId: string;
}): Promise<void> => {
  // eslint-disable-next-line no-console
  console.warn(
    "[claudemesh] TODO: wire email invite to Postmark provider",
    {
      to: params.to,
      shortUrl: params.shortUrl,
      inviterUserId: params.inviterUserId,
      meshId: params.meshId,
    },
  );
};
