import { z } from "zod";
import Conf from "conf";
import { randomUUID } from "crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { logger } from "./logger";
import { getDataDir, hasExplicitDataDir } from "./dataDir";
import { ProviderConfigSchema, type ProviderConfig } from "../lib/providerContract";

export { ProviderConfigSchema };
export type { ProviderConfig };

const ProvidersStoreSchema = z.object({
  providers: z.array(ProviderConfigSchema),
});

type ProvidersStore = z.infer<typeof ProvidersStoreSchema>;

/**
 * One-time migration from the legacy conf v15 default location
 * (`%APPDATA%\llm-inspector-nodejs\Config\config.json` on Windows,
 * `$XDG_CONFIG_HOME/llm-inspector-nodejs/Config/config.json` on Linux).
 * Older versions of llm-inspector let the `conf` library pick the path,
 * which produced a project-suffixed directory under the OS user-config
 * root. Users who upgrade now have their providers copied to the new
 * `<dataDir>/providers.json` location; the legacy file is left in
 * place for safety and can be deleted manually.
 *
 * The check is "new store is still empty" rather than "new file doesn't
 * exist" because `Conf` materialises the defaults file on construction
 * — so the file is always present immediately after the `new Conf()`
 * call above, even on a brand-new install.
 */
function migrateFromLegacyConfLocation(targetStore: Conf<ProvidersStore>): void {
  if (process.env["LLM_INSPECTOR_CONFIG_PATH"] !== undefined) return;
  if (hasExplicitDataDir()) return;
  if (targetStore.get("providers", []).length > 0) return;
  let legacyStore: Conf<ProvidersStore> | undefined;
  try {
    legacyStore = new Conf<ProvidersStore>({
      projectName: "llm-inspector",
      defaults: { providers: [] },
    });
    const legacyProviders = legacyStore.get("providers", []);
    if (legacyProviders.length === 0) return;
    targetStore.set("providers", legacyProviders);
    logger.info(
      `[providers] Migrated ${legacyProviders.length} provider(s) from ${legacyStore.path} to ${targetStore.path}`,
    );
  } catch (err) {
    logger.warn(`[providers] Legacy migration failed: ${String(err)}`);
  } finally {
    legacyStore?._closeWatcher();
  }
}

// Using conf for storage - works in any Node.js environment without Electron
// Note: conf stores data in plain JSON. For production, consider additional encryption.
const configPath = process.env["LLM_INSPECTOR_CONFIG_PATH"];

// The data directory is shared with the runtime config (`config.ts`).
// We pass `cwd` so conf writes the file as `<cwd>/<configName>.<ext>`,
// i.e. `<dataDir>/providers.json`. The runtime config lives at
// `<dataDir>/config.json`; the different filenames keep the two stores
// from overwriting each other. `mkdirSync(..., { recursive: true })` is
// a no-op when the directory already exists.
const dataDir = getDataDir();
if (configPath === undefined) {
  mkdirSync(dataDir, { recursive: true });
}

export const store = new Conf<ProvidersStore>({
  cwd: dataDir,
  configName: "providers",
  defaults: {
    providers: [],
  },
  ...(configPath !== undefined ? { path: configPath } : {}),
});

// Run the legacy migration once on module load. No-op when the new file
// already has data, when the user has set LLM_INSPECTOR_CONFIG_PATH or
// an explicit data dir, or when the legacy file is empty.
migrateFromLegacyConfLocation(store);

/**
 * Expands the old `{ format, baseUrl }` representation into the matching
 * format-specific URL. Once either format-specific field exists, the record is
 * already on the current schema and must be preserved exactly: an empty or
 * missing counterpart means that protocol was intentionally not configured.
 */
export function migrateProvider(p: ProviderConfig): ProviderConfig {
  if (p.anthropicBaseUrl !== undefined || p.openaiBaseUrl !== undefined) {
    return p;
  }
  if (p.format === undefined || p.baseUrl === undefined || p.baseUrl === "") {
    return p;
  }

  return p.format === "openai"
    ? { ...p, anthropicBaseUrl: "", openaiBaseUrl: p.baseUrl }
    : { ...p, anthropicBaseUrl: p.baseUrl, openaiBaseUrl: "" };
}

/**
 * Migrates existing provider configs to preserve both Anthropic and OpenAI URLs.
 * Old configs had anthropicBaseUrl/openaiBaseUrl, we now keep both.
 */
function migrateProviders(): void {
  const providers = store.get("providers", []);
  const updated = providers.map(migrateProvider);
  const migrated = updated.some((p, i) => JSON.stringify(p) !== JSON.stringify(providers[i]));

  if (migrated) {
    store.set("providers", updated);
  }
}

// Run migration on module load
migrateProviders();

/**
 * Migrate: merge providers with identical (apiKey, anthropicBaseUrl, openaiBaseUrl)
 * into multi-model providers. Existing single-model providers get their `model`
 * value promoted to `models: [model]`.
 */
function migrateMultiModel(): void {
  const providers = store.get("providers", []);
  if (providers.length === 0) return;
  let changed = false;

  // Step 1: Promote single model to models array for any provider missing models
  const promoted = providers.map((p) => {
    if (p.models !== undefined && p.models.length > 0) return p;
    const models = p.model !== undefined && p.model !== "" ? [p.model] : [];
    changed = true;
    return { ...p, models };
  });

  // Step 2: Merge providers with same (apiKey, anthropicBaseUrl, openaiBaseUrl)
  const groups = new Map<string, ProviderConfig[]>();
  for (const p of promoted) {
    const key = `${p.apiKey}::${p.anthropicBaseUrl ?? ""}::${p.openaiBaseUrl ?? ""}`;
    const group = groups.get(key);
    if (group !== undefined) {
      group.push(p);
    } else {
      groups.set(key, [p]);
    }
  }

  const merged: ProviderConfig[] = [];
  for (const group of groups.values()) {
    const first = group[0];
    if (group.length === 1 && first !== undefined) {
      merged.push(first);
      continue;
    }
    changed = true;
    // Merge: keep the earliest provider's metadata, combine all models (deduplicated)
    const sorted = group.toSorted(
      (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
    );
    const earliest = sorted[0];
    if (earliest === undefined) continue;
    const allModels = new Set<string>();
    for (const p of sorted) {
      for (const m of p.models ?? []) {
        if (m !== "") allModels.add(m);
      }
    }
    // Take first non-empty source
    const mergedSource = sorted.find((p) => p.source !== undefined)?.source;
    merged.push({
      ...earliest,
      models: [...allModels],
      source: mergedSource,
      updatedAt: new Date().toISOString(),
    });
  }

  if (changed && merged.length > 0) {
    // Safety backup before writing
    try {
      writeFileSync(`${store.path}.bak`, readFileSync(store.path, "utf-8"));
    } catch {
      // best-effort, ignore
    }
    store.set("providers", merged);
  }
}

migrateMultiModel();

// Override with JSON env var if provided (for testing)
const providersJson = process.env["LLM_INSPECTOR_PROVIDERS_JSON"];
if (providersJson !== undefined) {
  try {
    const parsed = ProviderConfigSchema.array().safeParse(JSON.parse(providersJson));
    if (parsed.success) {
      // Apply migration to JSON providers (e.g., convert baseUrl to openaiBaseUrl/anthropicBaseUrl)
      const migrated = parsed.data.map(migrateProvider);
      store.set("providers", migrated);
    }
  } catch {
    // Ignore invalid JSON
  }
}

export function getProviders(): ProviderConfig[] {
  return store.get("providers", []);
}

export function getProvider(id: string): ProviderConfig | undefined {
  const providers = getProviders();
  return providers.find((p) => p.id === id);
}

/**
 * Normalizes an API key by stripping "Bearer " prefix if present.
 */
export function normalizeApiKey(apiKey: string): string {
  return apiKey.replace(/^Bearer\s+/i, "").trim();
}

export function addProvider(
  name: string,
  apiKey: string,
  format?: "anthropic" | "openai",
  baseUrl?: string,
  models?: string[],
  authHeader?: "bearer" | "x-api-key",
  apiDocsUrl?: string,
  anthropicBaseUrl?: string,
  openaiBaseUrl?: string,
  source?: "company" | "personal",
): ProviderConfig {
  const providers = getProviders();
  const normalizedKey = normalizeApiKey(apiKey);
  const resolvedAnthropicUrl =
    anthropicBaseUrl ?? (format === "anthropic" && baseUrl !== undefined ? baseUrl : "");
  const resolvedOpenaiUrl =
    openaiBaseUrl ?? (format === "openai" && baseUrl !== undefined ? baseUrl : "");
  const now = new Date().toISOString();

  // If a provider with the same (apiKey, anthropicBaseUrl, openaiBaseUrl) already exists,
  // merge the new models into it instead of creating a duplicate.
  const existing = providers.find(
    (p) =>
      p.apiKey === normalizedKey &&
      (p.anthropicBaseUrl ?? "") === (resolvedAnthropicUrl ?? "") &&
      (p.openaiBaseUrl ?? "") === (resolvedOpenaiUrl ?? ""),
  );
  if (existing) {
    const newModels = (models ?? []).filter((m) => m !== "");
    if (newModels.length > 0) {
      const mergedModels = new Set(existing.models ?? []);
      for (const m of newModels) mergedModels.add(m);
      existing.models = [...mergedModels];
      existing.updatedAt = now;
      store.set("providers", providers);
    }
    return existing;
  }

  const newProvider: ProviderConfig = {
    id: randomUUID(),
    name,
    apiKey: normalizedKey,
    format:
      format ??
      (anthropicBaseUrl !== undefined
        ? "anthropic"
        : openaiBaseUrl !== undefined
          ? "openai"
          : undefined),
    baseUrl,
    models: models ?? [],
    authHeader: authHeader ?? "bearer",
    apiDocsUrl,
    createdAt: now,
    updatedAt: now,
    anthropicBaseUrl: resolvedAnthropicUrl,
    openaiBaseUrl: resolvedOpenaiUrl,
    source,
  };
  providers.push(newProvider);
  store.set("providers", providers);
  return newProvider;
}

export function updateProvider(
  id: string,
  updates: Partial<Omit<ProviderConfig, "id" | "createdAt">>,
): ProviderConfig | null {
  const providers = getProviders();
  const existing = providers.find((p) => p.id === id);
  if (!existing) return null;

  const updated: ProviderConfig = {
    id: existing.id,
    name: updates.name ?? existing.name,
    apiKey: updates.apiKey !== undefined ? normalizeApiKey(updates.apiKey) : existing.apiKey,
    models: updates.models !== undefined ? updates.models : existing.models,
    format: updates.format ?? existing.format,
    baseUrl: updates.baseUrl !== undefined ? updates.baseUrl : existing.baseUrl,
    authHeader: updates.authHeader ?? existing.authHeader,
    apiDocsUrl: updates.apiDocsUrl ?? existing.apiDocsUrl,
    createdAt: existing.createdAt,
    updatedAt: new Date().toISOString(),
    // Handle format-specific URLs
    anthropicBaseUrl:
      updates.anthropicBaseUrl !== undefined ? updates.anthropicBaseUrl : existing.anthropicBaseUrl,
    openaiBaseUrl:
      updates.openaiBaseUrl !== undefined ? updates.openaiBaseUrl : existing.openaiBaseUrl,
    source: updates.source !== undefined ? updates.source : existing.source,
  };
  const index = providers.findIndex((p) => p.id === id);
  providers[index] = updated;
  store.set("providers", providers);
  return updated;
}

export function deleteProvider(id: string): boolean {
  const providers = getProviders();
  const filtered = providers.filter((p) => p.id !== id);
  if (filtered.length === providers.length) return false;

  store.set("providers", filtered);
  return true;
}

/**
 * Export all providers as a JSON string (without API keys for security).
 */
export function exportProviders(): string {
  const providers = getProviders();
  // Mask API keys for security
  const safeProviders = providers.map(({ apiKey, ...rest }) => ({
    ...rest,
    apiKey: maskApiKey(apiKey),
  }));
  return JSON.stringify(safeProviders, null, 2);
}

/**
 * Export all providers as a JSON string including API keys (user must opt-in).
 */
export function exportProvidersWithKeys(): string {
  return JSON.stringify(getProviders(), null, 2);
}

/**
 * Import providers from a JSON string.
 * Returns { imported: number, errors: string[] }
 */
export function importProviders(json: string): { imported: number; errors: string[] } {
  const errors: string[] = [];
  let imported = 0;

  try {
    const parsed: unknown = JSON.parse(json);

    // Use Zod to validate the structure
    const ImportSchema = z.union([
      z.array(ProviderConfigSchema),
      z.object({
        providers: z.array(ProviderConfigSchema),
      }),
    ]);

    const parseResult = ImportSchema.safeParse(parsed);
    if (!parseResult.success) {
      return {
        imported: 0,
        errors: [`Invalid format: ${parseResult.error.message}`],
      };
    }

    const providerArray = Array.isArray(parseResult.data)
      ? parseResult.data
      : parseResult.data.providers;

    // Snapshot existing providers once
    const existingProviders = getProviders();

    // Build a Set of existing (name, anthropicBaseUrl) keys for O(1) duplicate lookup
    const existingKeys = new Set<string>();
    for (const p of existingProviders) {
      existingKeys.add(`${p.name}::${p.anthropicBaseUrl ?? ""}`);
    }

    const newProviders: ProviderConfig[] = [];

    for (let i = 0; i < providerArray.length; i++) {
      const item = providerArray[i];
      const result = ProviderConfigSchema.safeParse(item);

      if (!result.success) {
        errors.push(`Provider ${i + 1}: ${result.error.message}`);
        continue;
      }

      const provider = result.data;
      const key = `${provider.name}::${provider.anthropicBaseUrl ?? ""}`;

      if (existingKeys.has(key)) {
        errors.push(`Provider "${provider.name}" already exists, skipping`);
        continue;
      }

      // Generate new ID and add
      const newProvider: ProviderConfig = {
        ...provider,
        id: randomUUID(),
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      };

      newProviders.push(newProvider);
      existingKeys.add(key);
      imported++;
    }

    // Single write: append all new providers at once
    if (newProviders.length > 0) {
      store.set("providers", [...existingProviders, ...newProviders]);
    }
  } catch (err) {
    return {
      imported: 0,
      errors: [`Failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`],
    };
  }

  return { imported, errors };
}

function maskApiKey(apiKey: string): string {
  if (apiKey.length <= 8) return "••••••••";
  return apiKey.slice(0, 4) + "••••••••" + apiKey.slice(-4);
}

/**
 * Converts display model name to API usage name based on provider-specific rules.
 * Default behavior: returns model as-is.
 * MiniMax: replaces spaces with hyphens.
 */
export function getModelUsageName(model: string, providerName?: string): string {
  if (
    providerName !== undefined &&
    providerName !== "" &&
    providerName.toLowerCase().includes("minimax")
  ) {
    return model.replace(/ /g, "-");
  }
  return model;
}

/**
 * Normalize a model name for comparison: lowercase, replace whitespace with hyphens.
 */
function normalizeModelName(name: string): string {
  return name.toLowerCase().replace(/\s+/g, "-");
}

/**
 * Finds a provider by model name using three strategies:
 * 1. Case-insensitive prefix match against "{provider.name}-" (e.g., "deepseek-" matches "deepseek-*")
 * 2. Case-insensitive match against provider.model field (with whitespace/hyphen normalization)
 * 3. Fallback: concatenate provider name with provider's model and check if the request model matches
 *    this concatenation (handles "MiniMax" + "M3" → "MiniMax-M3" pattern when case differs)
 */
export function findProviderByModel(model: string): ProviderConfig | null {
  const providers = getProviders();
  const modelLower = model.toLowerCase();
  const modelNormalized = normalizeModelName(model);

  for (const provider of providers) {
    // Strategy 1: provider name prefix match
    const providerPrefix = (provider.name + "-").toLowerCase();
    if (modelLower.startsWith(providerPrefix)) {
      return provider;
    }
    // Strategy 2: match against provider.models array with normalization
    const modelList = provider.models ?? (provider.model !== undefined ? [provider.model] : []);
    for (const m of modelList) {
      if (m !== "" && modelNormalized === normalizeModelName(m)) {
        return provider;
      }
    }
    // Strategy 3: fallback - concatenate provider name with model part and compare
    for (const m of modelList) {
      if (m !== "") {
        const modelPart = modelNormalized.replace(normalizeModelName(provider.name), "");
        const concatenated = normalizeModelName(provider.name) + modelPart;
        if (modelNormalized === concatenated) {
          return provider;
        }
      }
    }
  }
  return null;
}
