import { readFileSync, existsSync, statSync } from 'fs';
import { parse as parseYaml } from 'yaml';
import { logger } from './logger.js';
import { normalizeConfig } from './config-normalize.js';
import type { McpRuntimeConfig } from './mcp/config.js';
import type { SshRuntimeConfig } from './ssh/config.js';

export interface AskConfig {
  maxPerJob: number;        // default: 2
}

export interface SubtasksConfig {
  maxDepth: number;         // default: 2 (0 = no decomposition)
  maxPerParent: number;     // default: 10 (max subtasks a single job can spawn)
}

export interface ToolsConfig {
  searxngUrl?: string;           // default: http://searxng:8080
  visionModel?: string;          // default: qwen2-vl:8b-instruct
  visionBaseUrl?: string;        // default: same as provider.baseUrl
  visionTimeout?: number;        // seconds, default: 60
  visionMaxTokens?: number;      // default: 1024
  webfetchTimeout?: number;      // seconds, default: 30
  websearchTimeout?: number;     // seconds, default: 15
  webfetchAllowedHosts?: string[]; // private IPs to explicitly allow
  xCliCommand?: string[] | string;
  xTimeout?: number;
  xAuthToken?: string;
  xCt0?: string;
  xProxy?: string;
  xChromeProfile?: string;
  /** X 投稿に紐づく画像/動画を自動 DL するか。'auto' (default) で media[] が空でなければ DL。'never' で全くやらない */
  xDownloadMedia?: 'auto' | 'never';
  /** 動画の扱い。'thumbnail' (default) で poster だけ、'full' で variants から最高画質 mp4 も DL、'never' でスキップ */
  xDownloadVideo?: 'thumbnail' | 'full' | 'never';
  /** 1 メディア当たりの DL サイズ上限 (MB)。default 25 */
  xMediaMaxMb?: number;
  /**
   * 1 メディアあたりの fetch hard timeout 秒。default 15。
   * pbs.twimg.com / video.twimg.com の応答が止まったまま fetch が無限に
   * ぶら下がるケース (CDN 障害, 大容量動画) を防ぐためのセーフティ。
   */
  xMediaFetchTimeoutSeconds?: number;
  ocrModel?: string;       // GLM-OCR 用モデル名 (default: glm-ocr)
  /** @deprecated Use AppConfig.searchFilter instead */
  searchFilter?: SearchFilterConfig;
  browserPageTimeout?: number;    // ms, default: 60000
  browserActionTimeout?: number;  // ms, default: 30000
  googleMapsApiKey?: string;      // Google Maps API キー（省略時は Nominatim/OSRM を使用）
  mapsTimeout?: number;           // 地図 API タイムアウト秒 (default: 30)
  amazonAffiliateTag?: string;   // Amazon アソシエイトタグ
  keepaApiKey?: string;          // Keepa API キー（省略時はグラフ画像リンクのみ）
  speechServerUrl?: string;      // 音声認識サーバー URL (例: http://localhost:8000/v1)
  speechTimeout?: number;        // タイムアウト秒 (default: 300)
  speechLanguage?: string;       // デフォルト言語 (default: ja)
  knowledgeServiceUrl?: string;  // DKS サーバー URL (例: http://dks-server:8100)
  knowledgeNamespaces?: Record<string, { apiKey: string }>; // namespace ごとの API キー
  officeExcelMaxSizeMb?: number;        // ReadExcel の最大ファイルサイズ (default: 10)
  officeDocxMaxSizeMb?: number;         // ReadDocx の最大ファイルサイズ (default: 10)
  officePdfMaxSizeMb?: number;          // ReadPdf の最大ファイルサイズ (default: 10)
  officePptxMaxSizeMb?: number;         // ReadPPTX の最大ファイルサイズ (default: 50)
  officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200)
  /**
   * Max request body size (MB) for the POST /api/local/tasks and
   * /api/local/tasks/:id/comments endpoints (includes base64-encoded
   * attachments). Default: 50.
   *
   * Because attachments are base64-encoded in the JSON body, the effective
   * raw file size is roughly value × 0.75 (e.g. 50 MB body ≈ 37 MB raw).
   * Hot-reloadable: applies to the next request.
   */
  taskUploadMaxSizeMb?: number;
  /**
   * Allow RunUserScript to execute user-authored scripts.
   * Default: false (opt-in required).
   * Plain-runtime scripts now run under Node's Permissions Model
   * (--permission), which blocks child_process, worker threads, and FS access
   * outside tmpdir. browser-macros still run with full Node.js capabilities
   * because Playwright needs them — only enable for trusted users.
   */
  userScriptsEnabled?: boolean;
  /**
   * Optional per-user allowlist for RunUserScript / scheduled script tasks.
   * When undefined (default), `userScriptsEnabled` alone controls access.
   * When set to a list of user ids, only those users may run scripts even if
   * the global gate is true — useful for granular rollout in shared installs.
   */
  userScriptsAllowUserids?: string[];
  /**
   * How many days files in `data/users/{userId}/trash/` are retained before
   * being auto-deleted by the daily trash sweep. Default: 30. Use 0 to disable
   * retention (every sweep deletes everything in trash).
   */
  trashRetentionDays?: number;
}

export interface RetryConfig {
  maxAttempts: number;
  backoffSeconds: number[];
}

function cloneRetryConfig(config: RetryConfig): RetryConfig {
  return {
    maxAttempts: config.maxAttempts,
    backoffSeconds: [...config.backoffSeconds],
  };
}

export const DEFAULT_PROVIDER_RETRY_CONFIG: RetryConfig = {
  maxAttempts: 3,
  backoffSeconds: [1, 3, 10],
};

export interface ProviderRetryConfig {
  maxAttempts: number;
  backoffMs: number[];
  retryableStatus: number[];
}

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

export const DEFAULT_LLM_RETRY_CONFIG: ProviderRetryConfig = {
  maxAttempts: 3,
  backoffMs: [2000, 5000, 15000],
  retryableStatus: [429, 500, 502, 503, 504],
};

export interface WorkerDef {
  id: string;
  endpoint: string;
  model?: string;  // falls back to provider.model if omitted
  enabled?: boolean;
  maxConcurrency?: number;
  vlm?: boolean;   // true: ReadImage uses this worker's own model instead of dedicated VLM endpoint
  roles?: string[];
  /** @deprecated Use roles instead. Kept for backward compat shim. */
  profiles?: string[];
  healthcheckIntervalSeconds?: number;
  /**
   * When true, this worker's endpoint points at an LLM gateway / proxy
   * (e.g. LiteLLM Proxy) that fans out to a pool of physical backends.
   * The AAO uses the proxy's response headers (e.g. `x-litellm-model-id`)
   * to identify which physical backend handled each LLM call, so that
   * features like Pet ↔ backend mapping work through the proxy.
   * Default: false (direct worker).
   * See docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md.
   */
  proxy?: boolean;
  /**
   * When proxy=true, identifies which proxy implementation the endpoint
   * speaks. Only 'litellm' is implemented in v1; the field exists for
   * future expansion. Defaults to 'litellm' when proxy=true and unset.
   */
  proxyType?: 'litellm';
  /**
   * Optional bearer / API key used when calling this worker's endpoint
   * (e.g. team-scoped LiteLLM token). When omitted the request is sent
   * without an Authorization header.
   */
  apiKey?: string;
}

export interface ProviderConfig {
  baseUrl?: string;   // DEPRECATED: for backward compat only
  model?: string;     // default model (optional — if omitted, model field is excluded from LLM requests)
  workers: WorkerDef[];
  retry?: ProviderRetryConfig; // per-call HTTP retry for LLM requests
  timeoutMinutes?: number;     // LLM request timeout in minutes (default: 10)
  /**
   * Phase 3b: Prometheus `/metrics` exporter settings for worker mode.
   * Defaults to enabled with `aao_worker_` prefix. Set
   * `metrics.enabled: false` to skip the endpoint + all counter
   * emission.
   */
  metrics?: ProviderMetricsConfig;
}

/**
 * Phase 3b — worker-mode Prometheus exporter knobs. Placement under
 * `provider` reflects that the LLM-call counters are the dominant
 * worker metric set; future relocation to its own root section is
 * acceptable as long as the legacy provider path keeps reading.
 *
 * Auth hardening (Phase 3b post-review):
 *  - `bearerToken`: when set, `/metrics` requires `Authorization:
 *    Bearer <token>`. Wins over the IP allowlist — operators use this
 *    when Prometheus scrapes from arbitrary subnets.
 *  - `allowedHosts`: client-IP allowlist applied when no bearer is
 *    set. Default is localhost-only (`['127.0.0.1', '::1',
 *    'localhost']`). Include `0.0.0.0` to disable IP checks entirely
 *    (only safe when a reverse proxy / firewall handles ACLs).
 *
 * Both fields can be overridden via env (`$AAO_WORKER_METRICS_BEARER_TOKEN`
 * etc.) — see env-override block in this file.
 */
export interface ProviderMetricsConfig {
  enabled?: boolean;
  prefix?: string;
  bearerToken?: string;
  allowedHosts?: string[];
}

export interface ContextConfig {
  limitTokens?: number;
  thresholds?: Array<{
    ratio: number;
    action: 'warn' | 'prompt' | 'force_transition';
  }>;
}

export interface HistorySummarizationConfig {
  enabled?: boolean;            // default true
  tailTurns?: number;           // default 2 (assistant+tool turns to always preserve)
  preserveRecentBudget?: number; // default 8000 tokens
}

export interface SafetyConfig {
  maxIterations?: number;
  maxRevisits?: number;
  /**
   * Tool-call loop detection threshold. If the LLM emits the byte-identical
   * regular tool-call batch (same tool names + same arguments) on this many
   * consecutive iterations within a single movement, the movement is
   * force-aborted as a loop. Must be an integer >= 2. Default: 5.
   */
  maxToolLoopRepeats?: number;
  /**
   * Fraction of the model context budget that the prompt is allowed to fill
   * before guardPromptBeforeSend triggers compaction/summarization.
   * Range: 0.5–0.95. Default: 0.8.
   */
  promptGuardRatio?: number;
  historySummarization?: HistorySummarizationConfig;
  /**
   * When true, Bash tool runs without command whitelist or path-scope
   * restrictions. Instead, each command is executed inside a bwrap sandbox
   * that bind-mounts only the task workspace (rw) and system dirs (ro).
   * Requires bwrap with user-namespace support. Default: false.
   */
  bashUnrestricted?: boolean;
  /**
   * Bash サンドボックス機構の選択。
   * - 'auto'（既定）: bwrap があれば sandboxed、無ければ hardened-whitelist
   * - 'always': sandboxed を強制。bwrap 不在なら起動時 fail-closed
   * - 'off': 旧来の素 exec（後方互換・デバッグ用、非推奨）
   */
  bashSandbox?: 'auto' | 'always' | 'off';
}

export interface SkillsConfig {
  maxPerUser?: number;         // default: 50
  maxSkillSizeKb?: number;     // default: 64
  maxTotalSizeMb?: number;     // default: 5
  maxSystemSkills?: number;    // default: 100
  maxIndexChars?: number;      // default: 2000
}

export interface SearchFilterConfig {
  blockedPatterns?: string[];
  autoBlock?: {
    privateIp?: boolean;
    internalDomain?: boolean;
    email?: boolean;
    phone?: boolean;
  };
}

export interface BrowserConfig {
  vncBasePort?: number;      // default 5900
  sessionDataDir?: string;   // default './data/browser-sessions'
  maxSessions?: number;      // default 5 (CAPTCHA Pool は別枠でカウントしない)
  captchaSolve?: 'skip' | 'novnc';  // default 'skip'
  maxCaptchaPages?: number;  // default 5
  /** Task Session が job 完了から何秒アイドルしたら GC するか (default 300) */
  taskSessionIdleTtl?: number;
  /** Playwright の channel ('chromium' default / 'chrome' で本物の Chrome を使う) */
  channel?: 'chromium' | 'chrome' | 'msedge';
  /** 任意の実行ファイルパス指定 (channel と排他、上級者向け) */
  executablePath?: string;
}

export interface AuthProviderConfig {
  clientId: string;
  clientSecret: string;
  callbackUrl: string;
  baseUrl?: string; // Gitea 用
}

export type PrimaryAuthProvider = 'google' | 'gitea';

export interface AuthConfig {
  sessionSecret: string;
  sessionMaxAge: number; // ミリ秒
  secureCookie: boolean;
  adminEmails: string[];
  primaryProvider?: PrimaryAuthProvider;
  providers: {
    google?: AuthProviderConfig;
    gitea?: AuthProviderConfig;
  };
}

export interface BrandingConfig {
  appName?: string;          // TopBar / document.title に表示
  primaryColor?: string;     // Tailwind の brand-primary に反映 (hex / rgb)
  loginPageTitle?: string;   // ログイン画面の見出し
  /** Public URL (eg. "/branding/logo-abc123.svg") — サーバーの data/branding/ 配下を /branding/ で配信 */
  logoUrl?: string;
  /** Public URL (eg. "/branding/favicon-abc123.png") — 未指定時は UI の同梱 favicon を使用 */
  faviconUrl?: string;
  /** TopBar 下部など UI で表示する小さな注記文字列 */
  footerText?: string;
}

export interface ReflectionConfig {
  enabled: boolean;
  workerRequired: boolean;
  maxMemoryChangesPerJob: number;
  maxEntryBodyBytes: number;
  pieceEditCooldownHours: number;
  snapshotRetentionDays: number;
  activityLogMaxBytes: number;
  abstainRateFloor: number;
  perUserDailyBudgetTokens: number;
  snapshotMaxBytesPerUser: number;
  snapshotMaxBytesPerEntry: number;
  storeLlmRaw: boolean;
}

export interface NotesInjectConfig {
  perNoteMaxKb: number;        // default: 8
  totalMaxKb: number;          // default: 32
  overBudgetStrategy: 'truncate_last' | 'skip_remaining' | 'degrade_to_search'; // default: 'skip_remaining'
}

export interface NotesConfig {
  inject?: Partial<NotesInjectConfig>;
}

export const DEFAULT_NOTES_INJECT: NotesInjectConfig = {
  perNoteMaxKb: 8,
  totalMaxKb: 32,
  overBudgetStrategy: 'skip_remaining',
};

/**
 * v2 LLM worker definition. Mirrors the v1 `WorkerDef` but:
 *  - replaces ambiguous `proxy: boolean` with explicit
 *    `connection_type: 'direct' | 'aao_gateway'` (settings doc 2026-05-21)
 *  - drops the implicit fallback to `provider.model` — each worker carries
 *    its own model string (may be empty if neither source supplied one)
 *
 * Kept structurally parallel to `WorkerDef` so existing runtime code that
 * reads `endpoint` / `model` / `roles` etc. works without per-field
 * rewiring; the only new required-ish field is `connectionType`.
 */
export interface LlmWorkerDef {
  id: string;
  /** Explicit connection mode; replaces v1 `proxy: boolean`. */
  connectionType: 'direct' | 'aao_gateway';
  endpoint: string;
  /** Model string. Empty allowed (paired with startup warning) for migration safety. */
  model: string;
  /** Optional bearer / API key. Required in UI when connectionType === 'aao_gateway'. */
  apiKey?: string;
  roles: string[];
  maxConcurrency: number;
  enabled: boolean;
  vlm?: boolean;
  healthcheckIntervalSeconds?: number;
}

/**
 * v2 LLM block. Sibling of the legacy `provider`; both will coexist for one
 * release. Generated by normalizer from either v2 input (`llm` key present)
 * or v1 input (`provider.*` keys).
 */
export interface LlmConfig {
  timeoutMinutes?: number;
  retry?: ProviderRetryConfig;
  metrics?: ProviderMetricsConfig;
  workers: LlmWorkerDef[];
}

/**
 * v2 Storage block. Groups path / quota / retention knobs that were
 * scattered across `worktree_dir`, `custom_pieces_dir`, `user_folder_root`,
 * `tools.task_upload_max_size_mb`, and `tools.trash_retention_days`.
 */
export interface StorageConfig {
  worktreeDir?: string;
  customPiecesDir?: string;
  userFolderRoot?: string;
  taskUploadMaxSizeMb?: number;
  trashRetentionDays?: number;
}

export interface PushNotificationsConfig {
  /** Master switch for Web Push (V2). Default false until operators opt in. */
  enabled?: boolean;
  /** RFC 8292 VAPID subject. Operations URL preferred over generic mailto:. */
  vapidSubject?: string;
  /** Path to the current VAPID keypair file. Default ./data/secrets/vapid.json */
  vapidCurrentPath?: string;
  /** Directory for retired VAPID keys. Default ./data/secrets/vapid-history */
  vapidHistoryDir?: string;
  /** Maximum push payload size in bytes (before encryption overhead). */
  payloadMaxBytes?: number;
  /** Max concurrent sends from the queue. */
  queueConcurrency?: number;
  /** Per-send timeout (ms). */
  perSendTimeoutMs?: number;
}

export interface NotificationsConfig {
  push?: PushNotificationsConfig;
}

export interface AppConfig {
  /**
   * Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1`
   * means a v1 file that the normalizer must migrate. Other values fatal
   * out at load time (typo guard).
   */
  configVersion?: number;
  provider: ProviderConfig;
  /**
   * v2 LLM block. Always populated post-normalization. Downstream code may
   * read either `provider.*` (legacy) or `llm.*` (preferred); both are kept
   * coherent by the normalizer during the compat window.
   */
  llm?: LlmConfig;
  worktreeDir: string;
  concurrency: number;
  maxMovements: number;
  retry: RetryConfig;
  tools?: ToolsConfig;
  ask: AskConfig;
  subtasks: SubtasksConfig;
  context?: ContextConfig;
  safety?: SafetyConfig;
  skills?: SkillsConfig;
  searchFilter?: SearchFilterConfig;
  browser?: BrowserConfig;
  customPiecesDir?: string;
  auth?: AuthConfig;
  branding?: BrandingConfig;
  reflection: ReflectionConfig;
  secrets?: {
    masterKeyPath?: string;   // default './data/secrets/master.key'
    mcpKeyPath?: string;      // default './data/secrets/mcp.key'
  };
  userFolderRoot?: string;   // default './data/users'
  /**
   * v2 Storage block. Populated by normalizer alongside the legacy top-level
   * `worktreeDir` / `customPiecesDir` / `userFolderRoot` so callers can
   * migrate at their own pace.
   */
  storage?: StorageConfig;
  mcp?: Partial<McpRuntimeConfig>;
  ssh?: Partial<SshRuntimeConfig>;
  notes?: NotesConfig;
  notifications?: NotificationsConfig;
}

const DEFAULT_REFLECTION: ReflectionConfig = {
  enabled: false,
  workerRequired: true,
  maxMemoryChangesPerJob: 3,
  maxEntryBodyBytes: 8192,
  pieceEditCooldownHours: 24,
  snapshotRetentionDays: 90,
  activityLogMaxBytes: 4096,
  abstainRateFloor: 0.3,
  perUserDailyBudgetTokens: 200_000,
  snapshotMaxBytesPerUser: 100 * 1024 * 1024,
  snapshotMaxBytesPerEntry: 1 * 1024 * 1024,
  storeLlmRaw: false,
};

// デフォルト設定
const defaults: AppConfig = {
  provider: {
    baseUrl: 'http://localhost:11434/v1',
    model: 'qwen3:32b',
    workers: [],
    retry: cloneProviderRetryConfig(DEFAULT_LLM_RETRY_CONFIG),
  },
  worktreeDir: '/var/lib/maestro/workspaces',
  concurrency: 1,
  maxMovements: 30,
  retry: {
    maxAttempts: 3,
    backoffSeconds: [60, 300, 900],
  },
  ask: {
    maxPerJob: 2,
  },
  subtasks: {
    maxDepth: 2,
    maxPerParent: 10,
  },
  tools: {
    searxngUrl: 'http://searxng:8080',
    visionModel: 'qwen2-vl:8b-instruct',
    visionTimeout: 60,
    visionMaxTokens: 1024,
    webfetchTimeout: 30,
    websearchTimeout: 15,
    webfetchAllowedHosts: [],
    xCliCommand: ['twitter'],
    xTimeout: 90,
    trashRetentionDays: 30,
  },
  context: {
    thresholds: [
      { ratio: 0.7, action: 'warn' as const },
      { ratio: 0.85, action: 'prompt' as const },
      { ratio: 0.95, action: 'force_transition' as const },
    ],
  },
  safety: {
    maxIterations: 200,
    maxRevisits: 3,
    maxToolLoopRepeats: 5,
    bashSandbox: 'auto',
  },
  reflection: { ...DEFAULT_REFLECTION },
};

// YAML のスネークケースキーをキャメルケースに変換するユーティリティ
function toCamel(s: string): string {
  return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
}

function transformKeys(obj: unknown): unknown {
  if (Array.isArray(obj)) return obj.map(transformKeys);
  if (obj !== null && typeof obj === 'object') {
    return Object.fromEntries(
      Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
        toCamel(k),
        transformKeys(v),
      ])
    );
  }
  return obj;
}

function toSnake(str: string): string {
  return str.replace(/[A-Z]/g, (ch) => '_' + ch.toLowerCase());
}

export function toSnakeKeys(obj: unknown): unknown {
  if (Array.isArray(obj)) return obj.map(toSnakeKeys);
  if (obj !== null && typeof obj === 'object') {
    return Object.fromEntries(
      Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
        toSnake(k),
        toSnakeKeys(v),
      ])
    );
  }
  return obj;
}

function deepMerge<T>(base: T, override: Partial<T>): T {
  const result = { ...base };
  for (const key of Object.keys(override) as Array<keyof T>) {
    const overrideVal = override[key];
    const baseVal = base[key];
    if (
      overrideVal !== null &&
      typeof overrideVal === 'object' &&
      !Array.isArray(overrideVal) &&
      baseVal !== null &&
      typeof baseVal === 'object' &&
      !Array.isArray(baseVal)
    ) {
      result[key] = deepMerge(baseVal, overrideVal as Partial<typeof baseVal>);
    } else if (overrideVal !== undefined) {
      result[key] = overrideVal as T[keyof T];
    }
  }
  return result;
}

// roles が設定されていて実行ロールを一つも含まない場合、そのワーカーはジョブ実行しない
// 例: roles: ['title'] のみのワーカーはタイトル生成専用でポーリングをスキップする
const EXECUTION_ROLES = new Set(['auto', 'fast', 'quality', 'worker', 'reflection']);

export function isExecutionWorker(worker: WorkerDef): boolean {
  if (!worker.roles || worker.roles.length === 0) return true;
  return worker.roles.some(r => EXECUTION_ROLES.has(r));
}

/**
 * 後方互換 shim: profiles が指定されていれば roles に変換する。
 */
function normalizeWorkerDefs(workers: WorkerDef[]): WorkerDef[] {
  return workers.map((worker) => {
    // roles が明示されていればそれを使う。なければ profiles から変換（shim）
    let roles: string[];
    if (worker.roles && worker.roles.length > 0) {
      roles = worker.roles;
    } else if (worker.profiles && worker.profiles.length > 0) {
      logger.info(`Config: worker ${worker.id}: converting deprecated 'profiles' to 'roles'`);
      roles = [...worker.profiles];
    } else {
      // デフォルト: 全実行ロール
      roles = ['auto', 'fast', 'quality'];
    }
    roles = Array.from(new Set(roles));

    // Normalize proxy fields: if proxy=true and proxyType is unset, default
    // to 'litellm' (currently the only supported proxy implementation).
    //
    // Preserve a non-boolean `proxy` AS-IS (do not coerce to false) so
    // validateConfig() can reject it instead of silently downgrading the
    // worker to direct mode. If we coerced here, a YAML typo like
    // `proxy: "true"` (quoted string) would make the worker behave as
    // direct while still shipping its apiKey as a Bearer token to the
    // upstream — a credential-leak footgun. validateConfig flags this.
    let proxy: boolean;
    if (worker.proxy === undefined || typeof worker.proxy === 'boolean') {
      proxy = worker.proxy === true;
    } else {
      // Leave the bad value in place so validateConfig can complain.
      proxy = worker.proxy as unknown as boolean;
    }
    const proxyType = proxy === true ? (worker.proxyType ?? 'litellm') : undefined;

    return {
      ...worker,
      enabled: worker.enabled !== false,
      maxConcurrency: Math.max(1, worker.maxConcurrency ?? 1),
      roles,
      // deprecated profiles フィールドを削除して混乱を防ぐ
      profiles: undefined,
      proxy,
      proxyType,
    };
  });
}

// loadConfig() is called from many hot paths (agent-loop, worker poll, tool
// execution) — historically several times per second. Re-reading + re-parsing
// the YAML and emitting an INFO log on every call produced a reload "storm"
// (observed: 7572 "Config: loaded" lines/day, multi-100MB server.log). Cache
// the parsed file keyed by (path, mtime, size): on an unchanged file we skip
// the read/parse and the log entirely. The cheap, pure merge/normalize below
// still runs each call, so callers always get a fresh, independent object.
let configFileCache:
  | { path: string; mtimeMs: number; size: number; fileConfig: Partial<AppConfig> }
  | null = null;

export function loadConfig(configPath: string = 'config.yaml'): AppConfig {
  let fileConfig: Partial<AppConfig> = {};
  let freshLoad = false;

  if (existsSync(configPath)) {
    try {
      const st = statSync(configPath);
      if (
        configFileCache &&
        configFileCache.path === configPath &&
        configFileCache.mtimeMs === st.mtimeMs &&
        configFileCache.size === st.size
      ) {
        // Cache hit — file unchanged since last parse. Clone so callers can
        // never mutate the cached copy (deepMerge shares array refs).
        fileConfig = structuredClone(configFileCache.fileConfig);
      } else {
        const raw = readFileSync(configPath, 'utf-8');
        const parsed = parseYaml(raw) as unknown;
        fileConfig = transformKeys(parsed) as Partial<AppConfig>;
        configFileCache = {
          path: configPath,
          mtimeMs: st.mtimeMs,
          size: st.size,
          fileConfig: structuredClone(fileConfig),
        };
        freshLoad = true;
        logger.info(`Config: loaded from ${configPath}`);
      }
    } catch (err) {
      logger.warn(`Config: failed to parse ${configPath}: ${err}`);
    }
  } else {
    logger.warn(`Config: ${configPath} not found, using defaults`);
  }

  // ファイル設定でデフォルトを上書き
  let config = deepMerge(defaults, fileConfig);

  // 環境変数上書き
  if (process.env['OLLAMA_BASE_URL']) {
    config = deepMerge(config, {
      provider: { ...config.provider, baseUrl: process.env['OLLAMA_BASE_URL'] },
    });
  }
  if (process.env['OLLAMA_MODEL']) {
    config = deepMerge(config, {
      provider: { ...config.provider, model: process.env['OLLAMA_MODEL'] },
    });
  }
  // NOTE: WORKTREE_DIR env override is intentionally applied *after*
  // normalizeConfig (below), not here, so an env value beats a
  // storage.worktree_dir mirror written by the normalizer. See the
  // post-normalize block at end of loadConfig for the actual override.
  if (process.env['CONCURRENCY']) {
    const n = parseInt(process.env['CONCURRENCY'], 10);
    if (!isNaN(n)) config.concurrency = n;
  }
  // Phase 3b post-review: env overrides for worker metrics auth. Lets
  // operators inject a bearer token without committing it to config.yaml.
  if (process.env['AAO_WORKER_METRICS_BEARER_TOKEN']) {
    config.provider = config.provider ?? { workers: [] };
    config.provider.metrics = {
      ...(config.provider.metrics ?? {}),
      bearerToken: process.env['AAO_WORKER_METRICS_BEARER_TOKEN'],
    };
  }
  if (process.env['AAO_WORKER_METRICS_ALLOWED_HOSTS']) {
    const list = process.env['AAO_WORKER_METRICS_ALLOWED_HOSTS']!
      .split(',')
      .map(s => s.trim())
      .filter(s => s.length > 0);
    if (list.length > 0) {
      config.provider = config.provider ?? { workers: [] };
      config.provider.metrics = {
        ...(config.provider.metrics ?? {}),
        allowedHosts: list,
      };
    }
  }
  // workers 自動生成: 明示的な workers 設定がない場合は baseUrl から生成
  if (!config.provider.workers || config.provider.workers.length === 0) {
    const endpoint = config.provider.baseUrl ?? 'http://localhost:11434/v1';
    config.provider.workers = [{ id: 'default', endpoint }];
  }

  config.provider.workers = normalizeWorkerDefs(config.provider.workers);

  // Only log on a fresh (re)load — see configFileCache note above. Logging on
  // every cache hit was the other half of the reload storm.
  if (freshLoad) {
    logger.info(`Config: workers=${config.provider.workers.map(w => w.id).join(',')}`);
  }

  // v2 normalization (Step 1 of 2026-05-21 Settings UI / Config restructure).
  //
  // Runs after legacy provider workers are populated + env overrides applied
  // so the v2 `llm.workers` block reflects the *effective* runtime config.
  // The normalizer itself is pure and only writes to `llm` / `storage` /
  // `configVersion` — the legacy `provider` block stays intact for callers
  // that haven't migrated yet. The migration-warning path is suppressed at
  // this site because we ran defaults+env merge first; we only want the
  // warning to fire when the *source file* was v1.
  if (fileConfig.configVersion === undefined && Object.keys(fileConfig).length > 0) {
    // Source file was v1 (no config_version key but had content) — let
    // normalizeConfig surface the migration log via its standard path.
    config = normalizeConfig({ ...config, configVersion: undefined });
  } else {
    // Either v2 source, or empty file — silent normalization.
    config = normalizeConfig({ ...config, configVersion: 2 });
  }

  // Post-normalize env overrides for top-level path settings.
  //
  // Runs *after* normalizeConfig so an explicit env value beats both
  // the mirrored-from-storage value and the legacy default. Without
  // this order, a deploy that sets `storage.worktree_dir` in YAML but
  // also exports `WORKTREE_DIR=/tmp/override` would silently keep the
  // YAML value — env was meant to be the highest precedence layer.
  if (process.env['WORKTREE_DIR']) {
    config.worktreeDir = process.env['WORKTREE_DIR'];
    if (config.storage) {
      config.storage.worktreeDir = process.env['WORKTREE_DIR'];
    }
  }

  const errors = validateConfig(config);
  for (const err of errors) {
    logger.warn(`Config validation: ${err}`);
  }

  return config;
}

export function validateConfig(config: AppConfig): string[] {
  const errors: string[] = [];

  if (!Number.isInteger(config.concurrency) || config.concurrency <= 0) {
    errors.push('concurrency must be a positive integer');
  }

  if (!Number.isInteger(config.maxMovements) || config.maxMovements <= 0) {
    errors.push('maxMovements must be a positive integer');
  }

  if (!Number.isInteger(config.ask.maxPerJob) || config.ask.maxPerJob < 0) {
    errors.push('ask.maxPerJob must be a non-negative integer');
  }

  if (!Number.isInteger(config.subtasks.maxDepth) || config.subtasks.maxDepth < 0) {
    errors.push('subtasks.maxDepth must be a non-negative integer');
  }
  if (!Number.isInteger(config.subtasks.maxPerParent) || config.subtasks.maxPerParent < 1) {
    errors.push('subtasks.maxPerParent must be a positive integer');
  }

  if (!Number.isInteger(config.retry.maxAttempts) || config.retry.maxAttempts <= 0) {
    errors.push('retry.maxAttempts must be a positive integer');
  }

  if (!Array.isArray(config.retry.backoffSeconds) || config.retry.backoffSeconds.length === 0) {
    errors.push('retry.backoffSeconds must be a non-empty array');
  }

  if (!Array.isArray(config.provider.workers) || config.provider.workers.length === 0) {
    errors.push('provider.workers must be a non-empty array');
  } else {
    for (const worker of config.provider.workers) {
      if (!worker.id || typeof worker.id !== 'string' || worker.id.trim() === '') {
        errors.push(`provider.workers: worker has empty id`);
      }
      if (!worker.endpoint || typeof worker.endpoint !== 'string' || worker.endpoint.trim() === '') {
        errors.push(`provider.workers[${worker.id ?? '?'}]: endpoint must be non-empty`);
      }
      if (worker.proxyType !== undefined && worker.proxyType !== 'litellm') {
        errors.push(`provider.workers[${worker.id ?? '?'}]: proxy_type must be 'litellm' (got '${String(worker.proxyType)}')`);
      }
      // Reject non-boolean `proxy`. YAML coerces `proxy: "true"` (quoted) to a
      // string, and `worker.proxy === true` then evaluates false — so the
      // worker silently runs in direct mode while still shipping its apiKey
      // as a Bearer token. That's a credential-leak footgun. Force admins to
      // write `proxy: true` (bare boolean) explicitly.
      if (worker.proxy !== undefined && typeof worker.proxy !== 'boolean') {
        errors.push(`provider.workers[${worker.id ?? '?'}]: proxy must be boolean, got '${typeof worker.proxy}' (${JSON.stringify(worker.proxy)})`);
      }
    }
  }

  if (config.safety !== undefined) {
    if (config.safety.maxIterations !== undefined) {
      if (!Number.isInteger(config.safety.maxIterations) || config.safety.maxIterations <= 0) {
        errors.push('safety.maxIterations must be a positive integer if defined');
      }
    }
    if (config.safety.maxRevisits !== undefined) {
      if (!Number.isInteger(config.safety.maxRevisits) || config.safety.maxRevisits <= 0) {
        errors.push('safety.maxRevisits must be a positive integer if defined');
      }
    }
    if (config.safety.maxToolLoopRepeats !== undefined) {
      if (!Number.isInteger(config.safety.maxToolLoopRepeats) || config.safety.maxToolLoopRepeats < 2) {
        errors.push('safety.maxToolLoopRepeats must be an integer >= 2 if defined');
      }
    }
    if (config.safety.promptGuardRatio !== undefined) {
      const r = config.safety.promptGuardRatio;
      if (typeof r !== 'number' || !Number.isFinite(r) || r < 0.5 || r > 0.95) {
        errors.push('safety.promptGuardRatio must be a number in [0.5, 0.95] if defined');
      }
    }
    if (config.safety.historySummarization !== undefined) {
      const hs = config.safety.historySummarization;
      if (hs.tailTurns !== undefined) {
        if (!Number.isInteger(hs.tailTurns) || hs.tailTurns < 0) {
          errors.push('safety.historySummarization.tailTurns must be a non-negative integer if defined');
        }
      }
      if (hs.preserveRecentBudget !== undefined) {
        if (!Number.isInteger(hs.preserveRecentBudget) || hs.preserveRecentBudget <= 0) {
          errors.push('safety.historySummarization.preserveRecentBudget must be a positive integer if defined');
        }
      }
    }
    if (config.safety.bashUnrestricted !== undefined && typeof config.safety.bashUnrestricted !== 'boolean') {
      errors.push('safety.bashUnrestricted must be a boolean if defined');
    }
    if (config.safety.bashSandbox !== undefined &&
        !['auto', 'always', 'off'].includes(config.safety.bashSandbox)) {
      errors.push("safety.bashSandbox must be one of 'auto' | 'always' | 'off' if defined");
    }
  }

  if (config.provider.retry !== undefined) {
    if (!Number.isInteger(config.provider.retry.maxAttempts) || config.provider.retry.maxAttempts <= 0) {
      errors.push('provider.retry.maxAttempts must be a positive integer if defined');
    }
  }

  if (config.provider.metrics?.prefix !== undefined) {
    const p = config.provider.metrics.prefix;
    if (typeof p !== 'string' || !/^[a-z][a-z0-9_]*$/.test(p)) {
      errors.push(
        `provider.metrics.prefix must match /^[a-z][a-z0-9_]*$/ (got ${JSON.stringify(p)})`,
      );
    } else if (p.length < 2 || p.length > 64) {
      // The regex alone accepts a single character or a 10 000-char
      // string. Both create Grafana / dashboard auto-complete pain —
      // cap to a sane range.
      errors.push(
        `provider.metrics.prefix length must be 2-64 chars (got ${p.length})`,
      );
    }
  }

  if (config.provider.metrics?.bearerToken !== undefined) {
    const t = config.provider.metrics.bearerToken;
    if (typeof t !== 'string' || t.length === 0) {
      errors.push('provider.metrics.bearer_token must be a non-empty string if defined');
    }
  }

  if (config.provider.metrics?.allowedHosts !== undefined) {
    const h = config.provider.metrics.allowedHosts;
    if (!Array.isArray(h) || h.some(v => typeof v !== 'string' || v.length === 0)) {
      errors.push('provider.metrics.allowed_hosts must be an array of non-empty strings');
    }
  }

  return errors;
}

let cachedProviderRetryConfig: ProviderRetryConfig | null = null;

export function getDefaultProviderRetryConfig(): ProviderRetryConfig {
  if (cachedProviderRetryConfig === null) {
    cachedProviderRetryConfig = cloneProviderRetryConfig(loadConfig().provider.retry ?? DEFAULT_LLM_RETRY_CONFIG);
  }
  return cloneProviderRetryConfig(cachedProviderRetryConfig);
}

