/**
 * Configuration for the `decide` pi extension.
 *
 * Shape: one top-level `backend` selector plus a self-contained block per backend, so a
 * credential and its endpoint are always paired:
 *
 *   typesafe   -> TypeSafe System One        /v1/systemone      TYPESAFE_API_KEY
 *   openrouter -> OpenRouter Decisions API   /alpha/decisions   OPENROUTER_API_KEY
 *   llm        -> any OpenAI-compatible chat /chat/completions  DECIDER_LLM_API_KEY
 *
 * Precedence per field: config file -> environment -> built-in default. Config is re-read on
 * every call, so edits need no `/reload`, and a string value may be `"$ENV_VAR"` to defer the
 * lookup to request time.
 *
 * The config file is `<agentDir>/decider.json`, where `<agentDir>` defaults to `~/.pi/agent`
 * and follows `PI_CODING_AGENT_DIR`. Callers pass the directory in, which keeps this module
 * free of pi imports and unit-testable outside the pi runtime.
 */

import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { JevError, messageOf } from "./errors.ts";

export const CONFIG_FILENAME = "decider.json";

export const BACKEND_IDS = ["typesafe", "openrouter", "llm"] as const;
export type BackendId = (typeof BACKEND_IDS)[number];
/** `decisions` backends take state + typed questions; `chat` backends are proxied chat completions. */
export type BackendKind = "decisions" | "chat";
export type BackendSelection = "auto" | BackendId;

export interface BackendDefaults {
  label: string;
  kind: BackendKind;
  baseUrl: string;
  path: string;
  /** Model id used when neither the config file, the environment, nor the tool call sets one. */
  model?: string;
  costPerMTokInput: number;
  costPerMTokOutput: number;
  timeoutMs: number;
  maxRetries: number;
}

export const BACKEND_DEFAULTS: Record<BackendId, BackendDefaults> = {
  typesafe: {
    label: "TypeSafe System One",
    kind: "decisions",
    baseUrl: "https://api.typesafe.ai",
    path: "/v1/systemone",
    model: "jev-latest",
    costPerMTokInput: 0.042,
    costPerMTokOutput: 0,
    timeoutMs: 60_000,
    maxRetries: 2,
  },
  openrouter: {
    label: "OpenRouter Decisions API",
    kind: "decisions",
    baseUrl: "https://openrouter.ai/api",
    path: "/alpha/decisions",
    model: "~typesafe/jev-latest",
    costPerMTokInput: 0.042,
    costPerMTokOutput: 0,
    timeoutMs: 60_000,
    maxRetries: 2,
  },
  llm: {
    label: "OpenAI-compatible chat proxy",
    kind: "chat",
    baseUrl: "https://openrouter.ai/api/v1",
    path: "/chat/completions",
    costPerMTokInput: 0,
    costPerMTokOutput: 0,
    timeoutMs: 60_000,
    maxRetries: 0,
  },
};

/** Well-known provider keys, tried after the backend-scoped ones. */
const PROVIDER_KEY_ENVS: Record<BackendId, string[]> = {
  typesafe: ["TYPESAFE_API_KEY"],
  openrouter: ["OPENROUTER_API_KEY"],
  llm: ["TYPESAFE_LLM_API_KEY", "OPENROUTER_API_KEY"],
};

/** Env var name `/decide setup` suggests for each backend. */
export const SUGGESTED_KEY_ENV: Record<BackendId, string> = {
  typesafe: "TYPESAFE_API_KEY",
  openrouter: "OPENROUTER_API_KEY",
  llm: "DECIDER_LLM_API_KEY",
};

export const DEFAULT_MODELS_PATH = "/v1/models";
export const DEFAULT_LLM_MAX_TOKENS = 2048;
export const DEFAULT_LLM_REPAIR_ATTEMPTS = 1;

export const BACKEND_ENV = "DECIDER_BACKEND";
export const MODELS_PATH_ENV = "DECIDER_MODELS_PATH";

/** OpenRouter serves Jev through its Decisions API; verified 2026-09-18. */
export const OPENROUTER_NOTE =
  `OpenRouter's Decisions API routes Jev through ${BACKEND_DEFAULTS.openrouter.baseUrl}${BACKEND_DEFAULTS.openrouter.path} ` +
  `with model ${BACKEND_DEFAULTS.openrouter.model} at $0.042/Mtok input and $0/Mtok output; ` +
  `only the family alias exists there (pinned ids such as ~typesafe/jev-1.13.0 return 404) and it is absent from the public /v1/models catalogue.`;

export type JsonMode = "json_object" | "none";

const JSON_MODES: readonly JsonMode[] = ["json_object", "none"];

export interface BackendConfig {
  id: BackendId;
  kind: BackendKind;
  label: string;
  apiKey: string | undefined;
  apiKeySource: string;
  baseUrl: string;
  baseUrlSource: string;
  path: string;
  pathSource: string;
  model: string | undefined;
  modelSource: string;
  timeoutMs: number;
  maxRetries: number;
  costPerMTokInput: number;
  costPerMTokOutput: number;
  /** chat backends only */
  temperature?: number;
  maxTokens?: number;
  jsonMode?: JsonMode;
  repairAttempts?: number;
}

export interface DeciderConfig {
  /** `auto`, or a pinned backend id. */
  backend: BackendSelection;
  backendSource: string;
  backends: Record<BackendId, BackendConfig>;
  modelsPath: string;
  configPath: string;
  notes: string[];
}

export interface ResolveConfigOptions {
  /** Directory holding pi's agent state (`getAgentDir()`), usually `~/.pi/agent`. */
  agentDir: string;
  env?: NodeJS.ProcessEnv;
  /** Override the config file location (tests). Defaults to `<agentDir>/decider.json`. */
  configPath?: string;
  /** Skip reading the config file entirely (tests). */
  skipConfigFile?: boolean;
}

/** Read and parse the config file. A missing file is not an error. */
export function readConfigFile(path: string): Record<string, unknown> {
  let raw: string;
  try {
    raw = readFileSync(path, "utf8");
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
    throw new JevError(`Cannot read config file ${path}: ${messageOf(error)}`);
  }
  const text = raw.replace(/^\uFEFF/, "").trim();
  if (text === "") return {};
  let parsed: unknown;
  try {
    parsed = JSON.parse(text);
  } catch (error) {
    throw new JevError(`Config file ${path} is not valid JSON: ${messageOf(error)}`);
  }
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
    throw new JevError(`Config file ${path} must contain a JSON object.`);
  }
  return parsed as Record<string, unknown>;
}

export function resolveConfig(options: ResolveConfigOptions): DeciderConfig {
  const env = options.env ?? process.env;
  const configPath = options.configPath ?? join(options.agentDir, CONFIG_FILENAME);
  const root = options.skipConfigFile ? {} : readConfigFile(configPath);
  const notes: string[] = [];

  const backends = {} as Record<BackendId, BackendConfig>;
  for (const id of BACKEND_IDS) {
    backends[id] = resolveBackend(id, readBackendBlock(root[id], configPath, id), env, notes);
  }

  const selection = readSelection(root.backend, configPath, env);
  if (backends.openrouter.apiKey !== undefined && backends.typesafe.apiKey === undefined) notes.push(OPENROUTER_NOTE);

  return {
    backend: selection.value,
    backendSource: selection.source,
    backends,
    modelsPath: optionalString(root.modelsPath, configPath, "modelsPath") ?? env[MODELS_PATH_ENV]?.trim() ?? DEFAULT_MODELS_PATH,
    configPath,
    notes,
  };
}

/**
 * Resolve the backend for a request. Explicit selections win; `auto` takes the first backend
 * that is actually usable, preferring real Jev (TypeSafe, then OpenRouter) over the chat proxy.
 */
export function selectBackend(cfg: DeciderConfig, requested?: string): BackendConfig {
  if (requested !== undefined) {
    const backend = cfg.backends[requested as BackendId];
    if (backend === undefined) {
      throw new JevError(`Unknown backend "${requested}".`, { hint: describeBackends(cfg) });
    }
    requireUsable(backend, cfg);
    return backend;
  }
  if (cfg.backend !== "auto") {
    const backend = cfg.backends[cfg.backend];
    requireUsable(backend, cfg);
    return backend;
  }
  for (const id of BACKEND_IDS) {
    const backend = cfg.backends[id];
    if (isUsable(backend)) return backend;
  }
  throw new JevError("No backend is usable.", { hint: describeBackends(cfg) });
}

/** One line per backend: credential state, endpoint, model. Used by errors and `/decide status`. */
export function describeBackends(cfg: DeciderConfig): string {
  return BACKEND_IDS.map((id) => {
    const backend = cfg.backends[id];
    const key = backend.apiKey === undefined ? "no key" : `${backend.apiKeySource} ${maskKey(backend.apiKey)}`;
    const model = backend.model ?? "no model";
    return `${id} (${backend.label}): ${key} · ${backend.baseUrl}${backend.path} · ${model}`;
  }).join("\n");
}

function isUsable(backend: BackendConfig): boolean {
  if (backend.apiKey === undefined) return false;
  // A chat backend cannot answer without a model to call.
  return backend.kind !== "chat" || (backend.model !== undefined && backend.model.trim() !== "");
}

function requireUsable(backend: BackendConfig, cfg: DeciderConfig): void {
  if (isUsable(backend)) return;
  const reason = backend.apiKey === undefined ? "has no API key" : "has no model";
  throw new JevError(`Backend "${backend.id}" ${reason}.`, { hint: describeBackends(cfg) });
}

/** `sk-a…wxyz (48 chars)` — never returns the full key. */
export function maskKey(key: string | undefined): string {
  if (!key) return "not set";
  if (key.length <= 8) return `…${key.slice(-2)} (${key.length} chars)`;
  return `${key.slice(0, 4)}…${key.slice(-4)} (${key.length} chars)`;
}

export function joinUrl(baseUrl: string, path: string): string {
  return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}

function resolveBackend(
  id: BackendId,
  block: Record<string, unknown>,
  env: NodeJS.ProcessEnv,
  notes: string[],
): BackendConfig {
  const defaults = BACKEND_DEFAULTS[id];
  const configPath = "config file";
  const prefix = `DECIDER_${id.toUpperCase()}`;

  const key = resolveString(block.apiKey, configPath, `${id}.apiKey`, env, notes);
  let apiKey = key.value;
  let apiKeySource = key.value === undefined ? "not set" : `${id}.apiKey`;
  for (const name of [`${prefix}_API_KEY`, ...PROVIDER_KEY_ENVS[id]]) {
    if (apiKey !== undefined) break;
    if (env[name]?.trim()) {
      apiKey = env[name]!.trim();
      apiKeySource = `$${name}`;
    }
  }

  const base = resolveString(block.baseUrl, configPath, `${id}.baseUrl`, env, notes);
  let baseUrl = base.value;
  let baseUrlSource = base.value === undefined ? "" : `${id}.baseUrl`;
  if (baseUrl === undefined && env[`${prefix}_BASE_URL`]?.trim()) {
    baseUrl = env[`${prefix}_BASE_URL`]!.trim();
    baseUrlSource = `$${prefix}_BASE_URL`;
  }
  if (baseUrl === undefined) {
    baseUrl = defaults.baseUrl;
    baseUrlSource = "default";
  }

  const path = resolveString(block.path, configPath, `${id}.path`, env, notes);
  let backendPath = path.value;
  let pathSource = path.value === undefined ? "" : `${id}.path`;
  if (backendPath === undefined && env[`${prefix}_PATH`]?.trim()) {
    backendPath = env[`${prefix}_PATH`]!.trim();
    pathSource = `$${prefix}_PATH`;
  }
  if (backendPath === undefined) {
    backendPath = defaults.path;
    pathSource = defaults.path === defaults.path ? "default" : "default";
  }

  const model = resolveString(block.model, configPath, `${id}.model`, env, notes);
  let modelValue = model.value;
  let modelSource = model.value === undefined ? "not set" : `${id}.model`;
  if (modelValue === undefined && env[`${prefix}_MODEL`]?.trim()) {
    modelValue = env[`${prefix}_MODEL`]!.trim();
    modelSource = `$${prefix}_MODEL`;
  }
  if (modelValue === undefined && defaults.model !== undefined) {
    modelValue = defaults.model;
    modelSource = "default";
  }

  const config: BackendConfig = {
    id,
    kind: defaults.kind,
    label: defaults.label,
    apiKey,
    apiKeySource,
    baseUrl: baseUrl.replace(/\/+$/, ""),
    baseUrlSource,
    path: backendPath,
    pathSource,
    model: modelValue,
    modelSource,
    timeoutMs: readNumber(block.timeoutMs, configPath, `${id}.timeoutMs`, defaults.timeoutMs, {
      integer: false,
      min: 1,
    }),
    maxRetries: readNumber(block.maxRetries, configPath, `${id}.maxRetries`, defaults.maxRetries, {
      integer: true,
      min: 0,
    }),
    costPerMTokInput: readNumber(block.costPerMTokInput, configPath, `${id}.costPerMTokInput`, defaults.costPerMTokInput, {
      integer: false,
      min: 0,
    }),
    costPerMTokOutput: readNumber(
      block.costPerMTokOutput,
      configPath,
      `${id}.costPerMTokOutput`,
      defaults.costPerMTokOutput,
      { integer: false, min: 0 },
    ),
  };

  if (defaults.kind === "chat") {
    config.temperature = readNumber(block.temperature, configPath, `${id}.temperature`, 0, { integer: false, min: 0 });
    config.maxTokens = readNumber(block.maxTokens, configPath, `${id}.maxTokens`, DEFAULT_LLM_MAX_TOKENS, {
      integer: true,
      min: 1,
    });
    config.jsonMode = readEnum(block.jsonMode, JSON_MODES, configPath, `${id}.jsonMode`) ?? "json_object";
    config.repairAttempts = readNumber(
      block.repairAttempts,
      configPath,
      `${id}.repairAttempts`,
      DEFAULT_LLM_REPAIR_ATTEMPTS,
      { integer: true, min: 0 },
    );
  }
  return config;
}

function readSelection(
  raw: unknown,
  configPath: string,
  env: NodeJS.ProcessEnv,
): { value: BackendSelection; source: string } {
  const allowed: readonly BackendSelection[] = ["auto", ...BACKEND_IDS];
  const fromFile = optionalString(raw, configPath, "backend");
  if (fromFile !== undefined) {
    if (!(allowed as readonly string[]).includes(fromFile)) {
      throw new JevError(`${configPath}: backend must be one of ${allowed.join(", ")} (got "${fromFile}").`);
    }
    return { value: fromFile as BackendSelection, source: `${configPath}:backend` };
  }
  const fromEnv = env[BACKEND_ENV]?.trim();
  if (fromEnv) {
    if (!(allowed as readonly string[]).includes(fromEnv)) {
      throw new JevError(`$${BACKEND_ENV} must be one of ${allowed.join(", ")} (got "${fromEnv}").`);
    }
    return { value: fromEnv as BackendSelection, source: `$${BACKEND_ENV}` };
  }
  return { value: "auto", source: "default" };
}

function readBackendBlock(raw: unknown, configPath: string, id: BackendId): Record<string, unknown> {
  if (raw === undefined || raw === null) return {};
  if (typeof raw !== "object" || Array.isArray(raw)) {
    throw new JevError(`${configPath}: ${id} must be an object.`);
  }
  return raw as Record<string, unknown>;
}

/** What `/decide setup` writes: a top-level backend choice and/or fields inside one backend block. */
export interface ConfigPatch {
  backend?: BackendSelection;
  /** Other top-level fields, e.g. `modelsPath`. */
  rootValues?: Record<string, unknown>;
  backendBlock?: { id: BackendId; values: Record<string, unknown> };
}

export type FieldKind = "string" | "number" | "integer" | "enum";

export interface FieldSpec {
  kind: FieldKind;
  /** Allowed values for `enum` fields. */
  values?: readonly string[];
  /** Keys are credentials: mask them when echoing what was written. */
  sensitive?: boolean;
}

const BACKEND_COMMON_FIELDS: Record<string, FieldSpec> = {
  apiKey: { kind: "string", sensitive: true },
  baseUrl: { kind: "string" },
  path: { kind: "string" },
  model: { kind: "string" },
  timeoutMs: { kind: "integer" },
  maxRetries: { kind: "integer" },
  costPerMTokInput: { kind: "number" },
  costPerMTokOutput: { kind: "number" },
};

const CHAT_ONLY_FIELDS: Record<string, FieldSpec> = {
  temperature: { kind: "number" },
  maxTokens: { kind: "integer" },
  jsonMode: { kind: "enum", values: ["json_object", "none"] },
  repairAttempts: { kind: "integer" },
};

/** Writable fields per target, driving both `/decide set|unset` and its error messages. */
export const SETTABLE_FIELDS: Record<BackendId | "root", Record<string, FieldSpec>> = {
  root: {
    backend: { kind: "enum", values: ["auto", ...BACKEND_IDS] },
    modelsPath: { kind: "string" },
  },
  typesafe: { ...BACKEND_COMMON_FIELDS },
  openrouter: { ...BACKEND_COMMON_FIELDS },
  llm: { ...BACKEND_COMMON_FIELDS, ...CHAT_ONLY_FIELDS },
};

export const SETTABLE_TARGETS: readonly string[] = ["root", ...BACKEND_IDS];

/** `set mock-model` -> a masked preview for credentials, the raw value otherwise. */
export function formatSettingValue(field: string, target: string, value: unknown): string {
  if (typeof value !== "string") return String(value);
  if (SETTABLE_FIELDS[target as BackendId | "root"]?.[field]?.sensitive === true && !value.startsWith("$")) {
    return maskKey(value);
  }
  return value;
}

function parseFieldValue(target: string, field: string, spec: FieldSpec, raw: string): unknown {
  const value = raw.trim();
  if (value === "") throw new JevError(`${target}.${field} needs a value.`);
  switch (spec.kind) {
    case "string":
      return value;
    case "enum":
      if (!(spec.values ?? []).includes(value)) {
        throw new JevError(`${target}.${field} must be one of ${(spec.values ?? []).join(", ")} (got "${value}").`);
      }
      return value;
    case "number": {
      const parsed = Number(value);
      if (!Number.isFinite(parsed) || parsed < 0) {
        throw new JevError(`${target}.${field} must be a finite number >= 0 (got "${value}").`);
      }
      return parsed;
    }
    case "integer": {
      const parsed = Number(value);
      if (!Number.isInteger(parsed) || parsed < 0) {
        throw new JevError(`${target}.${field} must be a non-negative integer (got "${value}").`);
      }
      return parsed;
    }
  }
}

function fieldsFor(target: string): Record<string, FieldSpec> {
  const table = SETTABLE_FIELDS[target as BackendId | "root"];
  if (table === undefined) {
    throw new JevError(`Unknown target "${target}". Targets: ${SETTABLE_TARGETS.join(", ")}.`);
  }
  return table;
}

/** `/decide set <target> <field> <value>` — validated, then merged into the existing config file. */
export function applySetting(
  root: Record<string, unknown>,
  target: string,
  field: string,
  rawValue: string | undefined,
): Record<string, unknown> {
  // `set backend llm` names the value in the field slot; `set root backend llm` spells it out.
  const selectorShortcut = target === "backend";
  const resolved = selectorShortcut ? "root" : target;
  const resolvedField = selectorShortcut ? "backend" : field;
  const table = fieldsFor(resolved);
  const spec = table[resolvedField];
  if (spec === undefined) {
    throw new JevError(`Unknown field "${resolvedField}" for ${resolved}. Writable: ${Object.keys(table).join(", ")}.`);
  }
  const value = parseFieldValue(resolved, resolvedField, spec, selectorShortcut ? field : (rawValue ?? ""));
  if (resolved === "root") {
    return resolvedField === "backend"
      ? mergeConfig(root, { backend: value as BackendSelection })
      : mergeConfig(root, { rootValues: { [resolvedField]: value } });
  }
  return mergeConfig(root, { backendBlock: { id: resolved as BackendId, values: { [resolvedField]: value } } });
}

/** `/decide unset <target> [field]` — drop one field, or the whole block when no field is given. */
export function clearSetting(root: Record<string, unknown>, target: string, field?: string): Record<string, unknown> {
  const resolved = target === "backend" ? "root" : target;
  fieldsFor(resolved);
  const next: Record<string, unknown> = { ...root };
  if (resolved === "root") {
    const key = target === "backend" ? "backend" : field;
    if (key === undefined) throw new JevError("unset root needs a field name.", { hint: "Writable: backend, modelsPath." });
    delete next[key];
    return next;
  }
  if (field === undefined) {
    delete next[resolved];
    return next;
  }
  const existing = next[resolved];
  if (existing === null || typeof existing !== "object" || Array.isArray(existing)) return next;
  const block = { ...(existing as Record<string, unknown>) };
  delete block[field];
  if (Object.keys(block).length === 0) delete next[resolved];
  else next[resolved] = block;
  return next;
}

/** Merge a patch into a parsed config file without dropping unrelated keys. */
export function mergeConfig(root: Record<string, unknown>, patch: ConfigPatch): Record<string, unknown> {
  const merged: Record<string, unknown> = { ...root };
  if (patch.backend !== undefined) merged.backend = patch.backend;
  if (patch.backendBlock !== undefined) {
    const { id, values } = patch.backendBlock;
    const existing = merged[id];
    const block =
      existing !== null && typeof existing === "object" && !Array.isArray(existing)
        ? { ...(existing as Record<string, unknown>) }
        : {};
    for (const [key, value] of Object.entries(values)) {
      if (value === undefined) delete block[key];
      else block[key] = value;
    }
    merged[id] = block;
  }
  return merged;
}

/** Persist a config file, creating the parent directory. Keys live here, so restrict permissions where the OS supports it. */
export function saveConfigFile(configPath: string, root: Record<string, unknown>): void {
  mkdirSync(dirname(configPath), { recursive: true });
  writeFileSync(configPath, `${JSON.stringify(root, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
  // `mode` only applies at creation and is ignored on Windows; enforce it when the platform supports it.
  if (process.platform !== "win32") chmodSync(configPath, 0o600);
}

interface ResolvedString {
  value: string | undefined;
}

/** Resolve a string field, expanding a leading `$VAR` / `${VAR}` indirection. */
function resolveString(
  raw: unknown,
  configPath: string,
  field: string,
  env: NodeJS.ProcessEnv,
  notes: string[],
): ResolvedString {
  const value = optionalString(raw, configPath, field);
  if (value === undefined) return { value: undefined };
  const braced = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(value);
  const name = braced?.[1] ?? /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value)?.[1];
  if (name === undefined) return { value };
  const resolved = env[name];
  if (resolved?.trim()) return { value: resolved.trim() };
  notes.push(`${field} points at $${name}, which is not set.`);
  return { value: undefined };
}

function optionalString(raw: unknown, configPath: string, field: string): string | undefined {
  if (raw === undefined || raw === null) return undefined;
  if (typeof raw !== "string") throw new JevError(`${configPath}: ${field} must be a string.`);
  const trimmed = raw.trim();
  return trimmed === "" ? undefined : trimmed;
}

function readEnum<T extends string>(
  raw: unknown,
  allowed: readonly T[],
  configPath: string,
  field: string,
): T | undefined {
  const value = optionalString(raw, configPath, field);
  if (value === undefined) return undefined;
  if (!(allowed as readonly string[]).includes(value)) {
    throw new JevError(`${configPath}: ${field} must be one of ${allowed.join(", ")} (got "${value}").`);
  }
  return value as T;
}

function readNumber(
  raw: unknown,
  configPath: string,
  field: string,
  fallback: number,
  bounds: { integer: boolean; min: number },
): number {
  if (raw === undefined || raw === null) return fallback;
  if (typeof raw !== "number" || !Number.isFinite(raw) || raw < bounds.min) {
    throw new JevError(`${configPath}: ${field} must be a finite number >= ${bounds.min}.`);
  }
  if (bounds.integer && !Number.isInteger(raw)) {
    throw new JevError(`${configPath}: ${field} must be an integer.`);
  }
  return raw;
}
