// This file contains Enterprise regions licensed under LICENSE_ENTERPRISE.
import {
  AUTO_PROVISIONED_INVITATION_STATUS,
  addNomicTaskPrefix,
  isModelSelectionComplete,
  providerRequiresPerUserCredential,
  RouteId,
  type SupportedProvider,
} from "@archestra/shared";
import { and, eq, inArray, like } from "drizzle-orm";
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod";
import { z } from "zod";
import config from "@/config";
import db, { schema } from "@/database";
import { syncBuiltInSkillsForOrganization } from "@/database/seed";
import mcpServerRuntimeManager from "@/k8s/mcp-server-runtime/manager";
import { callEmbedding } from "@/knowledge-base/embedding-clients";
import { resolveApiKeyFromChatApiKey } from "@/knowledge-base/kb-llm-client";
import logger from "@/logging";
import {
  AgentModel,
  InteractionModel,
  InternalMcpCatalogModel,
  InvitationModel,
  KbDocumentModel,
  KnowledgeBaseConnectorModel,
  LlmProviderApiKeyModel,
  McpToolCallModel,
  MemberModel,
  ModelModel,
  OrganizationModel,
  ToolModel,
  UserModel,
  UserTokenModel,
} from "@/models";
import { reconcileCatalogDeployments } from "@/services/environments/deployment-reconciliation";
import {
  ApiError,
  AppearanceSettingsSchema,
  CompleteOnboardingSchema,
  constructResponseSchema,
  type NetworkPolicy,
  SelectOrganizationSchema,
  type TrustedImageRegistries,
  UpdateAgentSettingsSchema,
  UpdateAppearanceSettingsSchema,
  UpdateAuthSettingsSchema,
  UpdateConnectionSettingsSchema,
  UpdateDefaultEnvironmentSchema,
  UpdateKnowledgeSettingsSchema,
  UpdateLlmSettingsSchema,
  UpdateSecuritySettingsSchema,
} from "@/types";

const organizationRoutes: FastifyPluginAsyncZod = async (fastify) => {
  fastify.get(
    "/api/organization",
    {
      schema: {
        operationId: RouteId.GetOrganization,
        description: "Get organization details",
        tags: ["Organization"],
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId }, reply) => {
      const organization = await OrganizationModel.getById(organizationId);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      return reply.send(organization);
    },
  );

  fastify.patch(
    "/api/organization/appearance-settings",
    {
      schema: {
        operationId: RouteId.UpdateAppearanceSettings,
        description: "Update appearance settings",
        tags: ["Organization"],
        body: UpdateAppearanceSettingsSchema,
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId, body }, reply) => {
      const currentOrganization =
        await OrganizationModel.getById(organizationId);
      if (!currentOrganization) {
        throw new ApiError(404, "Organization not found");
      }

      const organization = await OrganizationModel.patch(organizationId, body);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      // SPDX-SnippetBegin
      // SPDX-SnippetCopyrightText: 2026 Archestra Inc.
      // SPDX-License-Identifier: LicenseRef-Archestra-Enterprise
      if (
        config.enterpriseFeatures.fullWhiteLabeling &&
        (body.appName !== undefined || body.iconLogo !== undefined)
      ) {
        // appName renames the built-in tool/server names, and iconLogo updates
        // the built-in catalog metadata shown across the UI.
        const appNameChanged =
          currentOrganization.appName !== organization.appName;
        const iconChanged =
          currentOrganization.iconLogo !== organization.iconLogo;

        if (appNameChanged || iconChanged) {
          await ToolModel.syncArchestraBuiltInCatalog({
            organization: organization,
          });
        }

        // appName is baked into the built-in skills' stored rows (name, body,
        // tool-prefix references), so re-brand them now — without this the
        // catalog/load_skill output only updates after a backend restart. A
        // pristine copy auto-rebrands; an admin-edited copy is preserved.
        if (appNameChanged) {
          await syncBuiltInSkillsForOrganization(organization);
        }
      }
      // SPDX-SnippetEnd

      return reply.send(organization);
    },
  );

  fastify.patch(
    "/api/organization/security-settings",
    {
      schema: {
        operationId: RouteId.UpdateSecuritySettings,
        description:
          "Update security settings (default tool guardrails, chat file uploads)",
        tags: ["Organization"],
        body: UpdateSecuritySettingsSchema,
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId, body }, reply) => {
      const organization = await OrganizationModel.patch(organizationId, body);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      return reply.send(organization);
    },
  );

  fastify.patch(
    "/api/organization/llm-settings",
    {
      schema: {
        operationId: RouteId.UpdateLlmSettings,
        description:
          "Update LLM settings (TOON compression, compression scope)",
        tags: ["Organization"],
        body: UpdateLlmSettingsSchema,
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId, body }, reply) => {
      const organization = await OrganizationModel.patch(organizationId, body);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      return reply.send(organization);
    },
  );

  fastify.patch(
    "/api/organization/agent-settings",
    {
      schema: {
        operationId: RouteId.UpdateAgentSettings,
        description:
          "Update agent settings (default model, default agent, skill slash commands)",
        tags: ["Organization"],
        body: UpdateAgentSettingsSchema,
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId, body }, reply) => {
      if (body.defaultLlmApiKeyId) {
        const apiKey = await LlmProviderApiKeyModel.findById(
          body.defaultLlmApiKeyId,
        );
        if (!apiKey || apiKey.organizationId !== organizationId) {
          throw new ApiError(404, "API key not found");
        }
      }

      // The default model and its API key are a pair: persist both or neither.
      // Validate the merged result only when this update touches either field.
      if (
        body.defaultModelId !== undefined ||
        body.defaultLlmApiKeyId !== undefined
      ) {
        const currentOrg = await OrganizationModel.getById(organizationId);
        const mergedModelId =
          body.defaultModelId !== undefined
            ? body.defaultModelId
            : (currentOrg?.defaultModelId ?? null);
        const mergedApiKeyId =
          body.defaultLlmApiKeyId !== undefined
            ? body.defaultLlmApiKeyId
            : (currentOrg?.defaultLlmApiKeyId ?? null);
        if (
          !isModelSelectionComplete({
            modelId: mergedModelId,
            apiKeyId: mergedApiKeyId,
          })
        ) {
          throw new ApiError(
            400,
            "The default model and API key must be set together",
          );
        }
      }

      if (body.defaultAgentId) {
        const agent = await AgentModel.findById(body.defaultAgentId);
        if (!agent || agent.organizationId !== organizationId) {
          throw new ApiError(404, "Agent not found");
        }
      }

      const organization = await OrganizationModel.patch(organizationId, body);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      return reply.send(organization);
    },
  );

  fastify.patch(
    "/api/organization/connection-settings",
    {
      schema: {
        operationId: RouteId.UpdateConnectionSettings,
        description:
          "Update /connection admin settings (default gateway/proxy, hidden clients/providers)",
        tags: ["Organization"],
        body: UpdateConnectionSettingsSchema,
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId, body }, reply) => {
      if (body.connectionDefaultMcpGatewayId) {
        const agent = await AgentModel.findById(
          body.connectionDefaultMcpGatewayId,
        );
        if (!agent || agent.organizationId !== organizationId) {
          throw new ApiError(404, "MCP gateway not found");
        }
        if (
          agent.agentType !== "mcp_gateway" &&
          agent.agentType !== "profile"
        ) {
          throw new ApiError(400, "Agent is not an MCP gateway");
        }
      }

      if (body.connectionDefaultLlmProxyId) {
        const agent = await AgentModel.findById(
          body.connectionDefaultLlmProxyId,
        );
        if (!agent || agent.organizationId !== organizationId) {
          throw new ApiError(404, "LLM proxy not found");
        }
        if (agent.agentType !== "llm_proxy" && agent.agentType !== "profile") {
          throw new ApiError(400, "Agent is not an LLM proxy");
        }
      }

      if (body.connectionDefaultProviderKeys) {
        const keyIds = Object.values(body.connectionDefaultProviderKeys);
        const keys = await LlmProviderApiKeyModel.findByIds(keyIds);
        const keysById = new Map(keys.map((k) => [k.id, k]));
        for (const [provider, keyId] of Object.entries(
          body.connectionDefaultProviderKeys,
        )) {
          const key = keysById.get(keyId);
          if (!key || key.organizationId !== organizationId) {
            throw new ApiError(404, "Provider API key not found");
          }
          if (key.provider !== provider) {
            throw new ApiError(
              400,
              `Key "${key.name}" is for provider "${key.provider}", not "${provider}"`,
            );
          }
          // Per-user providers (GitHub Copilot) can't back a shared default:
          // each user connects their own account at setup time, so an admin
          // default would be meaningless (and the connection flow would refuse
          // to wrap someone else's personal key).
          if (
            providerRequiresPerUserCredential(provider as SupportedProvider)
          ) {
            throw new ApiError(
              400,
              `${provider} is per-user — each user connects their own account, so it can't be set as a default provider key for setup commands.`,
            );
          }
        }
      }

      const organization = await OrganizationModel.patch(organizationId, body);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      return reply.send(organization);
    },
  );

  fastify.patch(
    "/api/organization/default-environment",
    {
      schema: {
        operationId: RouteId.UpdateDefaultEnvironment,
        description:
          "Configure the implicit default environment (the deployment target referenced by internal_mcp_catalog.environment_id = null). Pass null for name to reset to the built-in 'Default' label, or null for namespace to unset it. Omitted fields are left unchanged.",
        tags: ["Organization"],
        body: UpdateDefaultEnvironmentSchema,
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId, body }, reply) => {
      const currentOrganization =
        "networkPolicy" in body
          ? await OrganizationModel.getById(organizationId)
          : null;
      const networkPolicyActuallyChanging =
        "networkPolicy" in body &&
        currentOrganization !== null &&
        !sameNetworkPolicy(
          body.networkPolicy ?? null,
          currentOrganization?.defaultNetworkPolicy ?? null,
        );

      // Map the clean API shape to DB columns, including only keys that are
      // present in the body so omitting a field leaves it unchanged (an
      // explicit null clears the column).
      const data: Partial<{
        defaultEnvironmentName: string | null;
        defaultEnvironmentDescription: string | null;
        defaultEnvironmentNamespace: string | null;
        defaultNetworkPolicy: typeof body.networkPolicy;
        defaultEnvironmentRestricted: boolean;
        defaultEnvironmentValidationRegex: string | null;
        defaultEnvironmentTrustedImageRegistries: TrustedImageRegistries | null;
      }> = {};
      if ("name" in body) {
        data.defaultEnvironmentName = body.name ?? null;
      }
      if ("description" in body) {
        data.defaultEnvironmentDescription = body.description ?? null;
      }
      if ("namespace" in body) {
        data.defaultEnvironmentNamespace = body.namespace ?? null;
      }
      if ("networkPolicy" in body) {
        data.defaultNetworkPolicy = body.networkPolicy ?? null;
      }
      if ("restricted" in body) {
        data.defaultEnvironmentRestricted = body.restricted ?? false;
      }
      if ("validationRegex" in body) {
        data.defaultEnvironmentValidationRegex = body.validationRegex ?? null;
      }
      if ("trustedImageRegistries" in body) {
        data.defaultEnvironmentTrustedImageRegistries =
          body.trustedImageRegistries ?? null;
      }

      const organization = await OrganizationModel.patch(organizationId, data);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      if (networkPolicyActuallyChanging && mcpServerRuntimeManager.isEnabled) {
        const catalogs =
          await InternalMcpCatalogModel.findDefaultEnvironmentLocalCatalogs(
            organizationId,
          );
        await reconcileCatalogDeployments({
          catalogs,
          reason: "default environment network policy change",
        });
      }

      return reply.send(organization);
    },
  );

  fastify.patch(
    "/api/organization/auth-settings",
    {
      schema: {
        operationId: RouteId.UpdateAuthSettings,
        description: "Update organization Auth settings",
        tags: ["Organization"],
        body: UpdateAuthSettingsSchema,
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId, body }, reply) => {
      const organization = await OrganizationModel.patch(organizationId, body);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      return reply.send(organization);
    },
  );

  fastify.patch(
    "/api/organization/knowledge-settings",
    {
      schema: {
        operationId: RouteId.UpdateKnowledgeSettings,
        description: "Update knowledge settings (embedding model)",
        tags: ["Organization"],
        body: UpdateKnowledgeSettingsSchema,
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId, body }, reply) => {
      const currentOrg = await OrganizationModel.getById(organizationId);

      // Embedding model is locked once both key and model have been saved
      const isEmbeddingConfigLocked =
        !!currentOrg?.embeddingChatApiKeyId && !!currentOrg?.embeddingModel;

      if (body.embeddingModel) {
        if (
          isEmbeddingConfigLocked &&
          body.embeddingModel !== currentOrg.embeddingModel
        ) {
          throw new ApiError(
            400,
            "Embedding model cannot be changed once configured. Changing models requires re-embedding all documents.",
          );
        }
      }

      if (
        isEmbeddingConfigLocked &&
        body.embeddingChatApiKeyId !== undefined &&
        body.embeddingChatApiKeyId !== currentOrg.embeddingChatApiKeyId
      ) {
        throw new ApiError(
          400,
          "Embedding API key cannot be changed once configured. Drop the embedding configuration before selecting a different key.",
        );
      }

      // Validate embedding API key exists
      if (body.embeddingChatApiKeyId) {
        const chatApiKey = await LlmProviderApiKeyModel.findById(
          body.embeddingChatApiKeyId,
        );
        if (!chatApiKey) {
          throw new ApiError(404, "Embedding API key not found");
        }
      }

      const shouldValidateEmbeddingSelection =
        body.embeddingChatApiKeyId !== undefined ||
        body.embeddingModel !== undefined;
      const effectiveEmbeddingKeyId =
        body.embeddingChatApiKeyId ?? currentOrg?.embeddingChatApiKeyId ?? null;
      const effectiveEmbeddingModel =
        body.embeddingModel ?? currentOrg?.embeddingModel ?? null;

      if (
        shouldValidateEmbeddingSelection &&
        effectiveEmbeddingKeyId &&
        effectiveEmbeddingModel
      ) {
        const resolved = await resolveApiKeyFromChatApiKey(
          effectiveEmbeddingKeyId,
        );
        if (!resolved) {
          throw new ApiError(400, "Could not resolve embedding API key");
        }

        const model = await ModelModel.findByProviderAndModelId(
          resolved.provider,
          effectiveEmbeddingModel,
        );

        if (model?.embeddingDimensions === null || !model) {
          throw new ApiError(
            400,
            "Embedding model must be marked as an embedding model with configured dimensions in LLM Providers > Models.",
          );
        }
      }

      const organization = await OrganizationModel.patch(organizationId, body);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      return reply.send(organization);
    },
  );

  fastify.post(
    "/api/organization/knowledge-settings/drop-embedding",
    {
      schema: {
        operationId: RouteId.DropEmbeddingConfig,
        description:
          "Drop the embedding configuration, deleting all KB documents and resetting connector checkpoints",
        tags: ["Organization"],
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId }, reply) => {
      const currentOrg = await OrganizationModel.getById(organizationId);
      if (!currentOrg?.embeddingChatApiKeyId || !currentOrg?.embeddingModel) {
        throw new ApiError(
          400,
          "Embedding configuration is not locked — nothing to drop",
        );
      }

      // Delete all KB documents (chunks cascade via FK)
      await KbDocumentModel.deleteByOrganization(organizationId);

      // Reset connector checkpoints so next sync does a full re-ingest
      await KnowledgeBaseConnectorModel.resetCheckpointsByOrganization(
        organizationId,
      );

      // Clear embedding config
      const organization = await OrganizationModel.patch(organizationId, {
        embeddingModel: null,
        embeddingChatApiKeyId: null,
      });

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      return reply.send(organization);
    },
  );

  fastify.post(
    "/api/organization/knowledge-settings/test-embedding",
    {
      schema: {
        operationId: RouteId.TestEmbeddingConnection,
        description: "Test the embedding connection by embedding a sample text",
        tags: ["Organization"],
        body: z.object({
          embeddingChatApiKeyId: z.string().uuid(),
          embeddingModel: z.string().min(1),
        }),
        response: constructResponseSchema(
          z.object({
            success: z.boolean(),
            error: z.string().optional(),
          }),
        ),
      },
    },
    async ({ body }, reply) => {
      // Validate API key exists
      const chatApiKey = await LlmProviderApiKeyModel.findById(
        body.embeddingChatApiKeyId,
      );
      if (!chatApiKey) {
        throw new ApiError(404, "API key not found");
      }

      // Resolve the actual secret
      const resolved = await resolveApiKeyFromChatApiKey(
        body.embeddingChatApiKeyId,
      );
      if (!resolved) {
        return reply.send({
          success: false,
          error: "Could not resolve API key secret",
        });
      }

      const model = await ModelModel.findByProviderAndModelId(
        resolved.provider,
        body.embeddingModel,
      );
      if (!model?.embeddingDimensions) {
        return reply.send({
          success: false,
          error:
            "Embedding model must be marked as an embedding model with configured dimensions in LLM Providers > Models.",
        });
      }

      try {
        const response = await callEmbedding({
          inputs: [
            addNomicTaskPrefix(
              body.embeddingModel,
              "hello world",
              "search_document",
            ),
          ],
          model: body.embeddingModel,
          apiKey: resolved.apiKey,
          baseUrl: resolved.baseUrl,
          dimensions: model.embeddingDimensions,
          provider: resolved.provider,
        });

        if (response.data.length > 0) {
          return reply.send({ success: true });
        }

        return reply.send({
          success: false,
          error: "No embedding data returned",
        });
      } catch (err) {
        const message = err instanceof Error ? err.message : "Unknown error";
        logger.error(
          { err },
          "[testEmbeddingConnection] embedding call failed",
        );
        return reply.send({ success: false, error: message });
      }
    },
  );

  fastify.post(
    "/api/organization/complete-onboarding",
    {
      schema: {
        operationId: RouteId.CompleteOnboarding,
        description: "Mark organization onboarding as complete",
        tags: ["Organization"],
        body: CompleteOnboardingSchema,
        response: constructResponseSchema(SelectOrganizationSchema),
      },
    },
    async ({ organizationId, body }, reply) => {
      const organization = await OrganizationModel.patch(organizationId, body);

      if (!organization) {
        throw new ApiError(404, "Organization not found");
      }

      return reply.send(organization);
    },
  );

  fastify.get(
    "/api/organization/onboarding-status",
    {
      schema: {
        operationId: RouteId.GetOnboardingStatus,
        description: "Check if organization onboarding is complete",
        tags: ["Organization"],
        response: constructResponseSchema(
          z.object({
            hasLlmProxyLogs: z.boolean(),
            hasMcpGatewayLogs: z.boolean(),
          }),
        ),
      },
    },
    async (_request, reply) => {
      // Check if onboarding is complete by checking if there are any logs
      const interactionCount = await InteractionModel.getCount();
      const mcpToolCallCount = await McpToolCallModel.getCount();

      return reply.send({
        hasLlmProxyLogs: interactionCount > 0,
        hasMcpGatewayLogs: mcpToolCallCount > 0,
      });
    },
  );

  /**
   * Get signup status for organization members.
   * Returns members that don't have an account record (auto-provisioned, haven't signed up),
   * along with the provider they were auto-provisioned from.
   */
  fastify.get(
    "/api/organization/members/signup-status",
    {
      schema: {
        operationId: RouteId.GetMemberSignupStatus,
        description:
          "Get which members have completed signup (have an account record)",
        tags: ["Organization"],
        response: constructResponseSchema(
          z.object({
            pendingSignupMembers: z.array(
              z.object({
                userId: z.string(),
                name: z.string().nullable(),
                email: z.string(),
                image: z.string().nullable(),
                role: z.string(),
                provider: z.string().nullable(),
                invitationId: z.string().nullable(),
              }),
            ),
          }),
        ),
      },
    },
    async ({ organizationId }, reply) => {
      // Get all member user IDs for this organization
      const members = await db
        .select({ userId: schema.membersTable.userId })
        .from(schema.membersTable)
        .where(eq(schema.membersTable.organizationId, organizationId));

      if (members.length === 0) {
        return reply.send({ pendingSignupMembers: [] });
      }

      const memberUserIds = members.map((m) => m.userId);

      // Find which of these users have an account record
      const usersWithAccounts = await db
        .select({ userId: schema.accountsTable.userId })
        .from(schema.accountsTable)
        .where(inArray(schema.accountsTable.userId, memberUserIds));

      const hasAccountSet = new Set(usersWithAccounts.map((a) => a.userId));
      const pendingUserIds = memberUserIds.filter(
        (id) => !hasAccountSet.has(id),
      );

      if (pendingUserIds.length === 0) {
        return reply.send({ pendingSignupMembers: [] });
      }

      // Look up auto-provisioned invitations to get provider and invitation ID
      const invitations = await db
        .select({
          id: schema.invitationsTable.id,
          email: schema.invitationsTable.email,
          status: schema.invitationsTable.status,
        })
        .from(schema.invitationsTable)
        .where(
          like(
            schema.invitationsTable.status,
            `${AUTO_PROVISIONED_INVITATION_STATUS}%`,
          ),
        );

      // Build email → { provider, invitationId } map
      const emailToInvitation = new Map<
        string,
        { provider: string | null; invitationId: string }
      >();
      for (const inv of invitations) {
        const parts = inv.status.split(":");
        emailToInvitation.set(inv.email, {
          provider: parts.length === 2 ? parts[1] : null,
          invitationId: inv.id,
        });
      }

      // Get emails for pending users
      const pendingUsers = await db
        .select({
          id: schema.usersTable.id,
          email: schema.usersTable.email,
          name: schema.usersTable.name,
          image: schema.usersTable.image,
          role: schema.membersTable.role,
        })
        .from(schema.membersTable)
        .innerJoin(
          schema.usersTable,
          eq(schema.membersTable.userId, schema.usersTable.id),
        )
        .where(
          and(
            eq(schema.membersTable.organizationId, organizationId),
            inArray(schema.usersTable.id, pendingUserIds),
          ),
        );

      const pendingSignupMembers = pendingUsers.map((u) => {
        const inv = emailToInvitation.get(u.email);
        return {
          userId: u.id,
          name: u.name,
          email: u.email,
          image: u.image,
          role: u.role,
          provider: inv?.provider ?? null,
          invitationId: inv?.invitationId ?? null,
        };
      });

      return reply.send({ pendingSignupMembers });
    },
  );

  /**
   * Delete an auto-provisioned member who hasn't completed signup.
   * Removes the member, invitation, user token, and user record.
   */
  fastify.delete(
    "/api/organization/members/:userId/pending-signup",
    {
      schema: {
        operationId: RouteId.DeletePendingSignupMember,
        description:
          "Delete an auto-provisioned member who hasn't completed signup",
        tags: ["Organization"],
        params: z.object({ userId: z.string() }),
        response: constructResponseSchema(z.object({ success: z.boolean() })),
      },
    },
    async ({ organizationId, params }, reply) => {
      const { userId } = params;

      // Verify user has no account (is actually pending signup)
      const [account] = await db
        .select({ userId: schema.accountsTable.userId })
        .from(schema.accountsTable)
        .where(eq(schema.accountsTable.userId, userId))
        .limit(1);

      if (account) {
        throw new ApiError(
          400,
          "Cannot delete a member who has already completed signup",
        );
      }

      // Get user email to find their invitation
      const user = await UserModel.getById(userId);
      if (!user) {
        throw new ApiError(404, "User not found");
      }

      // Delete invitation(s) with auto-provisioned status for this email
      const invitations = await db
        .select({ id: schema.invitationsTable.id })
        .from(schema.invitationsTable)
        .where(
          and(
            eq(schema.invitationsTable.email, user.email),
            like(
              schema.invitationsTable.status,
              `${AUTO_PROVISIONED_INVITATION_STATUS}%`,
            ),
          ),
        );

      for (const inv of invitations) {
        await InvitationModel.delete(inv.id);
      }

      // Delete personal tokens
      await UserTokenModel.deleteByUserAndOrg(userId, organizationId);

      // Delete member record
      await MemberModel.deleteByMemberOrUserId(userId, organizationId);

      // Delete user record (no other memberships since auto-provisioned)
      await UserModel.delete(userId);

      return reply.send({ success: true });
    },
  );

  fastify.get(
    "/api/organization/members",
    {
      schema: {
        operationId: RouteId.GetOrganizationMembers,
        description: "Get all members of the organization",
        tags: ["Organization"],
        response: constructResponseSchema(
          z.array(
            z.object({
              id: z.string(),
              name: z.string(),
              email: z.string(),
            }),
          ),
        ),
      },
    },
    async ({ organizationId }, reply) => {
      const members = await MemberModel.findAllByOrganization(organizationId);
      return reply.send(members);
    },
  );

  fastify.get(
    "/api/organization/members/:idOrEmail",
    {
      schema: {
        operationId: RouteId.GetOrganizationMember,
        description:
          "Get a member of the organization by user ID or email address",
        tags: ["Organization"],
        params: z.object({
          idOrEmail: z.string().min(1).describe("User ID or email address"),
        }),
        response: constructResponseSchema(
          z.object({
            id: z.string(),
            name: z.string(),
            email: z.string(),
            role: z.string(),
          }),
        ),
      },
    },
    async ({ organizationId, params: { idOrEmail } }, reply) => {
      const member = await MemberModel.findByIdOrEmail(
        idOrEmail,
        organizationId,
      );
      if (!member) {
        throw new ApiError(404, "Member not found");
      }
      return reply.send(member);
    },
  );

  fastify.get(
    "/api/organization/appearance-settings",
    {
      schema: {
        operationId: RouteId.GetAppearanceSettings,
        description: "Get organization appearance settings",
        tags: ["Organization"],
        response: constructResponseSchema(AppearanceSettingsSchema),
      },
    },
    async (_request, reply) => {
      return reply.send(await OrganizationModel.getAppearanceSettings());
    },
  );
};

export default organizationRoutes;

// === Internal helpers ===

function sameNetworkPolicy(
  a: NetworkPolicy | null,
  b: NetworkPolicy | null,
): boolean {
  return JSON.stringify(a) === JSON.stringify(b);
}
