import fs from 'fs';
import fsPromises from 'fs/promises';
import os from 'os';
import path from 'path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';

// Source of truth: ~/.pilotdeck/pilotdeck.yaml. The disk format and the
// "internal" config object are the same V2 schema — no more adapter layer.
//
// Top-level shape:
//   schemaVersion: 1
//   agent:    { model: "provider/model", params, subagents }
//   model:    { providers: { [pid]: { protocol, url, apiKey, models, headers, timeoutMs } } }
//   memory:   { enabled, model, apiType?, reasoningMode, ... }
//   webui:    { runtime: { host, serverPort, vitePort, proxyPort, ... } }
//   router:   { enabled, stats: { enabled, modelPricing }, ... }
//   gateway:  { enabled, home, ... }
//   alwaysOn: { enabled, trigger, dormancy, workspace, execution, projects }
//   customEnv:{ KEY: VALUE }    (UI-only; engine ignores)
//
// Everything not in this list (router/gateway/alwaysOn deep fields, etc.)
// flows through verbatim — the gateway-side PilotConfigStore owns those
// schemas. UI server just round-trips them.

const CONFIG_VERSION = 1;
const PILOT_HOME_DIR = process.env.PILOT_HOME || path.join(os.homedir(), '.pilotdeck');
const DEFAULT_CONFIG_PATH = path.join(PILOT_HOME_DIR, 'pilotdeck.yaml');
const MASK = '********';

const SECRET_KEY_RE = /(api[_-]?key|token|secret|password|auth[_-]?token|access[_-]?token|bot[_-]?token|app[_-]?token|encoding[_-]?aes[_-]?key)$/i;
const SECRET_EXACT_KEYS = new Set(['key', 'apiKey', 'api_key', 'authToken', 'accessToken']);

function clone(value) {
  return JSON.parse(JSON.stringify(value));
}

function isRecord(value) {
  return value && typeof value === 'object' && !Array.isArray(value);
}

function normalizeString(value) {
  return typeof value === 'string' ? value.trim() : '';
}

function deepMerge(base, override) {
  if (!isRecord(base)) return clone(override);
  const output = clone(base);
  if (!isRecord(override)) return output;
  for (const [key, value] of Object.entries(override)) {
    if (value === undefined) continue;
    if (isRecord(value) && isRecord(output[key])) {
      output[key] = deepMerge(output[key], value);
    } else {
      output[key] = value;
    }
  }
  return output;
}

// ─── Defaults ────────────────────────────────────────────────────────────────

export function buildDefaultPilotDeckConfig() {
  return {
    schemaVersion: CONFIG_VERSION,
    agent: {
      model: '',
      params: {},
      subagents: { default: 'inherit', params: {} },
    },
    model: {
      providers: {},
    },
    memory: {
      enabled: true,
      reasoningMode: 'answer_first',
      autoIndexIntervalMinutes: 30,
      autoDreamIntervalMinutes: 60,
      captureStrategy: 'last_turn',
      includeAssistant: true,
      maxMessageChars: 6000,
      heartbeatBatchSize: 30,
    },
    webui: {
      runtime: {
        host: '0.0.0.0',
        serverPort: 3001,
        vitePort: 5173,
        proxyPort: 18080,
        apiTimeoutMs: 120000,
        httpsProxy: '',
        databasePath: path.join(PILOT_HOME_DIR, 'auth.db'),
        workspacesRoot: os.homedir(),
      },
    },
  };
}

// `normalize` here means "fill in missing top-level sections with defaults"
// — it never reshapes. Idempotent.
export function normalizePilotDeckConfig(input) {
  return deepMerge(buildDefaultPilotDeckConfig(), isRecord(input) ? input : {});
}

// Strip surrounding whitespace from provider apiKey + url before they
// hit disk. Without this, a copy-paste with a stray space (e.g.
// `apiKey: " sk-..."`) survives the round-trip and produces an
// `Authorization: Bearer  sk-...` header that providers reject as
// `invalid_token` / `无效的令牌`. The gateway's parseModelConfig already
// trims as a defence-in-depth, but cleaning here keeps the on-disk
// yaml authoritative + diff-clean for users browsing the file.
export function sanitizeProviderCredentials(config) {
  if (!isRecord(config)) return config;
  const providers = config?.model?.providers;
  if (!isRecord(providers)) return config;
  for (const provider of Object.values(providers)) {
    if (!isRecord(provider)) continue;
    if (typeof provider.apiKey === 'string') {
      provider.apiKey = provider.apiKey.trim();
    }
    if (typeof provider.url === 'string') {
      provider.url = provider.url.trim();
    }
  }
  return config;
}

// ─── Model resolution ────────────────────────────────────────────────────────

function splitModelRef(ref) {
  const text = normalizeString(ref);
  if (!text) return null;
  const slash = text.indexOf('/');
  if (slash <= 0 || slash === text.length - 1) return null;
  return { providerId: text.slice(0, slash), modelId: text.slice(slash + 1) };
}

// Returns { id, providerId, provider, model, def } or null if the
// reference doesn't resolve. `id` is the canonical "provider/model"
// string (after inherit-resolution).
export function resolveModel(config, ref, options = {}) {
  const inheritFallback = normalizeString(config?.agent?.model);
  const refText = normalizeString(ref);
  const effective = (!refText || refText === 'inherit')
    ? inheritFallback
    : refText;
  const parts = splitModelRef(effective);
  if (!parts) {
    if (options.allowMissing) return null;
    throw new Error(`Invalid model reference: ${ref ?? ''}`);
  }
  const provider = config?.model?.providers?.[parts.providerId];
  if (!isRecord(provider)) {
    if (options.allowMissing) return null;
    throw new Error(`Provider not found for model "${effective}": ${parts.providerId}`);
  }
  const def = isRecord(provider.models) ? provider.models[parts.modelId] : null;
  return {
    id: effective,
    providerId: parts.providerId,
    provider,
    model: parts.modelId,
    def: isRecord(def) ? def : {},
  };
}

// ─── Validation ──────────────────────────────────────────────────────────────

function validateProvider(id, provider, errors) {
  if (!isRecord(provider)) {
    errors.push(`model.providers.${id} must be an object`);
    return;
  }
  const protocol = normalizeString(provider.protocol).toLowerCase();
  if (!protocol) errors.push(`model.providers.${id}.protocol is required`);
  else if (protocol !== 'openai' && protocol !== 'anthropic') {
    errors.push(`model.providers.${id}.protocol must be "openai" or "anthropic"`);
  }
  if (!normalizeString(provider.url)) errors.push(`model.providers.${id}.url is required`);
  if (!normalizeString(provider.apiKey)) errors.push(`model.providers.${id}.apiKey is required`);
}

function validateModelRef(config, ref, label, errors) {
  const modelRef = normalizeString(ref);
  if (!modelRef) return;
  if (!resolveModel(config, modelRef, { allowMissing: true })) {
    errors.push(`${label}="${modelRef}" doesn't resolve to a configured provider/model`);
  }
}

function validateRouterModelRefs(config, errors) {
  const router = config.router;
  if (!isRecord(router)) return;

  if (isRecord(router.scenarios)) {
    for (const [key, ref] of Object.entries(router.scenarios)) {
      validateModelRef(config, ref, `router.scenarios.${key}`, errors);
    }
  }

  if (isRecord(router.fallback)) {
    for (const [key, refs] of Object.entries(router.fallback)) {
      if (!Array.isArray(refs)) continue;
      refs.forEach((ref, index) => validateModelRef(config, ref, `router.fallback.${key}[${index}]`, errors));
    }
  }

  const tokenSaver = router.tokenSaver;
  if (!isRecord(tokenSaver)) return;

  validateModelRef(config, tokenSaver.judge, 'router.tokenSaver.judge', errors);

  if (isRecord(tokenSaver.tiers)) {
    for (const [key, tier] of Object.entries(tokenSaver.tiers)) {
      if (!isRecord(tier)) continue;
      validateModelRef(config, tier.model, `router.tokenSaver.tiers.${key}.model`, errors);
    }
  }
}

export function validatePilotDeckConfig(config) {
  const normalized = normalizePilotDeckConfig(config);
  const errors = [];
  const warnings = [];

  const mainRef = normalizeString(normalized.agent.model);
  if (!mainRef) {
    warnings.push('agent.model is empty; pick a model from model.providers.');
  } else {
    const main = resolveModel(normalized, mainRef, { allowMissing: true });
    if (!main) {
      errors.push(`agent.model="${mainRef}" doesn't resolve to a configured provider/model`);
    } else {
      validateProvider(main.providerId, main.provider, errors);
    }
  }

  if (normalized.memory?.enabled && normalizeString(normalized.memory.model)) {
    const ref = normalizeString(normalized.memory.model);
    if (ref !== 'inherit') {
      const memory = resolveModel(normalized, ref, { allowMissing: true });
      if (!memory) {
        errors.push(`memory.model="${ref}" doesn't resolve to a configured provider/model`);
      }
    }
  }

  validateRouterModelRefs(normalized, errors);

  if (normalized.webui?.runtime?.contextWindow !== undefined) {
    warnings.push(
      'webui.runtime.contextWindow is deprecated and ignored. ' +
      'Use agent.maxContextTokens to override the model\'s context window for auto-compaction.',
    );
  }

  return { valid: errors.length === 0, errors, warnings, config: normalized };
}

// ─── Secret masking ──────────────────────────────────────────────────────────

function isSecretKey(key) {
  return SECRET_EXACT_KEYS.has(key) || SECRET_KEY_RE.test(key);
}

export function maskSecrets(value) {
  if (Array.isArray(value)) return value.map(maskSecrets);
  if (!isRecord(value)) return value;
  const output = {};
  for (const [key, child] of Object.entries(value)) {
    if (isSecretKey(key) && typeof child === 'string' && child.trim()) {
      output[key] = MASK;
    } else {
      output[key] = maskSecrets(child);
    }
  }
  return output;
}

export function preserveMaskedSecrets(nextValue, previousValue) {
  if (nextValue === MASK && typeof previousValue === 'string') return previousValue;
  if (Array.isArray(nextValue)) {
    return nextValue.map((item, index) =>
      preserveMaskedSecrets(item, Array.isArray(previousValue) ? previousValue[index] : undefined),
    );
  }
  if (isRecord(nextValue)) {
    const output = {};
    for (const [key, child] of Object.entries(nextValue)) {
      output[key] = preserveMaskedSecrets(child, isRecord(previousValue) ? previousValue[key] : undefined);
    }
    return output;
  }
  return nextValue;
}

// ─── Runtime env derivation ──────────────────────────────────────────────────

function providerProtocolToMemoryApi(protocol) {
  // V2 catalog only uses 'openai' (Chat Completions) and 'anthropic'.
  // The /responses style is only relevant when a user manually sets
  // memory.apiType, which they can do alongside protocol="openai".
  return 'openai-completions';
}

export function buildRuntimeEnv(config) {
  const normalized = normalizePilotDeckConfig(config);
  const main = resolveModel(normalized, normalized.agent.model, { allowMissing: true });
  const runtime = normalized.webui?.runtime ?? {};
  const proxyPort = String(runtime.proxyPort ?? 18080);

  const env = {
    PILOTDECK_PROXY_PORT: process.env.PILOTDECK_PROXY_PORT || proxyPort,
    PROXY_PORT: process.env.PROXY_PORT || proxyPort,
    SERVER_PORT: process.env.SERVER_PORT || String(runtime.serverPort ?? 3001),
    VITE_PORT: process.env.VITE_PORT || String(runtime.vitePort ?? 5173),
    HOST: process.env.HOST || String(runtime.host ?? '0.0.0.0'),
    API_TIMEOUT_MS: String(runtime.apiTimeoutMs ?? 120000),
    PILOTDECK_MEMORY_ENABLED: normalized.memory?.enabled ? '1' : '0',
  };

  if (runtime.databasePath) env.DATABASE_PATH = expandTilde(runtime.databasePath);
  if (runtime.workspacesRoot) env.WORKSPACES_ROOT = expandTilde(runtime.workspacesRoot);
  if (runtime.httpsProxy) {
    env.HTTPS_PROXY = runtime.httpsProxy;
    env.https_proxy = runtime.httpsProxy;
  }

  if (main) {
    env.PILOTDECK_API_BASE_URL = main.provider.url || '';
    env.PILOTDECK_API_KEY = main.provider.apiKey || '';
    env.PILOTDECK_MODEL = main.model;
    env.OPENAI_BASE_URL = main.provider.url || '';
    env.OPENAI_API_KEY = main.provider.apiKey || '';
    env.OPENAI_MODEL = main.model;
    env.ANTHROPIC_API_KEY = main.provider.apiKey || '';
    env.ANTHROPIC_MODEL = main.model;
  }
  env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${proxyPort}`;

  // Reasoning models (DeepSeek-R1, MiniMax-M2.7, etc.) need a generous
  // output token cap; honor agent.params.maxOutputTokens / max_tokens.
  const mainParams = normalized.agent?.params ?? {};
  const requestedMaxOutput = Number.parseInt(
    String(
      mainParams.maxOutputTokens ??
        mainParams.max_output_tokens ??
        mainParams.max_tokens ??
        ''
    ).trim(),
    10,
  );
  if (Number.isFinite(requestedMaxOutput) && requestedMaxOutput > 0) {
    env.PILOTDECK_MAX_OUTPUT_TOKENS = String(requestedMaxOutput);
  } else if (process.env.PILOTDECK_MAX_OUTPUT_TOKENS) {
    env.PILOTDECK_MAX_OUTPUT_TOKENS = process.env.PILOTDECK_MAX_OUTPUT_TOKENS;
  }

  const tavilyKey = mainParams.tavilyApiKey ?? mainParams.tavily_api_key ?? process.env.TAVILY_API_KEY;
  if (tavilyKey) env.TAVILY_API_KEY = String(tavilyKey);

  // Memory uses memory.model (or inherits agent.model when blank).
  const memoryRef = normalizeString(normalized.memory?.model) || normalized.agent.model;
  const memory = resolveModel(normalized, memoryRef, { allowMissing: true });
  if (memory) {
    env.PILOTDECK_MEMORY_MODEL = memory.model;
    env.PILOTDECK_MEMORY_PROVIDER = memory.providerId;
    env.PILOTDECK_MEMORY_BASE_URL = memory.provider.url || '';
    env.PILOTDECK_MEMORY_API_KEY = memory.provider.apiKey || '';
    env.PILOTDECK_MEMORY_API_TYPE = normalizeString(normalized.memory?.apiType)
      || providerProtocolToMemoryApi(memory.provider.protocol);
  }

  // Pass through customEnv (UI-managed escape hatch).
  if (isRecord(normalized.customEnv)) {
    for (const [key, value] of Object.entries(normalized.customEnv)) {
      if (typeof value === 'string' && value.trim()) env[key] = value;
    }
  }

  return env;
}

export function applyConfigToProcessEnv(config) {
  Object.assign(process.env, buildRuntimeEnv(config));
}

// ─── Memory service options ──────────────────────────────────────────────────

export function buildMemoryLlmOptions(config) {
  const normalized = normalizePilotDeckConfig(config);
  const ref = normalizeString(normalized.memory?.model) || normalized.agent.model;
  const memory = resolveModel(normalized, ref, { allowMissing: true });
  if (!memory) return undefined;
  return {
    provider: memory.providerId,
    model: memory.model,
    apiType: normalizeString(normalized.memory?.apiType)
      || providerProtocolToMemoryApi(memory.provider.protocol),
    baseUrl: memory.provider.url || '',
    apiKey: memory.provider.apiKey || '',
    headers: isRecord(memory.provider.headers) ? memory.provider.headers : {},
  };
}

export function buildMemoryDefaults(config) {
  const memory = normalizePilotDeckConfig(config).memory ?? {};
  return {
    llm: buildMemoryLlmOptions(config),
    defaultIndexingSettings: {
      reasoningMode: memory.reasoningMode,
      autoIndexIntervalMinutes: memory.autoIndexIntervalMinutes,
      autoDreamIntervalMinutes: memory.autoDreamIntervalMinutes,
    },
    captureStrategy: memory.captureStrategy,
    includeAssistant: memory.includeAssistant,
    maxMessageChars: memory.maxMessageChars,
    heartbeatBatchSize: memory.heartbeatBatchSize,
  };
}

// ─── File I/O ────────────────────────────────────────────────────────────────

export function getPilotDeckConfigPath() {
  if (process.env.PILOTDECK_CONFIG_PATH?.trim()) {
    return process.env.PILOTDECK_CONFIG_PATH.trim();
  }
  return DEFAULT_CONFIG_PATH;
}

export function readPilotDeckConfigFile() {
  const configPath = getPilotDeckConfigPath();
  if (!fs.existsSync(configPath)) {
    return {
      exists: false,
      configPath,
      raw: '',
      config: buildDefaultPilotDeckConfig(),
      rawYaml: {},
    };
  }
  const raw = fs.readFileSync(configPath, 'utf8');
  const parsed = parseYaml(raw) || {};
  const config = normalizePilotDeckConfig(parsed);
  return { exists: true, configPath, raw, config, rawYaml: parsed };
}

// Keep `router.scenarios.default` aligned with `agent.model` whenever we
// write the config. The gateway treats agent.model as the source of truth
// (loadPilotConfig.ts auto-overrides router.scenarios.default with
// agent.model on conflict, with a warning). Doing the rewrite here too
// means the on-disk yaml stays consistent — no stale router refs left
// over from before the user picked a new model in onboarding/settings.
//
// Scope is deliberately narrow:
//   • only touches `router.scenarios.default` (not tokenSaver tiers,
//     fallback chains, or other scenario keys — those are user-curated)
//   • no-ops when agent.model is empty or unparseable
//   • no-ops when router block doesn't exist (won't create one)
export function syncAgentModelWithRouter(config) {
  if (!isRecord(config)) return config;
  const agentRef = normalizeString(config.agent?.model);
  if (!agentRef) return config;
  const slash = agentRef.indexOf('/');
  if (slash <= 0 || slash >= agentRef.length - 1) return config;
  const providerId = agentRef.slice(0, slash);
  const modelId = agentRef.slice(slash + 1);

  if (!isRecord(config.router)) return config;
  if (!isRecord(config.router.scenarios)) return config;
  const currentDefault = config.router.scenarios.default;
  // Accept both string ("provider/model") and object ref shapes.
  const currentId = typeof currentDefault === 'string'
    ? currentDefault.trim()
    : (isRecord(currentDefault) ? normalizeString(currentDefault.id) : '');
  if (currentId === agentRef) return config;
  config.router.scenarios.default = typeof currentDefault === 'string'
    ? agentRef
    : { id: agentRef, provider: providerId, model: modelId };
  return config;
}

const BOOTSTRAP_PLACEHOLDER_KEY = 'PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE';

// Remove bootstrap placeholder providers — both the new `_placeholder` name
// and any legacy provider whose apiKey is still the onboarding sentinel.
// Called automatically on every config write so stale placeholders disappear
// as soon as the user saves real provider details.
function purgeBootstrapPlaceholder(config) {
  if (!isRecord(config)) return config;
  const providers = config?.model?.providers;
  if (isRecord(providers)) {
    for (const [pid, prov] of Object.entries(providers)) {
      if (pid === '_placeholder' || normalizeString(prov?.apiKey) === BOOTSTRAP_PLACEHOLDER_KEY) {
        delete providers[pid];
      }
    }
  }

  const agentModel = normalizeString(config?.agent?.model);
  if (agentModel === '_placeholder/_placeholder') {
    const realProviders = isRecord(providers) ? Object.keys(providers) : [];
    if (realProviders.length > 0) {
      const firstProvider = realProviders[0];
      const models = Object.keys(providers[firstProvider]?.models ?? {});
      if (models.length > 0) {
        config.agent.model = `${firstProvider}/${models[0]}`;
      }
    }
  }

  const router = config?.router;
  if (!isRecord(router)) return config;

  const agentRef = normalizeString(config.agent?.model);
  const survivingProviders = isRecord(providers) ? new Set(Object.keys(providers)) : new Set();

  function isOrphanRef(ref) {
    const s = normalizeString(ref);
    if (!s) return false;
    const slash = s.indexOf('/');
    if (slash <= 0) return false;
    return !survivingProviders.has(s.slice(0, slash));
  }

  if (isRecord(router.scenarios)) {
    for (const [key, val] of Object.entries(router.scenarios)) {
      if (isOrphanRef(val)) router.scenarios[key] = agentRef || val;
    }
  }
  if (Array.isArray(router.fallback?.default)) {
    router.fallback.default = router.fallback.default.map(
      v => isOrphanRef(v) ? (agentRef || v) : v
    );
  }
  if (isRecord(router.tokenSaver)) {
    if (isOrphanRef(router.tokenSaver.judge)) {
      router.tokenSaver.judge = agentRef || router.tokenSaver.judge;
    }
    if (isRecord(router.tokenSaver.tiers)) {
      for (const tier of Object.values(router.tokenSaver.tiers)) {
        if (isRecord(tier) && isOrphanRef(tier.model)) {
          tier.model = agentRef || tier.model;
        }
      }
    }
  }

  return config;
}

// Lossless writer — config object is the V2 disk shape, written verbatim
// after running through validation. UI-internal === disk schema, so
// there's no read-modify-write needed anymore (the previous translation
// layer existed only to bridge an older internal schema).
export async function writePilotDeckConfig(config) {
  const sanitized = purgeBootstrapPlaceholder(
    syncAgentModelWithRouter(
      sanitizeProviderCredentials(
        isRecord(config) ? deepMerge({}, config) : config,
      ),
    ),
  );
  const validation = validatePilotDeckConfig(sanitized);
  if (!validation.valid) {
    const error = new Error('Invalid PilotDeck config');
    error.validation = validation;
    throw error;
  }
  const configPath = getPilotDeckConfigPath();
  await fsPromises.mkdir(path.dirname(configPath), { recursive: true });
  const yamlObj = validation.config;
  const raw = stringifyYaml(yamlObj, { lineWidth: 0 });
  await fsPromises.writeFile(configPath, raw, 'utf8');
  return { configPath, raw, validation, config: yamlObj };
}

// Kept as a thin alias for callers that supply an already-parsed YAML
// object (Raw YAML editor path). Behaviour is identical to
// writePilotDeckConfig now that internal === disk.
export async function writeRawPilotDeckYaml(yamlObj) {
  return writePilotDeckConfig(yamlObj);
}

export function expandTilde(value) {
  const text = normalizeString(value);
  if (text === '~') return os.homedir();
  if (text.startsWith('~/')) return path.join(os.homedir(), text.slice(2));
  return text;
}

export function configToYaml(config) {
  const normalized = normalizePilotDeckConfig(config);
  return stringifyYaml(normalized, { lineWidth: 0 });
}

// Lossless masked serialization for the "Raw YAML" view. Now that
// internal === disk, this is just `stringifyYaml(maskSecrets(rawYaml))`.
export function rawYamlToMaskedString(rawYaml) {
  const obj = isRecord(rawYaml) ? rawYaml : {};
  return stringifyYaml(maskSecrets(obj), { lineWidth: 0 });
}

export function parseConfigYaml(raw) {
  return normalizePilotDeckConfig(parseYaml(raw) || {});
}