import { Types } from 'mongoose';
import {
  AccessRoleIds,
  PermissionBits,
  PrincipalType,
  ResourceType,
} from 'librechat-data-provider';
import {
  AllMethods,
  MCPServerDocument,
  createMethods,
  logger,
  encryptV2,
  decryptV2,
} from '@librechat/data-schemas';
import type { IServerConfigsRepositoryInterface } from '~/mcp/registry/ServerConfigsRepositoryInterface';
import { AccessControlService } from '~/acl/accessControlService';
import type { ParsedServerConfig, AddServerResult } from '~/mcp/types';

/**
 * DB backed config storage
 * Handles CRUD Methods of dynamic mcp servers
 * Will handle Permission ACL
 */
export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
  private _dbMethods: AllMethods;
  private _aclService: AccessControlService;
  private _mongoose: typeof import('mongoose');

  constructor(mongoose: typeof import('mongoose')) {
    if (!mongoose) {
      throw new Error('ServerConfigsDB requires mongoose instance');
    }
    this._mongoose = mongoose;
    this._dbMethods = createMethods(mongoose);
    this._aclService = new AccessControlService(mongoose);
  }

  /**
   * Checks if user has access to an MCP server via an agent they can VIEW.
   * @param serverName - The MCP server name to check
   * @param userId - The user ID (optional - if not provided, checks publicly accessible agents)
   * @returns true if user has VIEW access to at least one agent that has this MCP server
   */
  private async hasAccessViaAgent(serverName: string, userId?: string): Promise<boolean> {
    let accessibleAgentIds: Types.ObjectId[];

    if (!userId) {
      // Get publicly accessible agents
      accessibleAgentIds = await this._aclService.findPubliclyAccessibleResources({
        resourceType: ResourceType.AGENT,
        requiredPermissions: PermissionBits.VIEW,
      });
    } else {
      // Get user-accessible agents
      accessibleAgentIds = await this._aclService.findAccessibleResources({
        userId,
        requiredPermissions: PermissionBits.VIEW,
        resourceType: ResourceType.AGENT,
      });
    }

    if (accessibleAgentIds.length === 0) {
      return false;
    }

    // Check if any accessible agent has this MCP server
    const Agent = this._mongoose.model('Agent');
    const exists = await Agent.exists({
      _id: { $in: accessibleAgentIds },
      mcpServerNames: serverName,
    });

    return exists !== null;
  }

  /**
   * Creates a new MCP server and grants owner permissions to the user.
   * @param serverName - Temporary server name (not persisted) will be replaced by the nano id generated by the db method
   * @param config - Server configuration to store
   * @param userId - ID of the user creating the server (required)
   * @returns The created server result with serverName and config (including dbId)
   * @throws Error if userId is not provided
   */
  public async add(
    serverName: string,
    config: ParsedServerConfig,
    userId?: string,
  ): Promise<AddServerResult> {
    logger.debug(
      `[ServerConfigsDB.add] Starting Creating server with temp servername: ${serverName} for the user with the ID ${userId}`,
    );
    if (!userId) {
      throw new Error(
        '[ServerConfigsDB.add] User ID is required to create a database-stored MCP server.',
      );
    }
    // Transform user-provided API key config (adds customUserVars and headers)
    const transformedConfig = this.transformUserApiKeyConfig(config);
    // Encrypt sensitive fields before storing in database
    const encryptedConfig = await this.encryptConfig(transformedConfig);
    const createdServer = await this._dbMethods.createMCPServer({
      config: encryptedConfig,
      author: userId,
    });
    await this._aclService.grantPermission({
      principalType: PrincipalType.USER,
      principalId: userId,
      resourceType: ResourceType.MCPSERVER,
      resourceId: createdServer._id,
      accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
      grantedBy: userId,
    });
    return {
      serverName: createdServer.serverName,
      config: await this.mapDBServerToParsedConfig(createdServer),
    };
  }

  /**
   *
   * @param serverName mcp server unique identifier "serverName"
   * @param config new Configuration to update
   * @param userId user id required to update DB server config
   */
  public async update(
    serverName: string,
    config: ParsedServerConfig,
    userId?: string,
  ): Promise<void> {
    if (!userId) {
      throw new Error(
        '[ServerConfigsDB.update] User ID is required to update a database-stored MCP server.',
      );
    }

    const existingServer = await this._dbMethods.findMCPServerByServerName(serverName);
    let configToSave: ParsedServerConfig = { ...config };

    // Transform user-provided API key config (adds customUserVars and headers)
    configToSave = this.transformUserApiKeyConfig(configToSave);

    // Encrypt NEW secrets only (secrets provided in this update)
    // We must do this BEFORE preserving existing encrypted secrets
    configToSave = await this.encryptConfig(configToSave);

    // Preserve existing OAuth client_secret if not provided in update (already encrypted)
    if (!config.oauth?.client_secret && existingServer?.config?.oauth?.client_secret) {
      configToSave = {
        ...configToSave,
        oauth: {
          ...configToSave.oauth,
          client_secret: existingServer.config.oauth.client_secret,
        },
      };
    }

    // Preserve existing API key if not provided in update (already encrypted)
    // Only preserve if both old and new configs use admin mode to avoid cross-mode key leakage
    if (
      config.apiKey?.source === 'admin' &&
      !config.apiKey?.key &&
      existingServer?.config?.apiKey?.source === 'admin' &&
      existingServer?.config?.apiKey?.key
    ) {
      configToSave = {
        ...configToSave,
        apiKey: {
          source: configToSave.apiKey!.source,
          authorization_type: configToSave.apiKey!.authorization_type,
          custom_header: configToSave.apiKey?.custom_header,
          key: existingServer.config.apiKey.key,
        },
      };
    }

    // specific user permissions for action permission will be handled in the controller calling the  update method of the registry
    await this._dbMethods.updateMCPServer(serverName, { config: configToSave });
  }

  /**
   * Deletes an MCP server and removes all associated ACL entries.
   * @param serverName - The serverName of the server to remove
   * @param userId - User performing the deletion (for logging)
   */
  public async remove(serverName: string, userId?: string): Promise<void> {
    logger.debug(`[ServerConfigsDB.remove] removing ${serverName}. UserId: ${userId}`);
    const deletedServer = await this._dbMethods.deleteMCPServer(serverName);
    if (deletedServer && deletedServer._id) {
      logger.debug(`[ServerConfigsDB.remove] removing all permissions entries of ${serverName}.`);
      await this._aclService.removeAllPermissions({
        resourceType: ResourceType.MCPSERVER,
        resourceId: deletedServer._id!,
      });
      return;
    }
    logger.warn(`[ServerConfigsDB.remove] server with serverName ${serverName} does not exist`);
  }

  /**
   * Retrieves a single MCP server configuration by its serverName.
   * @param serverName - The serverName of the server to retrieve
   * @param userId - the user id provide the scope of the request. If the user Id is not provided, only publicly visible servers are returned.
   * @returns The parsed server config or undefined if not found. If accessed via agent, consumeOnly will be true.
   */
  public async get(serverName: string, userId?: string): Promise<ParsedServerConfig | undefined> {
    const server = await this._dbMethods.findMCPServerByServerName(serverName);
    if (!server) return undefined;

    // Check public access if no userId
    if (!userId) {
      const directlyAccessibleMCPIds = (
        await this._aclService.findPubliclyAccessibleResources({
          resourceType: ResourceType.MCPSERVER,
          requiredPermissions: PermissionBits.VIEW,
        })
      ).map((id) => id.toString());
      if (directlyAccessibleMCPIds.indexOf(server._id.toString()) > -1) {
        return await this.mapDBServerToParsedConfig(server);
      }

      // Check access via publicly accessible agents
      const hasAgentAccess = await this.hasAccessViaAgent(serverName);
      if (hasAgentAccess) {
        logger.debug(
          `[ServerConfigsDB.get] accessing ${serverName} via public agent (consumeOnly)`,
        );
        return {
          ...(await this.mapDBServerToParsedConfig(server)),
          consumeOnly: true,
        };
      }

      return undefined;
    }

    // Check direct user access
    const userHasDirectAccess = await this._aclService.checkPermission({
      userId,
      resourceType: ResourceType.MCPSERVER,
      requiredPermission: PermissionBits.VIEW,
      resourceId: server._id,
    });

    if (userHasDirectAccess) {
      logger.debug(
        `[ServerConfigsDB.get] getting ${serverName} for user with the UserId: ${userId}`,
      );
      return await this.mapDBServerToParsedConfig(server);
    }

    // Check agent access (user can VIEW an agent that has this MCP server)
    const hasAgentAccess = await this.hasAccessViaAgent(serverName, userId);
    if (hasAgentAccess) {
      logger.debug(
        `[ServerConfigsDB.get] user ${userId} accessing ${serverName} via agent (consumeOnly)`,
      );
      return {
        ...(await this.mapDBServerToParsedConfig(server)),
        consumeOnly: true,
      };
    }

    return undefined;
  }

  /**
   * Return all DB stored configs (scoped by user Id if provided)
   * @param userId optional user id. if not provided only publicly shared mcp configs will be returned
   * @returns record of parsed configs
   */
  public async getAll(userId?: string): Promise<Record<string, ParsedServerConfig>> {
    // 1. Get directly accessible MCP IDs
    let directlyAccessibleMCPIds: Types.ObjectId[] = [];
    if (!userId) {
      logger.debug(`[ServerConfigsDB.getAll] fetching all publicly shared mcp servers`);
      directlyAccessibleMCPIds = await this._aclService.findPubliclyAccessibleResources({
        resourceType: ResourceType.MCPSERVER,
        requiredPermissions: PermissionBits.VIEW,
      });
    } else {
      logger.debug(
        `[ServerConfigsDB.getAll] fetching mcp servers directly shared with the user with ID: ${userId}`,
      );
      directlyAccessibleMCPIds = await this._aclService.findAccessibleResources({
        userId,
        requiredPermissions: PermissionBits.VIEW,
        resourceType: ResourceType.MCPSERVER,
      });
    }

    // 2. Get agent-accessible MCP server names
    let agentMCPServerNames: string[] = [];
    let accessibleAgentIds: Types.ObjectId[] = [];

    if (!userId) {
      // Get publicly accessible agents
      accessibleAgentIds = await this._aclService.findPubliclyAccessibleResources({
        resourceType: ResourceType.AGENT,
        requiredPermissions: PermissionBits.VIEW,
      });
    } else {
      // Get user-accessible agents
      accessibleAgentIds = await this._aclService.findAccessibleResources({
        userId,
        requiredPermissions: PermissionBits.VIEW,
        resourceType: ResourceType.AGENT,
      });
    }

    if (accessibleAgentIds.length > 0) {
      // Efficient query: get agents with non-empty mcpServerNames
      const Agent = this._mongoose.model('Agent');
      const agentsWithMCP = await Agent.find(
        {
          _id: { $in: accessibleAgentIds },
          mcpServerNames: { $exists: true, $not: { $size: 0 } },
        },
        { mcpServerNames: 1 },
      ).lean();

      // Flatten and dedupe server names
      agentMCPServerNames = [
        ...new Set(
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          agentsWithMCP.flatMap((a: any) => a.mcpServerNames || []),
        ),
      ];
    }

    // 3. Fetch directly accessible MCP servers
    const directResults = await this._dbMethods.getListMCPServersByIds({
      ids: directlyAccessibleMCPIds,
    });

    // 4. Build result with direct access servers (parallel decryption)
    const parsedConfigs: Record<string, ParsedServerConfig> = {};
    const directData = directResults.data || [];
    const directServerNames = new Set(directData.map((s) => s.serverName));

    const directParsed = await Promise.all(
      directData.map((s) => this.mapDBServerToParsedConfig(s)),
    );
    directData.forEach((s, i) => {
      parsedConfigs[s.serverName] = directParsed[i];
    });

    // 5. Fetch agent-accessible servers (excluding already direct)
    const agentOnlyServerNames = agentMCPServerNames.filter((name) => !directServerNames.has(name));

    if (agentOnlyServerNames.length > 0) {
      const agentServers = await this._dbMethods.getListMCPServersByNames({
        names: agentOnlyServerNames,
      });

      const agentData = agentServers.data || [];
      const agentParsed = await Promise.all(
        agentData.map((s) => this.mapDBServerToParsedConfig(s)),
      );
      agentData.forEach((s, i) => {
        parsedConfigs[s.serverName] = { ...agentParsed[i], consumeOnly: true };
      });
    }

    return parsedConfigs;
  }

  /** No-op for DB storage; logs a warning if called. */
  public async reset(): Promise<void> {
    logger.warn('Attempt to reset the DB config storage');
    return;
  }

  /**
   * Maps a MongoDB server document to the ParsedServerConfig format.
   * Decrypts sensitive fields (oauth.client_secret) after retrieval.
   */
  private async mapDBServerToParsedConfig(
    serverDBDoc: MCPServerDocument,
  ): Promise<ParsedServerConfig> {
    const config: ParsedServerConfig = {
      ...serverDBDoc.config,
      dbId: (serverDBDoc._id as Types.ObjectId).toString(),
      updatedAt: serverDBDoc.updatedAt?.getTime(),
    };
    // Decrypt sensitive fields after retrieval from database
    return await this.decryptConfig(config);
  }

  /**
   * Transforms user-provided API key config by auto-generating customUserVars and headers.
   * This is a config transformation, not encryption.
   * @param config - The server config to transform
   * @returns The transformed config with customUserVars and headers set up
   */
  private transformUserApiKeyConfig(config: ParsedServerConfig): ParsedServerConfig {
    if (!config.apiKey || config.apiKey.source !== 'user') {
      return config;
    }

    const result = { ...config };
    const headerName =
      result.apiKey!.authorization_type === 'custom'
        ? result.apiKey!.custom_header || 'X-Api-Key'
        : 'Authorization';

    let headerValue: string;
    if (result.apiKey!.authorization_type === 'basic') {
      headerValue = 'Basic {{MCP_API_KEY}}';
    } else if (result.apiKey!.authorization_type === 'bearer') {
      headerValue = 'Bearer {{MCP_API_KEY}}';
    } else {
      headerValue = '{{MCP_API_KEY}}';
    }

    result.customUserVars = {
      ...result.customUserVars,
      MCP_API_KEY: {
        title: 'API Key',
        description: 'Your API key for this MCP server',
      },
    };

    // Cast to access headers property (not available on Stdio type)
    const resultWithHeaders = result as ParsedServerConfig & {
      headers?: Record<string, string>;
    };
    resultWithHeaders.headers = {
      ...resultWithHeaders.headers,
      [headerName]: headerValue,
    };

    // Remove key field since it's user-provided (destructure to omit, not set to undefined)
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { key: _removed, ...apiKeyWithoutKey } = result.apiKey!;
    result.apiKey = apiKeyWithoutKey;

    return result;
  }

  /**
   * Encrypts sensitive fields in config before database storage.
   * Encrypts oauth.client_secret and apiKey.key (when source === 'admin').
   * Throws on failure to prevent storing plaintext secrets.
   */
  private async encryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
    let result = { ...config };

    // Encrypt admin-provided API key
    if (result.apiKey?.source === 'admin' && result.apiKey.key) {
      try {
        result.apiKey = {
          ...result.apiKey,
          key: await encryptV2(result.apiKey.key),
        };
      } catch (error) {
        logger.error('[ServerConfigsDB.encryptConfig] Failed to encrypt apiKey.key', error);
        throw new Error('Failed to encrypt MCP server configuration');
      }
    }

    // Encrypt OAuth client_secret
    if (result.oauth?.client_secret) {
      try {
        result = {
          ...result,
          oauth: {
            ...result.oauth,
            client_secret: await encryptV2(result.oauth.client_secret),
          },
        };
      } catch (error) {
        logger.error('[ServerConfigsDB.encryptConfig] Failed to encrypt client_secret', error);
        throw new Error('Failed to encrypt MCP server configuration');
      }
    }

    return result;
  }

  /**
   * Decrypts sensitive fields in config after database retrieval.
   * Decrypts oauth.client_secret and apiKey.key (when source === 'admin').
   * Returns config without secret on failure (graceful degradation).
   */
  private async decryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
    let result = { ...config };

    // Handle API key decryption (admin-provided only)
    if (result.apiKey?.source === 'admin' && result.apiKey.key) {
      try {
        result.apiKey = {
          ...result.apiKey,
          key: await decryptV2(result.apiKey.key),
        };
      } catch (error) {
        logger.warn(
          '[ServerConfigsDB.decryptConfig] Failed to decrypt apiKey.key, returning config without key',
          error,
        );
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const { key: _removedKey, ...apiKeyWithoutKey } = result.apiKey;
        result.apiKey = apiKeyWithoutKey;
      }
    }

    // Handle OAuth client_secret decryption
    if (result.oauth?.client_secret) {
      // Cast oauth to type with client_secret since we've verified it exists
      const oauthConfig = result.oauth as { client_secret: string } & typeof result.oauth;
      try {
        result = {
          ...result,
          oauth: {
            ...oauthConfig,
            client_secret: await decryptV2(oauthConfig.client_secret),
          },
        };
      } catch (error) {
        logger.warn(
          '[ServerConfigsDB.decryptConfig] Failed to decrypt client_secret, returning config without secret',
          error,
        );
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const { client_secret: _removed, ...oauthWithoutSecret } = oauthConfig;
        result = {
          ...result,
          oauth: oauthWithoutSecret,
        };
      }
    }

    return result;
  }
}
