// src/config-manager.ts
import { EventEmitter } from 'events';
import { readFileSync, writeFileSync, statSync } from 'fs';
import { stringify } from 'yaml';
import { loadConfig, toSnakeKeys, type AppConfig } from './config.js';
import { createHash } from 'crypto';
import { logger } from './logger.js';

const MASKED = '********';
const SENSITIVE_PATHS = ['tools.xAuthToken', 'tools.xCt0'];

/**
 * Keys stripped from `getConfigForApi` output. The v2 contract (design doc
 * 2026-05-21) says GET emits ONLY the new shape: `config_version: 2` +
 * `llm.*` + `storage.*` + `gateway.*` + every other top-level key that
 * doesn't have a v2 supersession. The legacy `provider` block and the flat
 * storage keys live on disk during the compat window but must not appear
 * in API responses — UI / external callers should treat the v2 shape as
 * the canonical contract.
 */
const V2_STRIPPED_TOP_LEVEL_KEYS = [
  'provider',
  'worktreeDir',
  'customPiecesDir',
  'userFolderRoot',
] as const;

/**
 * Keys inside `tools.*` that were promoted into `storage.*` in v2. Stripped
 * from GET output so the UI doesn't see them under both blocks.
 */
const V2_STRIPPED_TOOLS_KEYS = ['taskUploadMaxSizeMb', 'trashRetentionDays'] as const;

interface UpdateSuccess { ok: true }
interface UpdateFailure { ok: false; errors?: unknown; conflict?: boolean; message?: string }
type UpdateResult = UpdateSuccess | UpdateFailure;

export class ConfigManager {
  private currentConfig: AppConfig;
  private configPath: string;
  private emitter = new EventEmitter();

  constructor(configPath: string) {
    this.configPath = configPath;
    this.currentConfig = loadConfig(configPath);
  }

  getConfig(): AppConfig {
    return this.currentConfig;
  }

  getConfigForApi(): { config: Record<string, unknown>; etag: string; overriddenByEnv: Record<string, boolean> } {
    const config = JSON.parse(JSON.stringify(this.currentConfig)) as Record<string, unknown>;

    for (const path of SENSITIVE_PATHS) {
      const parts = path.split('.');
      let obj: any = config;
      for (let i = 0; i < parts.length - 1; i++) {
        obj = obj?.[parts[i]];
        if (!obj) break;
      }
      if (obj && parts[parts.length - 1] in obj) {
        obj[parts[parts.length - 1]] = MASKED;
      }
    }

    // Mask all API keys in knowledgeNamespaces
    const namespaces = (config as any).tools?.knowledgeNamespaces;
    if (namespaces && typeof namespaces === 'object') {
      for (const ns of Object.values(namespaces)) {
        if (ns && typeof ns === 'object' && 'apiKey' in ns) {
          (ns as any).apiKey = MASKED;
        }
      }
    }

    // Mask api keys in llm.workers[] and gateway.backends[]. Both
    // arrays carry per-endpoint bearer tokens (worker apiKey is used for
    // AAO Gateway / LiteLLM bearer auth, backend apiKey is forwarded to
    // the upstream LLM server). The mask preserves array order and
    // indices so the UI can keep editing other fields without losing the
    // row's identity.
    const llmWorkers = (config as any).llm?.workers;
    if (Array.isArray(llmWorkers)) {
      for (const w of llmWorkers) {
        if (w && typeof w === 'object' && typeof (w as any).apiKey === 'string' && (w as any).apiKey !== '') {
          (w as any).apiKey = MASKED;
        }
      }
    }
    const gwBackends = (config as any).gateway?.backends;
    if (Array.isArray(gwBackends)) {
      for (const b of gwBackends) {
        if (b && typeof b === 'object' && typeof (b as any).apiKey === 'string' && (b as any).apiKey !== '') {
          (b as any).apiKey = MASKED;
        }
      }
    }

    // v2 contract: stamp config_version=2 + strip the legacy keys that v2
    // supersedes. The normalizer keeps the legacy `provider` block alive
    // for in-process downstream readers (worker.ts etc.), but the API
    // surface MUST present a single source of truth so the UI / external
    // callers can rely on the v2 layout exclusively.
    (config as any).configVersion = 2;
    for (const key of V2_STRIPPED_TOP_LEVEL_KEYS) {
      delete (config as any)[key];
    }
    if ((config as any).tools && typeof (config as any).tools === 'object') {
      for (const key of V2_STRIPPED_TOOLS_KEYS) {
        delete (config as any).tools[key];
      }
    }

    // Flag fields overridden by environment variables. Paths reflect the
    // v2 shape so the UI's overriddenByEnv lookup matches its bind paths.
    const overriddenByEnv: Record<string, boolean> = {};
    if (process.env['OLLAMA_BASE_URL']) overriddenByEnv['llm.workers[0].endpoint'] = true;
    if (process.env['OLLAMA_MODEL']) overriddenByEnv['llm.workers[0].model'] = true;
    if (process.env['WORKTREE_DIR']) overriddenByEnv['storage.worktreeDir'] = true;
    if (process.env['CONCURRENCY']) overriddenByEnv['concurrency'] = true;
    if (process.env['DB_PATH']) overriddenByEnv['dbPath'] = true;

    return { config, etag: this.computeEtag(), overriddenByEnv };
  }

  updateConfig(partial: Record<string, unknown>, etag?: string): UpdateResult {
    const prevCustomPiecesDir = this.currentConfig.customPiecesDir;

    if (etag && etag !== this.computeEtag()) {
      return { ok: false, conflict: true, message: 'Config was modified externally' };
    }

    // Preserve masked sensitive fields
    const merged = this.mergeWithMaskPreservation(partial);

    // v2 write contract: stamp config_version=2 and strip the legacy
    // top-level keys that v2 supersedes. The legacy `provider` block was
    // already mirrored into `llm.*` by the normalizer (Step 1); writing
    // both blocks back would create a drift hazard where on-disk
    // `provider.workers` and `llm.workers` go out of sync the next time
    // someone hand-edits the YAML. v2 emits ONLY the v2 shape — the
    // normalizer rebuilds the in-process `provider` mirror on reload.
    (merged as any).configVersion = 2;
    for (const key of V2_STRIPPED_TOP_LEVEL_KEYS) {
      delete (merged as any)[key];
    }
    if ((merged as any).tools && typeof (merged as any).tools === 'object') {
      for (const key of V2_STRIPPED_TOOLS_KEYS) {
        delete (merged as any).tools[key];
      }
    }

    // Convert to snake_case and generate YAML string
    const snakeConfig = toSnakeKeys(merged) as Record<string, unknown>;
    const yamlStr = stringify(snakeConfig, { lineWidth: 120 });

    // Validate BEFORE writing: backup, write, validate, rollback on failure
    const backupContent = readFileSync(this.configPath, 'utf-8');
    try {
      writeFileSync(this.configPath, yamlStr, 'utf-8');
      logger.info(`[config-manager] config written to ${this.configPath}`);
      this.currentConfig = loadConfig(this.configPath);
    } catch (e) {
      // Restore original file on validation failure
      writeFileSync(this.configPath, backupContent, 'utf-8');
      logger.warn(`[config-manager] config update failed, reverted: ${e}`);
      return { ok: false, errors: e, message: 'Invalid config — changes reverted' };
    }

    if (prevCustomPiecesDir !== this.currentConfig.customPiecesDir) {
      logger.info(`[config-manager] customPiecesDir changed old=${prevCustomPiecesDir ?? 'none'} new=${this.currentConfig.customPiecesDir ?? 'none'}`);
    }

    this.emitter.emit('config-changed', this.currentConfig);
    return { ok: true };
  }

  reloadFromFile(): AppConfig {
    logger.info(`[config-manager] reloading config from ${this.configPath}`);
    this.currentConfig = loadConfig(this.configPath);
    this.emitter.emit('config-changed', this.currentConfig);
    return this.currentConfig;
  }

  onConfigChanged(callback: (config: AppConfig) => void): void {
    this.emitter.on('config-changed', callback);
  }

  private computeEtag(): string {
    try {
      const stat = statSync(this.configPath);
      return createHash('md5').update(`${stat.mtimeMs}-${stat.size}`).digest('hex').slice(0, 16);
    } catch {
      return 'unknown';
    }
  }

  private mergeWithMaskPreservation(partial: Record<string, unknown>): Record<string, unknown> {
    const current = JSON.parse(JSON.stringify(this.currentConfig));
    const merged = deepMergeConfig(current, partial);

    // Restore masked values
    for (const path of SENSITIVE_PATHS) {
      const parts = path.split('.');
      let mergedObj: any = merged;
      let currentObj: any = current;
      for (let i = 0; i < parts.length - 1; i++) {
        mergedObj = mergedObj?.[parts[i]];
        currentObj = currentObj?.[parts[i]];
        if (!mergedObj || !currentObj) break;
      }
      const lastKey = parts[parts.length - 1];
      if (mergedObj && mergedObj[lastKey] === MASKED && currentObj) {
        mergedObj[lastKey] = currentObj[lastKey];
      }
    }

    // Restore masked knowledgeNamespaces API keys
    const mergedNs = (merged as any).tools?.knowledgeNamespaces;
    const currentNs = current.tools?.knowledgeNamespaces;
    if (mergedNs && currentNs) {
      for (const [name, val] of Object.entries(mergedNs)) {
        if ((val as any)?.apiKey === MASKED && currentNs[name]) {
          (val as any).apiKey = currentNs[name].apiKey;
        }
      }
    }

    // Restore masked provider.workers[].apiKey by matching on worker id
    // (preferred) or array index (fallback). Matching on id survives
    // reordering, which is needed because the UI may sort rows before
    // saving even if the operator didn't touch the api key.
    //
    // NOTE: The v2 API contract drops `provider.*` from PUT bodies, so this
    // branch only runs when callers still send legacy v1-shaped payloads
    // (Step 2 keeps mask-preservation here as a belt-and-suspenders measure
    // — the bridge layer rejects them with 400 first).
    const mergedWorkers = (merged as any).provider?.workers;
    const currentWorkers = current.provider?.workers;
    if (Array.isArray(mergedWorkers) && Array.isArray(currentWorkers)) {
      mergedWorkers.forEach((mw: any, idx: number) => {
        if (!mw || typeof mw !== 'object' || mw.apiKey !== MASKED) return;
        const match = (mw.id ? currentWorkers.find((cw: any) => cw?.id === mw.id) : undefined)
          ?? currentWorkers[idx];
        if (match && typeof match.apiKey === 'string') {
          mw.apiKey = match.apiKey;
        } else {
          // existing key was undefined — drop the mask so it stays undefined
          delete mw.apiKey;
        }
      });
    }

    // Same id-or-index strategy for the v2 `llm.workers[].apiKey`. Sources
    // for the "current" key are checked in priority order: llm.workers (v2),
    // then provider.workers (legacy mirror) — whichever has a matching id
    // wins. This lets v2 PUTs preserve secrets even if the operator's prior
    // YAML was authored in v1 shape.
    const mergedLlmWorkers = (merged as any).llm?.workers;
    const currentLlmWorkers = current.llm?.workers;
    if (Array.isArray(mergedLlmWorkers)) {
      mergedLlmWorkers.forEach((mw: any, idx: number) => {
        if (!mw || typeof mw !== 'object' || mw.apiKey !== MASKED) return;
        const fromLlm = Array.isArray(currentLlmWorkers)
          ? (mw.id ? currentLlmWorkers.find((cw: any) => cw?.id === mw.id) : undefined) ?? currentLlmWorkers[idx]
          : undefined;
        const fromProvider = Array.isArray(currentWorkers)
          ? (mw.id ? currentWorkers.find((cw: any) => cw?.id === mw.id) : undefined)
          : undefined;
        const matched = fromLlm ?? fromProvider;
        if (matched && typeof (matched as any).apiKey === 'string') {
          mw.apiKey = (matched as any).apiKey;
        } else {
          delete mw.apiKey;
        }
      });
    }

    // Restore masked gateway.backends[].apiKey using the same id-or-index
    // strategy as workers above.
    const mergedBackends = (merged as any).gateway?.backends;
    const currentBackends = current.gateway?.backends;
    if (Array.isArray(mergedBackends) && Array.isArray(currentBackends)) {
      mergedBackends.forEach((mb: any, idx: number) => {
        if (!mb || typeof mb !== 'object' || mb.apiKey !== MASKED) return;
        const match = (mb.id ? currentBackends.find((cb: any) => cb?.id === mb.id) : undefined)
          ?? currentBackends[idx];
        if (match && typeof match.apiKey === 'string') {
          mb.apiKey = match.apiKey;
        } else {
          delete mb.apiKey;
        }
      });
    }
    return merged;
  }
}

function deepMergeConfig(base: any, override: any): any {
  if (override === null || override === undefined) return base;
  if (typeof override !== 'object' || Array.isArray(override)) return override;
  const result = { ...base };
  for (const [key, value] of Object.entries(override)) {
    if (typeof value === 'object' && value !== null && !Array.isArray(value) && typeof result[key] === 'object') {
      result[key] = deepMergeConfig(result[key], value);
    } else {
      result[key] = value;
    }
  }
  return result;
}
