/**
 * Config normalization layer (Step 1 of the 2026-05-21 Settings UI / Config
 * Restructure design).
 *
 * Inputs come from two shapes:
 *
 *  - **v2** (`config_version: 2`): use as-is. No migration, no warnings.
 *  - **v1** (`config_version` missing or `1`): migrate the legacy
 *    `provider.*` + flat storage keys into the new `llm.*` and `storage.*`
 *    blocks. Log a single warning pointing operators at
 *    `scripts/migrate-config.sh`.
 *
 * Any other `config_version` value is a typo guard — we throw at load time
 * rather than silently treat it as v1. (Future v3 etc. will need to extend
 * this allowlist when they ship.)
 *
 * Invariants:
 *  - The normalizer NEVER touches secret string syntax. `${VAR}` /
 *    `env:VAR` references stay verbatim so YAML round-trips lossless.
 *  - The normalizer NEVER hard-fails on missing models. A v1 file with
 *    `provider.workers[*].model` blank AND `provider.model` blank produces
 *    `llm.workers[*].model: ''` + a logger.warn. The startup fatal would
 *    take down every single-Ollama deployment that hadn't yet migrated.
 *  - The legacy `provider` block stays populated even on v2 input. This
 *    keeps downstream readers (worker.ts, openai-compat, etc.) working
 *    without touching them in this PR.
 *
 * UI / API code is OUT OF SCOPE for this step. We only add internal types
 * + a function that's wired into `loadConfig`.
 */
import { logger } from './logger.js';
// Type-only imports break the otherwise circular dependency between
// config.ts ↔ config-normalize.ts (config.ts calls normalizeConfig at
// load time; the normalizer needs the shape definitions). Keeping these
// `import type` ensures the bundled output has no runtime cycle.
import type {
  AppConfig,
  LlmConfig,
  LlmWorkerDef,
  ProviderConfig,
  ProviderRetryConfig,
  StorageConfig,
  ToolsConfig,
  WorkerDef,
} from './config.js';

/** Schema versions this normalizer understands. */
const SUPPORTED_VERSIONS = new Set<number>([1, 2]);

/** Default "model is unset" sentinel — empty string is intentional, see header. */
const EMPTY_MODEL = '';

/**
 * Thrown when `config_version` is set to a value the loader doesn't
 * understand. Fatal by design: silently treating "version 99" as v1 would
 * mask config drift between AAO releases.
 */
export class UnsupportedConfigVersionError extends Error {
  constructor(public readonly version: unknown) {
    super(
      `config_version=${JSON.stringify(version)} is not supported by this AAO ` +
        `release (known versions: ${[...SUPPORTED_VERSIONS].sort().join(', ')}). ` +
        `Update config.yaml or run scripts/migrate-config.sh.`,
    );
    this.name = 'UnsupportedConfigVersionError';
  }
}

/**
 * Normalize a freshly-parsed (camelCased) config object. Returns an
 * AppConfig with the v2 blocks (`llm`, `storage`, `configVersion: 2`) always
 * populated. Legacy `provider` / `worktreeDir` / etc. stay populated for the
 * compat window.
 *
 * The caller is responsible for the YAML parse + `transformKeys` snake→camel
 * step. This function is pure (no fs, no env), which makes the migration
 * tests fixture-driven.
 */
export function normalizeConfig(raw: unknown): AppConfig {
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
    // Treat null / non-object input as "empty v1 file" — downstream
    // defaults in loadConfig will fill in everything.
    raw = {};
  }
  const input = raw as Record<string, unknown>;

  // 1. Resolve schema version (default = 1, the legacy shape).
  const version = resolveConfigVersion(input.configVersion);

  // 2. For v1 inputs we mutate a shallow copy so the caller's object isn't
  //    surprised. Deep clones are intentionally avoided — we just rebind
  //    the top-level keys we touch.
  const out: Record<string, unknown> = { ...input };

  if (version === 1) {
    migrateV1InPlace(out);
    logger.warn(
      '[config] migrated from v1; run scripts/migrate-config.sh to persist the new layout',
    );
  } else {
    // v2: copy any optional flat keys into the v2 blocks if the user
    // wrote a mix (e.g. set `llm` but kept legacy `worktree_dir`).
    backfillV2Blocks(out);
  }

  out.configVersion = 2;
  return out as unknown as AppConfig;
}

function resolveConfigVersion(raw: unknown): 1 | 2 {
  if (raw === undefined || raw === null) return 1;
  if (typeof raw !== 'number' || !Number.isInteger(raw)) {
    throw new UnsupportedConfigVersionError(raw);
  }
  if (!SUPPORTED_VERSIONS.has(raw)) {
    throw new UnsupportedConfigVersionError(raw);
  }
  return raw as 1 | 2;
}

/**
 * v1 → v2 migration. Mutates `out` in place:
 *  - `provider.*` is mirrored into `llm.*` (unless `llm` already present)
 *  - `worktree_dir` / `custom_pieces_dir` / `user_folder_root` /
 *    `tools.task_upload_max_size_mb` / `tools.trash_retention_days`
 *    are mirrored into `storage.*`
 *  - legacy keys are PRESERVED so downstream readers keep working.
 */
function migrateV1InPlace(out: Record<string, unknown>): void {
  const provider = (out.provider ?? {}) as ProviderConfig;
  const existingLlm = out.llm as Partial<LlmConfig> | undefined;

  const llm = existingLlm
    ? coerceLlm(existingLlm, provider)
    : llmFromProvider(provider);
  out.llm = llm;

  out.storage = buildStorage(out);
}

/**
 * v2 backfill for hand-edited mixed configs. If `llm.workers` is missing but
 * `provider.workers` exists, mirror them in. Same for storage flat keys.
 *
 * Also performs the *reverse* backfill: when a v2-only file is loaded (no
 * `provider.workers`, only `llm.workers`), we mirror `llm.workers` back into
 * the legacy `provider.workers` block so downstream readers (worker.ts /
 * openai-compat / WorkerManager — all still reading `config.provider.*` in
 * the compat window) keep working without per-call rewiring. Without this,
 * any deployment that goes through `PUT /api/config` (which emits v2-only
 * YAML) would silently fall back to the default single-worker built by
 * loadConfig, losing the user's actual worker list.
 */
function backfillV2Blocks(out: Record<string, unknown>): void {
  const llm = (out.llm ?? {}) as Partial<LlmConfig>;
  const provider = (out.provider ?? {}) as ProviderConfig;

  if (!Array.isArray(llm.workers) || llm.workers.length === 0) {
    if (Array.isArray(provider.workers) && provider.workers.length > 0) {
      llm.workers = provider.workers.map(w =>
        workerFromProvider(w, provider.model),
      );
    } else if (provider.baseUrl) {
      llm.workers = [
        defaultWorker(provider.baseUrl, provider.model ?? EMPTY_MODEL),
      ];
    } else {
      llm.workers = [];
    }
  } else {
    // Normalize each entry (fills missing roles / concurrency / etc.).
    // Cast through unknown so partial v2 worker shapes from YAML are
    // accepted; normalizeLlmWorker re-checks all fields.
    llm.workers = (llm.workers as unknown as Array<Partial<LlmWorkerDef> & Record<string, unknown>>)
      .map(w => normalizeLlmWorker(w));
  }

  llm.timeoutMinutes = llm.timeoutMinutes ?? provider.timeoutMinutes;
  llm.retry = llm.retry ?? provider.retry;
  llm.metrics = llm.metrics ?? provider.metrics;

  out.llm = llm as LlmConfig;

  // Reverse backfill: ensure provider.workers mirrors llm.workers so the
  // legacy compat readers see the same set the v2 block declares. This is
  // a no-op when both blocks were authored consistently (e.g. v1 file with
  // matching shapes). It DOES NOT touch downstream code — only the in-memory
  // AppConfig shape that downstream code reads.
  syncProviderFromLlm(out, llm as LlmConfig);

  out.storage = buildStorage(out);
}

/**
 * Mirror `llm.workers` back into `provider.workers` for v2-only files. We
 * only overwrite when the source file effectively had no provider.workers
 * (either undefined, empty, or a single auto-generated default worker that
 * loadConfig synthesized from the default baseUrl).
 *
 * Field mapping is the inverse of `workerFromProvider`:
 *   connectionType: 'aao_gateway' → proxy: true
 *   connectionType: 'direct'      → proxy: false (omitted)
 *
 * The legacy fields (`profiles`, `vlm`, `apiKey`, `proxyType`) are mirrored
 * as-is to satisfy WorkerDef's optional shape.
 */
function syncProviderFromLlm(out: Record<string, unknown>, llm: LlmConfig): void {
  if (!Array.isArray(llm.workers) || llm.workers.length === 0) return;

  const existingProvider = (out.provider ?? {}) as ProviderConfig;
  const existingWorkers = Array.isArray(existingProvider.workers) ? existingProvider.workers : [];

  // Heuristic: if the existing provider.workers already match llm.workers by
  // id count, leave them alone — the v1 path already populated them.
  const llmIds = new Set(llm.workers.map(w => w.id));
  const allExistingMatch = existingWorkers.length > 0
    && existingWorkers.every(w => typeof w.id === 'string' && llmIds.has(w.id))
    && existingWorkers.length === llm.workers.length;
  if (allExistingMatch) return;

  const mirroredWorkers: WorkerDef[] = llm.workers.map(w => {
    const def: WorkerDef = {
      id: w.id,
      endpoint: w.endpoint,
      enabled: w.enabled,
      maxConcurrency: w.maxConcurrency,
      roles: w.roles,
    };
    if (w.model !== undefined && w.model !== '') def.model = w.model;
    if (w.apiKey !== undefined) def.apiKey = w.apiKey;
    if (w.vlm !== undefined) def.vlm = w.vlm;
    if (w.healthcheckIntervalSeconds !== undefined) {
      def.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
    }
    if (w.connectionType === 'aao_gateway') {
      def.proxy = true;
      def.proxyType = 'litellm';
    }
    return def;
  });

  out.provider = {
    ...existingProvider,
    workers: mirroredWorkers,
    timeoutMinutes: existingProvider.timeoutMinutes ?? llm.timeoutMinutes,
    retry: existingProvider.retry ?? llm.retry,
    metrics: existingProvider.metrics ?? llm.metrics,
  } as ProviderConfig;
}

/**
 * Build the `llm` block from a v1 `provider` block.
 */
function llmFromProvider(provider: ProviderConfig): LlmConfig {
  const workers = (provider.workers ?? []).map(w =>
    workerFromProvider(w, provider.model),
  );

  if (workers.length === 0 && provider.baseUrl) {
    workers.push(
      defaultWorker(provider.baseUrl, provider.model ?? EMPTY_MODEL),
    );
  }

  return {
    timeoutMinutes: provider.timeoutMinutes,
    retry: provider.retry ? cloneRetry(provider.retry) : undefined,
    metrics: provider.metrics ? { ...provider.metrics } : undefined,
    workers,
  };
}

/**
 * Existing-llm path: trust user's `llm.workers` but normalize each entry,
 * and fill in retry/metrics from provider if llm side is blank.
 */
function coerceLlm(llm: Partial<LlmConfig>, provider: ProviderConfig): LlmConfig {
  const workers = Array.isArray(llm.workers)
    ? (llm.workers as unknown as Array<Partial<LlmWorkerDef> & Record<string, unknown>>)
        .map(w => normalizeLlmWorker(w))
    : [];

  return {
    timeoutMinutes: llm.timeoutMinutes ?? provider.timeoutMinutes,
    retry: llm.retry ?? (provider.retry ? cloneRetry(provider.retry) : undefined),
    metrics: llm.metrics ?? (provider.metrics ? { ...provider.metrics } : undefined),
    workers,
  };
}

function cloneRetry(r: ProviderRetryConfig): ProviderRetryConfig {
  return {
    maxAttempts: r.maxAttempts,
    backoffMs: [...r.backoffMs],
    retryableStatus: [...r.retryableStatus],
  };
}

/**
 * Map a v1 worker (with `proxy: boolean`) to a v2 worker (with
 * `connection_type`). Fills in model from `provider.model` ONLY when the
 * worker has no model of its own. If both are empty, the worker still ships
 * with `model: ''` and we log a single warning per worker — see header for
 * rationale.
 */
function workerFromProvider(w: WorkerDef, providerModel: string | undefined): LlmWorkerDef {
  const connectionType: 'direct' | 'aao_gateway' =
    w.proxy === true ? 'aao_gateway' : 'direct';

  const ownModel = typeof w.model === 'string' && w.model.length > 0 ? w.model : undefined;
  const fallback = typeof providerModel === 'string' && providerModel.length > 0 ? providerModel : undefined;
  const model = ownModel ?? fallback ?? EMPTY_MODEL;

  if (model === EMPTY_MODEL) {
    logger.warn(
      `[config] worker '${w.id ?? '?'}' has no model and provider.model is empty; ` +
        `LLM calls will fail until model is set in Settings → LLM Workers`,
    );
  }

  const roles = pickRoles(w);

  const worker: LlmWorkerDef = {
    id: w.id,
    connectionType,
    endpoint: w.endpoint,
    model,
    roles,
    maxConcurrency: Math.max(1, w.maxConcurrency ?? 1),
    enabled: w.enabled !== false,
  };
  if (w.apiKey !== undefined) worker.apiKey = w.apiKey;
  if (w.vlm !== undefined) worker.vlm = w.vlm;
  if (w.healthcheckIntervalSeconds !== undefined) {
    worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
  }
  return worker;
}

/**
 * Normalize a hand-written v2 worker entry: fill missing roles/concurrency
 * defaults, validate connectionType is recognized.
 */
function normalizeLlmWorker(w: Partial<LlmWorkerDef> & Record<string, unknown>): LlmWorkerDef {
  const id = typeof w.id === 'string' ? w.id : '';
  const endpoint = typeof w.endpoint === 'string' ? w.endpoint : '';
  const rawType = w.connectionType;
  const connectionType: 'direct' | 'aao_gateway' =
    rawType === 'aao_gateway' ? 'aao_gateway' : 'direct';
  const model = typeof w.model === 'string' ? w.model : EMPTY_MODEL;
  if (model === EMPTY_MODEL) {
    logger.warn(
      `[config] llm.workers[${id || '?'}].model is empty; LLM calls will fail ` +
        `until model is set in Settings → LLM Workers`,
    );
  }

  // Tolerate v1-style `profiles` if a user pastes a v1 worker into the
  // v2 `llm.workers` block by mistake — translate to roles.
  const rolesFromV1 =
    'profiles' in w && Array.isArray((w as { profiles?: unknown }).profiles)
      ? ((w as { profiles?: string[] }).profiles ?? [])
      : undefined;
  const rolesRaw = Array.isArray(w.roles) ? w.roles : rolesFromV1;
  const roles =
    rolesRaw && rolesRaw.length > 0
      ? Array.from(new Set(rolesRaw.filter((r): r is string => typeof r === 'string')))
      : ['auto', 'fast', 'quality'];

  const worker: LlmWorkerDef = {
    id,
    connectionType,
    endpoint,
    model,
    roles,
    maxConcurrency: Math.max(1, typeof w.maxConcurrency === 'number' ? w.maxConcurrency : 1),
    enabled: w.enabled !== false,
  };
  if (typeof w.apiKey === 'string') worker.apiKey = w.apiKey;
  if (typeof w.vlm === 'boolean') worker.vlm = w.vlm;
  if (typeof w.healthcheckIntervalSeconds === 'number') {
    worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
  }
  return worker;
}

function defaultWorker(endpoint: string, model: string): LlmWorkerDef {
  if (model === EMPTY_MODEL) {
    logger.warn(
      `[config] default worker has no model; LLM calls will fail until model is set`,
    );
  }
  return {
    id: 'default',
    connectionType: 'direct',
    endpoint,
    model,
    roles: ['auto', 'fast', 'quality'],
    maxConcurrency: 1,
    enabled: true,
  };
}

function pickRoles(w: WorkerDef): string[] {
  if (Array.isArray(w.roles) && w.roles.length > 0) {
    return Array.from(new Set(w.roles));
  }
  if (Array.isArray(w.profiles) && w.profiles.length > 0) {
    return Array.from(new Set(w.profiles));
  }
  return ['auto', 'fast', 'quality'];
}

/**
 * Pull storage-related keys from the (possibly mixed) raw object into a v2
 * Storage block. Reads from `out.storage` first so a user that already
 * authored v2 keeps their values; flat legacy keys only fill blanks.
 *
 * Side effect (REVERSE backfill): mirrors the resolved storage values
 * back into the legacy flat top-level keys (`worktreeDir`, etc.) and
 * back into `tools.*` for upload/trash. Without this, a v2-only file
 * (no top-level `worktreeDir`) loads with `config.worktreeDir`
 * undefined, and downstream callers (worker-bootstrap.ts:153/172 pass
 * `config.worktreeDir` to Scheduler + workers; ConfigManager defaults
 * fill in `/var/lib/maestro/workspaces` which is not
 * writable on typical deploys) explode with EACCES on mkdir. The same
 * pattern as `syncProviderFromLlm` — keep the legacy compat readers
 * working while the downstream code gradually moves to `storage.*`.
 * Discovered when production aao stopped processing jobs after the
 * 2026-05-21 config restructure landed.
 */
function buildStorage(out: Record<string, unknown>): StorageConfig {
  const existing = (out.storage ?? {}) as Partial<StorageConfig>;
  const tools = (out.tools ?? {}) as Partial<ToolsConfig>;

  const storage: StorageConfig = {};
  if (existing.worktreeDir !== undefined) storage.worktreeDir = existing.worktreeDir;
  else if (typeof out.worktreeDir === 'string') storage.worktreeDir = out.worktreeDir as string;

  if (existing.customPiecesDir !== undefined) storage.customPiecesDir = existing.customPiecesDir;
  else if (typeof out.customPiecesDir === 'string') storage.customPiecesDir = out.customPiecesDir as string;

  if (existing.userFolderRoot !== undefined) storage.userFolderRoot = existing.userFolderRoot;
  else if (typeof out.userFolderRoot === 'string') storage.userFolderRoot = out.userFolderRoot as string;

  if (existing.taskUploadMaxSizeMb !== undefined) storage.taskUploadMaxSizeMb = existing.taskUploadMaxSizeMb;
  else if (typeof tools.taskUploadMaxSizeMb === 'number') storage.taskUploadMaxSizeMb = tools.taskUploadMaxSizeMb;

  if (existing.trashRetentionDays !== undefined) storage.trashRetentionDays = existing.trashRetentionDays;
  else if (typeof tools.trashRetentionDays === 'number') storage.trashRetentionDays = tools.trashRetentionDays;

  // Reverse backfill into the legacy flat keys so downstream readers
  // that haven't migrated to `config.storage.*` keep finding the values
  // where they expect.
  //
  // Precedence: when `storage.X` was set on the *input* to the normalizer
  // (i.e. the user authored a v2 storage block), that value is the
  // authoritative source of truth and unconditionally overrides any
  // top-level value — because the top-level value at this point is
  // almost certainly the merged-in legacy default
  // (`/var/lib/maestro/workspaces`) which is unwritable on
  // typical deploys. A user that wants to override storage.* with an
  // env var (WORKTREE_DIR etc) should apply that override *after* this
  // normalizer runs; loadConfig() in src/config.ts does exactly that
  // for WORKTREE_DIR (Step 1 hotfix #369).
  //
  // When `storage.X` was synthesized from a legacy top-level key (v1
  // input), the top-level was already set and the mirror is a no-op.
  if (existing.worktreeDir !== undefined && storage.worktreeDir !== undefined) {
    out.worktreeDir = storage.worktreeDir;
  } else if (storage.worktreeDir !== undefined && (out.worktreeDir === undefined || out.worktreeDir === '')) {
    out.worktreeDir = storage.worktreeDir;
  }
  if (existing.customPiecesDir !== undefined && storage.customPiecesDir !== undefined) {
    out.customPiecesDir = storage.customPiecesDir;
  } else if (storage.customPiecesDir !== undefined && (out.customPiecesDir === undefined || out.customPiecesDir === '')) {
    out.customPiecesDir = storage.customPiecesDir;
  }
  if (existing.userFolderRoot !== undefined && storage.userFolderRoot !== undefined) {
    out.userFolderRoot = storage.userFolderRoot;
  } else if (storage.userFolderRoot !== undefined && (out.userFolderRoot === undefined || out.userFolderRoot === '')) {
    out.userFolderRoot = storage.userFolderRoot;
  }
  // tools.* upload / trash: only mirror when not already set on the
  // tools block. The settings UI surfaces these on the Media & Documents
  // sub-form which binds directly to `tools.*`, so keeping that path
  // alive is the cheapest fix for the v2 read path.
  if (storage.taskUploadMaxSizeMb !== undefined) {
    const toolsObj = (out.tools ?? {}) as Record<string, unknown>;
    if (toolsObj.taskUploadMaxSizeMb === undefined) {
      toolsObj.taskUploadMaxSizeMb = storage.taskUploadMaxSizeMb;
      out.tools = toolsObj;
    }
  }
  if (storage.trashRetentionDays !== undefined) {
    const toolsObj = (out.tools ?? {}) as Record<string, unknown>;
    if (toolsObj.trashRetentionDays === undefined) {
      toolsObj.trashRetentionDays = storage.trashRetentionDays;
      out.tools = toolsObj;
    }
  }

  return storage;
}

