// ABOUTME: Provider registry for managing available AI providers and their discovery
// ABOUTME: Handles provider registration and provides providers for agent execution

import { AIProvider, ProviderConfig, ProviderInfo, ModelInfo } from './base-provider';
import { AnthropicProvider } from './anthropic-provider';
import { BedrockProvider } from './bedrock-provider';
import { OpenAIProvider } from './openai-provider';
import { LMStudioProvider } from './lmstudio-provider';
import { OllamaProvider } from './ollama-provider';
import { GeminiProvider } from './gemini-provider';
import { ProviderCatalogManager } from './catalog/manager';
import { ProviderInstanceManager } from './instance/manager';
import { resolveModelAlias } from './catalog/alias-resolver';
import type { CatalogProvider, CatalogModel, ModelConfig } from './catalog/types';
import { OpenRouterDynamicProvider } from './openrouter/dynamic-provider';
import { OpenAIDynamicProvider } from './openai/dynamic-provider';
import { AnthropicDynamicProvider } from './anthropic/dynamic-provider';
import { logger } from '@lace/agent/utils/logger';

/**
 * Expand environment variables in provider configuration values
 * Returns undefined if the environment variable is not set (to use provider defaults)
 */
function expandEnvVar(value: string | undefined): string | undefined {
  if (!value) return undefined;

  // Check if it's an environment variable (starts with $)
  if (value.startsWith('$')) {
    const envVarName = value.substring(1);
    const envValue = process.env[envVarName];
    // Return undefined if env var not set, so provider can use defaults
    return envValue || undefined;
  }

  return value;
}

export interface ConfiguredInstance {
  id: string;
  displayName: string;
  catalogProviderId: string;
  endpoint?: string;
  timeout?: number;
  retryPolicy?: string;
  modelConfig?: ModelConfig;
  hasCredentials: boolean;
}

export class ProviderRegistry {
  private static instance: ProviderRegistry | null = null;
  private static initializationPromise: Promise<void> | null = null;

  private _providers = new Map<string, AIProvider>();
  private catalogManager: ProviderCatalogManager;
  private instanceManager: ProviderInstanceManager;
  private isInitialized = false;

  private constructor() {
    this.catalogManager = new ProviderCatalogManager();
    this.instanceManager = new ProviderInstanceManager();
  }

  static getInstance(): ProviderRegistry {
    if (!ProviderRegistry.instance) {
      ProviderRegistry.instance = new ProviderRegistry();
      // Start initialization immediately (fire and forget)
      void ProviderRegistry.instance.ensureInitialized();
    }
    return ProviderRegistry.instance;
  }

  static clearInstance(): void {
    ProviderRegistry.instance = null;
    ProviderRegistry.initializationPromise = null;
  }

  async ensureInitialized(): Promise<void> {
    // If already initialized, return immediately
    if (this.isInitialized) {
      return;
    }

    // If initialization is in progress, wait for it
    if (ProviderRegistry.initializationPromise) {
      return ProviderRegistry.initializationPromise;
    }

    // Start initialization
    ProviderRegistry.initializationPromise = this.doInitialize();
    await ProviderRegistry.initializationPromise;
    ProviderRegistry.initializationPromise = null;
  }

  private async doInitialize(): Promise<void> {
    // Only load catalog data - instances are loaded on demand
    await this.catalogManager.loadCatalogs();
    this.isInitialized = true;
  }

  async getCatalogProviders(): Promise<CatalogProvider[]> {
    await this.ensureInitialized();

    // Get static catalogs first
    const staticProviders = this.catalogManager.getAvailableProviders();

    // Replace with dynamic catalogs where available
    const providers = await Promise.all(
      staticProviders.map(async (provider) => {
        const dynamic = await this.getCatalogProvider(provider.id);
        return dynamic ?? provider;
      })
    );

    return providers;
  }

  /**
   * Get dynamic catalog for a specific provider instance
   * This allows each instance (e.g., real OpenAI vs local Apple FM) to have its own catalog
   */
  async getCatalogForInstance(
    instanceId: string,
    forceRefresh = false
  ): Promise<CatalogProvider | null> {
    await this.ensureInitialized();

    const instances = await this.instanceManager.loadInstances();
    const instance = instances.instances[instanceId];

    if (!instance) {
      return null;
    }

    if (process.env.LACE_DISABLE_DYNAMIC_CATALOGS === '1') {
      return this.catalogManager.getProvider(instance.catalogProviderId);
    }

    const credential = this.instanceManager.loadCredential(instanceId);
    if (!credential?.apiKey) {
      return null;
    }

    // Handle different provider types with dynamic catalogs
    if (instance.catalogProviderId === 'anthropic') {
      const provider = new AnthropicDynamicProvider(instanceId, instance.endpoint);
      const staticCatalog = this.catalogManager.getProvider('anthropic');

      if (staticCatalog) {
        try {
          logger.debug('Fetching dynamic catalog for Anthropic instance', {
            instanceId,
            endpoint: instance.endpoint || 'https://api.anthropic.com/v1',
          });
          return await provider.getCatalog(credential.apiKey, staticCatalog, forceRefresh);
        } catch (error) {
          logger.warn('Failed to fetch Anthropic dynamic catalog for instance, using static', {
            instanceId,
            error,
          });
        }
      }
    } else if (instance.catalogProviderId === 'openai') {
      const provider = new OpenAIDynamicProvider(instanceId, instance.endpoint);
      const staticCatalog = this.catalogManager.getProvider('openai');

      if (staticCatalog) {
        try {
          logger.debug('Fetching dynamic catalog for OpenAI instance', {
            instanceId,
            endpoint: instance.endpoint || 'https://api.openai.com/v1',
          });
          return await provider.getCatalog(credential.apiKey, staticCatalog, forceRefresh);
        } catch (error) {
          logger.warn('Failed to fetch OpenAI dynamic catalog for instance, using static', {
            instanceId,
            error,
          });
        }
      }
    } else if (instance.catalogProviderId === 'openrouter') {
      const provider = new OpenRouterDynamicProvider(instanceId);
      const config = instance.modelConfig ?? {
        enableNewModels: true,
        disabledModels: [],
        disabledProviders: [],
      };

      try {
        logger.debug('Fetching dynamic catalog for OpenRouter instance', { instanceId });
        return await provider.getCatalogWithConfig(credential.apiKey, config, forceRefresh);
      } catch (error) {
        logger.warn('Failed to fetch OpenRouter dynamic catalog for instance, using static', {
          instanceId,
          error,
        });
      }
    }

    // Fall back to static catalog
    return this.catalogManager.getProvider(instance.catalogProviderId);
  }

  async getCatalogProvider(
    providerId: string,
    forceRefresh = false
  ): Promise<CatalogProvider | null> {
    await this.ensureInitialized();

    // Special handling for Anthropic
    if (providerId === 'anthropic') {
      // Skip dynamic catalog if explicitly disabled
      if (process.env.LACE_DISABLE_DYNAMIC_CATALOGS === '1') {
        return this.catalogManager.getProvider('anthropic');
      }

      // Check if we have an instance with API key
      const instances = await this.instanceManager.loadInstances();
      const anthropicInstance = Object.entries(instances.instances).find(
        ([_, inst]) => inst.catalogProviderId === 'anthropic'
      );

      if (anthropicInstance) {
        const [instanceId, instance] = anthropicInstance;
        const credential = this.instanceManager.loadCredential(instanceId);

        if (credential?.apiKey) {
          const provider = new AnthropicDynamicProvider(instanceId, instance.endpoint);
          const staticCatalog = this.catalogManager.getProvider('anthropic');

          if (staticCatalog) {
            try {
              return await provider.getCatalog(credential.apiKey, staticCatalog, forceRefresh);
            } catch (error) {
              logger.warn('Failed to fetch Anthropic dynamic catalog, using static', { error });
            }
          }
        }
      }
    }

    // Special handling for OpenRouter
    if (providerId === 'openrouter') {
      // Check if we have an instance with API key
      const instances = await this.instanceManager.loadInstances();
      const openRouterInstance = Object.entries(instances.instances).find(
        ([_, inst]) => inst.catalogProviderId === 'openrouter'
      );

      if (openRouterInstance) {
        const [instanceId, instance] = openRouterInstance;
        const credential = this.instanceManager.loadCredential(instanceId);

        if (credential?.apiKey) {
          const provider = new OpenRouterDynamicProvider(instanceId);
          const config = instance.modelConfig ?? {
            enableNewModels: true,
            disabledModels: [],
            disabledProviders: [],
          };

          try {
            return await provider.getCatalogWithConfig(credential.apiKey, config);
          } catch (error) {
            logger.warn('Failed to fetch dynamic catalog, using static', { error });
          }
        }
      }
    }

    // Special handling for OpenAI
    if (providerId === 'openai') {
      // Check if we have an instance with API key
      const instances = await this.instanceManager.loadInstances();
      const openAIInstance = Object.entries(instances.instances).find(
        ([_, inst]) => inst.catalogProviderId === 'openai'
      );

      if (openAIInstance) {
        const [instanceId, instance] = openAIInstance;
        const credential = this.instanceManager.loadCredential(instanceId);

        if (credential?.apiKey) {
          // Pass instance endpoint (if configured) to dynamic provider
          const provider = new OpenAIDynamicProvider(instanceId, instance.endpoint);
          const staticCatalog = this.catalogManager.getProvider('openai');

          if (staticCatalog) {
            try {
              return await provider.getCatalog(credential.apiKey, staticCatalog, forceRefresh);
            } catch (error) {
              logger.warn('Failed to fetch OpenAI dynamic catalog, using static', { error });
            }
          }
        }
      }
    }

    // Fall back to static catalog
    return this.catalogManager.getProvider(providerId);
  }

  async getConfiguredInstances(): Promise<ConfiguredInstance[]> {
    await this.ensureInitialized();

    // Load instances fresh from disk every time
    const config = await this.instanceManager.loadInstances();
    const instances: ConfiguredInstance[] = [];

    for (const [instanceId, instance] of Object.entries(config.instances)) {
      // Check if credentials exist without loading them
      const hasCredentials = this.instanceManager.loadCredential(instanceId) != null;

      instances.push({
        id: instanceId,
        displayName: instance.displayName,
        catalogProviderId: instance.catalogProviderId,
        endpoint: instance.endpoint,
        timeout: instance.timeout,
        retryPolicy: instance.retryPolicy,
        modelConfig: instance.modelConfig,
        hasCredentials,
      });
    }

    return instances;
  }

  async getModelFromCatalog(providerId: string, modelId: string): Promise<CatalogModel | null> {
    await this.ensureInitialized();

    // Resolve aliases against the live (dynamic) catalog first, falling back to
    // the built-in static catalog. The live catalog may be missing alias-matching
    // entries (cold cache, partial refresh) even when the static catalog has them.
    const staticCatalog = this.catalogManager.getProvider(providerId);
    const catalog = await this.getCatalogProvider(providerId);
    if (catalog) {
      const resolvedModelId = resolveModelAlias(modelId, catalog.models, staticCatalog?.models);
      const model =
        catalog.models.find((m) => m.id === resolvedModelId) ??
        staticCatalog?.models.find((m) => m.id === resolvedModelId);
      if (model) {
        return model;
      }
    }

    // Fall back to static catalog with the original input (no alias resolution).
    return this.catalogManager.getModel(providerId, modelId);
  }

  async createProviderFromInstance(instanceId: string): Promise<AIProvider> {
    await this.ensureInitialized();

    // Load fresh instance data
    const config = await this.instanceManager.loadInstances();
    const instance = config.instances[instanceId];
    if (!instance) {
      throw new Error(`Provider instance not found: ${instanceId}`);
    }

    const credentials = this.instanceManager.loadCredential(instanceId);
    if (credentials == null) {
      throw new Error(`No credentials found for instance: ${instanceId}`);
    }

    const catalogProvider = this.catalogManager.getProvider(instance.catalogProviderId);
    if (!catalogProvider) {
      throw new Error(`Catalog provider not found: ${instance.catalogProviderId}`);
    }

    // Fetch dynamic catalog for this specific instance to ensure we have latest models
    const instanceCatalog = await this.getCatalogForInstance(instanceId);

    // Build provider config from instance and credentials
    // Priority: instance.endpoint > catalog.api_endpoint (with env expansion) > provider default
    const trimmedInstance = (instance.endpoint ?? '').trim();
    const trimmedCatalog = (expandEnvVar(catalogProvider.api_endpoint) ?? '').trim();
    const baseURL = trimmedInstance || trimmedCatalog;
    const providerConfig: ProviderConfig = {
      apiKey: credentials.apiKey,
      ...(credentials.additionalAuth || {}),
      ...(baseURL && { baseURL }),
      ...(instance.timeout && { timeout: instance.timeout }),
      // Pass instance catalog (with dynamic models) so provider can look up model metadata
      catalogProvider: instanceCatalog ?? catalogProvider,
    };

    // Create provider using the existing createProvider method
    return this.createProvider(catalogProvider.type, providerConfig);
  }

  async createProviderFromInstanceAndModel(
    instanceId: string,
    modelId: string
  ): Promise<AIProvider> {
    await this.ensureInitialized();

    // Load fresh instance data
    const config = await this.instanceManager.loadInstances();
    const instance = config.instances[instanceId];
    if (!instance) {
      throw new Error(`Provider instance not found: ${instanceId}`);
    }

    const credentials = this.instanceManager.loadCredential(instanceId);
    if (credentials == null) {
      throw new Error(`No credentials found for instance: ${instanceId}`);
    }

    const catalogProvider = this.catalogManager.getProvider(instance.catalogProviderId);
    if (!catalogProvider) {
      throw new Error(`Catalog provider not found: ${instance.catalogProviderId}`);
    }

    // Fetch dynamic catalog for this specific instance to ensure we have latest models
    // This triggers catalog discovery for instances like Apple FM that have custom endpoints
    const instanceCatalog = await this.getCatalogForInstance(instanceId);

    // Verify model exists in instance-specific catalog. Resolve aliases through the
    // live catalog with the built-in static catalog as a fallback — the live catalog
    // may lack alias-matching entries (cold cache, partial refresh) even when the
    // static catalog has them. The resolved model can come from either source.
    const staticCatalog = this.catalogManager.getProvider(instance.catalogProviderId);
    const catalog = instanceCatalog ?? staticCatalog;
    const resolvedModelId = resolveModelAlias(
      modelId,
      catalog?.models ?? [],
      staticCatalog?.models
    );
    if (resolvedModelId !== modelId) {
      logger.debug('Resolved model alias', {
        input: modelId,
        resolved: resolvedModelId,
        instanceId,
      });
    }
    const model =
      catalog?.models.find((m) => m.id === resolvedModelId) ??
      staticCatalog?.models.find((m) => m.id === resolvedModelId);

    if (!model) {
      throw new Error(
        `Model not found in catalog: ${modelId} for instance ${instanceId} (provider ${instance.catalogProviderId})`
      );
    }

    // Build provider config with model
    // Priority: instance.endpoint > catalog.api_endpoint (with env expansion) > provider default
    const trimmedInstance = (instance.endpoint ?? '').trim();
    const trimmedCatalog = (expandEnvVar(catalogProvider.api_endpoint) ?? '').trim();
    const baseURL = trimmedInstance || trimmedCatalog;
    const providerConfig: ProviderConfig = {
      model: resolvedModelId,
      apiKey: credentials.apiKey,
      ...(credentials.additionalAuth || {}),
      ...(baseURL && { baseURL }),
      ...(instance.timeout && { timeout: instance.timeout }),
      // Pass instance catalog (with dynamic models) so provider can look up model metadata
      catalogProvider: catalog ?? catalogProvider,
    };

    // Create provider using the existing createProvider method
    return this.createProvider(catalogProvider.type, providerConfig);
  }

  registerProvider(provider: AIProvider): void {
    this._providers.set(provider.providerName, provider);
  }

  getProvider(name: string): AIProvider | undefined {
    return this._providers.get(name);
  }

  getAllProviders(): AIProvider[] {
    return Array.from(this._providers.values());
  }

  getProviderNames(): string[] {
    return Array.from(this._providers.keys());
  }

  // Get all available provider metadata
  getAvailableProviders(): Array<{
    info: ProviderInfo;
    models: ModelInfo[];
    configured: boolean;
  }> {
    const providers = ['anthropic', 'bedrock', 'openai', 'gemini', 'lmstudio', 'ollama'];
    const results = [];

    for (const providerName of providers) {
      try {
        // Try to create provider instance to check configuration
        const provider = this.createProvider(providerName);
        const info = provider.getProviderInfo();
        const models = provider.getAvailableModels();
        const configured = provider.isConfigured();

        // Clean up provider
        provider.cleanup();

        results.push({ info, models, configured });
      } catch {
        // Provider not configured - still return info
        const tempProvider = this.getProviderForMetadata(providerName);
        if (tempProvider) {
          const info = tempProvider.getProviderInfo();
          const models = tempProvider.getAvailableModels();
          tempProvider.cleanup();
          results.push({ info, models, configured: false });
        }
      }
    }

    return results;
  }

  // Helper to get provider instance for metadata without full instantiation
  private getProviderForMetadata(providerName: string): AIProvider | null {
    try {
      // Try to get catalog data for this provider
      const catalogProvider = this.catalogManager.getProvider(providerName);

      switch (providerName.toLowerCase()) {
        case 'anthropic': {
          // Metadata-only instance (no API calls); include catalog for models
          return new AnthropicProvider({
            apiKey: null,
            ...(catalogProvider ? { catalogProvider } : {}),
          });
        }
        case 'bedrock': {
          // Metadata-only instance (no API calls); include catalog for models
          return new BedrockProvider({
            ...(catalogProvider ? { catalogProvider } : {}),
          });
        }
        case 'openai': {
          // Metadata-only instance (no API calls); include catalog for models
          return new OpenAIProvider({
            apiKey: null,
            ...(catalogProvider ? { catalogProvider } : {}),
          });
        }
        case 'gemini': {
          // Metadata-only instance (no API calls); include catalog for models
          return new GeminiProvider({
            apiKey: null,
            ...(catalogProvider ? { catalogProvider } : {}),
          });
        }
        case 'lmstudio': {
          return new LMStudioProvider({ baseURL: 'http://localhost:1234' });
        }
        case 'ollama': {
          return new OllamaProvider({ baseURL: 'http://localhost:11434' });
        }
        default:
          return null;
      }
    } catch {
      return null;
    }
  }

  /**
   * Internal method to create a provider directly.
   * @internal This should only be used by agents for provider creation.
   * All other code should use createProviderFromInstance or createProviderFromInstanceAndModel.
   */
  createProvider(providerName: string, config: ProviderConfig = {}): AIProvider {
    logger.debug('ProviderRegistry.createProvider called', {
      providerName,
      hasApiKey: !!config.apiKey,
      configKeys: Object.keys(config),
    });

    // Use static imports for better build performance
    switch (providerName.toLowerCase()) {
      case 'anthropic': {
        logger.debug('Creating AnthropicProvider', {
          hasApiKey: !!config.apiKey,
        });
        return new AnthropicProvider({
          ...config,
          apiKey: typeof config.apiKey === 'string' ? config.apiKey : null,
        });
      }
      case 'bedrock': {
        logger.debug('Creating BedrockProvider', {
          hasExplicitCreds:
            typeof config.awsAccessKeyId === 'string' &&
            typeof config.awsSecretAccessKey === 'string',
          awsRegion: typeof config.awsRegion === 'string' ? config.awsRegion : undefined,
        });
        return new BedrockProvider({
          ...config,
          awsRegion: typeof config.awsRegion === 'string' ? config.awsRegion : undefined,
          awsAccessKeyId:
            typeof config.awsAccessKeyId === 'string' ? config.awsAccessKeyId : undefined,
          awsSecretAccessKey:
            typeof config.awsSecretAccessKey === 'string' ? config.awsSecretAccessKey : undefined,
          awsSessionToken:
            typeof config.awsSessionToken === 'string' ? config.awsSessionToken : undefined,
        });
      }
      case 'openai': {
        logger.debug('Creating OpenAIProvider', {
          hasApiKey: !!config.apiKey,
        });
        return new OpenAIProvider({
          ...config,
          apiKey: typeof config.apiKey === 'string' ? config.apiKey : null,
        });
      }
      case 'gemini': {
        logger.debug('Creating GeminiProvider', {
          hasApiKey: !!config.apiKey,
        });
        return new GeminiProvider({
          ...config,
          apiKey: typeof config.apiKey === 'string' ? config.apiKey : null,
        });
      }
      case 'lmstudio': {
        return new LMStudioProvider(config);
      }
      case 'ollama': {
        return new OllamaProvider(config);
      }
      case 'test-provider': {
        // Mock provider needs to be handled differently for tests
        throw new Error('Test provider not supported in production builds');
      }
      default:
        throw new Error(
          `Unknown provider: ${providerName}. Available providers: ${this.getProviderNames().join(', ')}`
        );
    }
  }

  static isProviderClass(value: unknown): boolean {
    // Check if it's a constructor function/class
    if (typeof value !== 'function') return false;
    if (!value.prototype) return false;
    // Check if the class name ends with "Provider" (simple heuristic)
    if (!value.name || !value.name.endsWith('Provider')) return false;
    // Check if it extends AIProvider by checking the prototype chain
    let proto = value.prototype as unknown;
    while (proto) {
      if ((proto as { constructor: { name: string } }).constructor.name === 'AIProvider')
        return true;
      proto = Object.getPrototypeOf(proto);
    }
    return false;
  }

  getInstanceManager(): ProviderInstanceManager {
    return this.instanceManager;
  }
}
