import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { randomBytes } from 'node:crypto';
import YAML from 'yaml';
import { encryptSecret, decryptSecret, isEncrypted, SECRET_FIELDS } from './crypto';
import { getDataDir } from './dirs';

const DATA_DIR = getDataDir();
const SETTINGS_FILE = join(DATA_DIR, 'settings.yaml');

/**
 * AgentEntry — a CLI agent configuration (one CLI binary + its env / model).
 *
 * As of v0.9.20 the agents dict is CLI-only; API endpoints live in the
 * sibling `apiProfiles` dict. The `tool` field is the discriminator
 * (which CLI binary to invoke). Multiple entries with the same `tool`
 * are allowed (used to be "CLI profiles" — now just additional named
 * agents with their own env).
 *
 * Deprecated fields (`base` / `cliType` / `type` / `provider` / `apiKey`
 * / `baseUrl`) are migrated away by migrateAgentsFlatten on first load
 * and removed from the schema in a later release.
 */
export interface AgentEntry {
  tool?: 'claude' | 'codex' | 'aider' | 'opencode';   // which CLI binary
  path?: string; name?: string; enabled?: boolean;
  flags?: string[]; taskFlags?: string; resumeFlag?: string; outputFormat?: string;
  models?: { terminal?: string; task?: string; telegram?: string; help?: string; mobile?: string };
  skipPermissionsFlag?: string;
  requiresTTY?: boolean;
  env?: Record<string, string>;  // environment variables injected when spawning CLI

  // === Deprecated (kept for migration compat; removed in a later release) ===
  /** @deprecated 顶层 model 被 models.terminal/task/... 子表替代,migrate.ts 自动清掉 */
  model?: string;
  /** @deprecated migrated to `tool` */
  base?: string;
  /** @deprecated migrated to `tool` */
  cliType?: 'claude-code' | 'codex' | 'aider' | 'generic';
  /** @deprecated 'api' entries migrated to apiProfiles dict */
  type?: 'cli' | 'api';
  /** @deprecated moved to apiProfiles entry */
  provider?: string;
  /** @deprecated moved to apiProfiles entry */
  apiKey?: string;
  /** @deprecated moved to apiProfiles entry */
  baseUrl?: string;
  /** @deprecated unused */
  profile?: string;
}

/**
 * ApiProfile — a direct HTTP LLM endpoint (anthropic / openai-compat).
 *
 * Used by chat / summarizer / telegram chat surface — anything that
 * needs a single-shot or streaming LLM call without a subprocess.
 * Stored in `settings.apiProfiles`, not `settings.agents`. The two
 * dicts never cross: tasks/pipelines/terminal pick from agents,
 * chat/summarizer pick from apiProfiles. UI enforces the boundary.
 */
export interface ApiProfile {
  name?: string;
  enabled?: boolean;
  provider: 'anthropic' | 'openai-compatible';
  model: string;
  apiKey: string;
  baseUrl?: string;
  /**
   * Total input-token ceiling for chat. Includes system prompt + connector
   * catalog + memory context (summary + Temper recall) + history. Forge
   * dynamically trims (memory tail dropped first, then oldest history)
   * to keep the assembled prompt under this. Defaults to 200k (Claude
   * Code's working window) when unset; lower it (e.g. 12000) for older
   * 16k-context models that would otherwise truncate server-side.
   */
  maxInputTokens?: number;
}

/**
 * External MCP server configuration — merged into per-project .mcp.json
 * alongside the built-in `forge` entry. Format mirrors the MCP standard.
 */
export interface McpServerConfig {
  type: 'stdio' | 'sse' | 'streamable-http';
  // stdio
  command?: string;
  args?: string[];
  env?: Record<string, string>;
  // sse / streamable-http
  url?: string;
  headers?: Record<string, string>;
}

export interface Settings {
  projectRoots: string[];
  docRoots: string[];
  claudePath: string;
  // claudeHome removed in 0.10.10 — was a never-UI-exposed override that
  // mostly served to leak `claudeHome: ""` into every yaml. Set the
  // CLAUDE_HOME env var if you need to point at a non-default ~/.claude.
  telegramBotToken: string;
  telegramChatId: string;
  notifyOnComplete: boolean;
  notifyOnFailure: boolean;
  tunnelAutoStart: boolean;
  telegramTunnelPassword: string;
  skipPermissions: boolean;
  manageClaudeConfig: boolean;
  notificationRetentionDays: number;
  skillsRepoUrl: string;
  /**
   * Remote registry for connector manifests. Points to the raw-content
   * URL of a forge-connectors-style repo. Setting empty disables sync
   * (offline only). Default: `aiwatching/forge-connectors`.
   */
  connectorsRepoUrl: string;
  /**
   * Remote registry for workflow templates (recipes + pipelines). Same
   * shape as connectorsRepoUrl. Default: `aiwatching/forge-workflow`.
   */
  workflowRepoUrl: string;
  /**
   * Base URL for the `forge-public-info` repo. Houses model lists and
   * other small JSON files that the maintainer updates between npm
   * releases — push a JSON commit, all users pick it up within 24h via
   * the cache. Sub-paths (e.g. `models/registry.json`) are appended by
   * `lib/public-info/fetch.ts`. Default: `aiwatching/forge-public-info`.
   */
  publicInfoRepoUrl: string;
  /**
   * Maximum concurrent pipeline runs (running + pending). When a Job's
   * scheduler tick would push the total above this, additional items
   * are deferred to the next tick instead of dispatched.
   * Default 5; ceiling 20 enforced in scheduler.
   */
  maxConcurrentPipelines: number;
  displayName: string;
  displayEmail: string;
  /** User profile — company + dept. Auto-filled by the wizard when the
   *  user picks an enterprise template (company = tenant display name,
   *  dept = chosen department's display name). Editable in Settings →
   *  Identity. Changing `dept` re-applies the matching dept template
   *  to rotate dept-flavored connector defaults (default_project_path,
   *  jenkins instance, etc.). */
  company: string;
  dept: string;
  favoriteProjects: string[];
  defaultAgent: string;
  telegramAgent: string;
  docsAgent: string;
  /** Default API agent profile used by Forge's chat backend (extension / web / CLI / Telegram /chat). */
  chatAgent: string;
  /**
   * Temper memory service — long-term memory backend for the chat agent.
   * When url + key are both set, chat fetches pinned blocks / search hits
   * on every turn and exposes memory_* tools to the LLM.
   */
  temperUrl: string;
  temperKey: string;
  /** Optional namespace override (empty = use the key's default namespace). */
  temperNamespace: string;
  /**
   * Which memory backend the chat agent uses.
   *   'auto'   — Temper if URL+key are set, otherwise local SQLite (default).
   *   'local'  — always local SQLite, even if Temper credentials are filled.
   *   'temper' — always Temper; if credentials are missing, memory is disabled.
   */
  memoryBackend: 'auto' | 'local' | 'temper';
  agents: Record<string, AgentEntry>;
  /** API endpoint profiles — chat / summarizer / telegram-chat target.
   *  Separate from `agents` (CLI subprocess) so type guard is structural. */
  apiProfiles: Record<string, ApiProfile>;
  /** Extra MCP servers merged into each project's .mcp.json (chrome-devtools-mcp etc.) */
  mcpServers: Record<string, McpServerConfig>;
  /**
   * Preferred timezone for rendering timestamps in the UI. Format: IANA
   * (e.g. "America/Los_Angeles", "Asia/Shanghai", "Europe/Berlin").
   * Empty = use the OS / browser default (Intl.DateTimeFormat().resolvedOptions().timeZone).
   * Used by the extension's date formatter for chat / jobs / pipeline run timestamps.
   */
  timezone: string;
  /**
   * SMTP transport for Schedule action=email (and any future email-out
   * paths). Flat fields so smtpPassword fits SECRET_FIELDS encryption.
   * Empty smtpHost disables the email channel.
   *   smtpSecure: true → implicit TLS (port 465).
   *                false + port 587 → STARTTLS.
   *   smtpFrom: e.g. "Forge <noreply@example.com>"; falls back to smtpUser.
   */
  smtpHost: string;
  smtpPort: number;
  smtpSecure: boolean;
  smtpUser: string;
  smtpPassword: string;
  smtpFrom: string;
  /**
   * Enterprise unified-login (EnterpriseBadge) — opt-in credential cache
   * so the user doesn't retype every session. OTP is never saved.
   *   idpSavedUsername — plaintext (not really secret; already visible as email)
   *   idpSavedPassword — encrypted via SECRET_FIELDS (AES-256-GCM)
   */
  idpSavedUsername: string;
  idpSavedPassword: string;
  /**
   * Pipeline tmp dir GC — controls retention of `<project>/worktrees/pipeline-<id>/`
   * directories that nodes write scratch files to via `{{run.tmp_dir}}`.
   *   pipelineTmpCleanDoneImmediate: true (default) → wipe immediately when pipeline status flips to 'done'
   *   pipelineTmpKeepFailedDays / pipelineTmpKeepCancelledDays: keep these many days, then sweep
   *   pipelineTmpGcIntervalHours: how often the background sweep runs (clamped to >= 1h)
   */
  pipelineTmpCleanDoneImmediate: boolean;
  pipelineTmpKeepFailedDays: number;
  pipelineTmpKeepCancelledDays: number;
  pipelineTmpGcIntervalHours: number;
  /**
   * First-run onboarding wizard. False (or absent) → Forge shows the
   * wizard on every launch until the user completes it or skips. True
   * → silent. Settings → "Re-run Onboarding" sets it back to false.
   */
  onboardingCompleted: boolean;
  /**
   * Last successful GitLab SSH 2FA verification (`ssh git@<host> 2fa_verify`).
   * Stamped by /api/auth/gitlab-2fa on POST. UI uses verifiedAt + a
   * client-side 1h validity window (GitLab's default) to show "47m left"
   * on the Enterprise dropdown.
   */
  gitlab2fa?: {
    verifiedAt: string;     // ISO timestamp
    host: string;           // hostname we verified against (e.g. dops-git106.fortinet-us.com)
  };
}

const defaults: Settings = {
  projectRoots: [],
  docRoots: [],
  claudePath: '',
  // claudeHome intentionally omitted — it's an optional override (most users
  // have claude at ~/.claude). Keeping it as a default empty string used to
  // leak `claudeHome: ""` into every saved settings.yaml, which the dirs.ts
  // regex then mis-parsed as the value `"`. See loadSettings normalizer +
  // dirs.ts:getClaudeDir for the read-side handling.
  telegramBotToken: '',
  telegramChatId: '',
  notifyOnComplete: true,
  notifyOnFailure: true,
  tunnelAutoStart: false,
  telegramTunnelPassword: '',
  skipPermissions: false,
  manageClaudeConfig: true,
  notificationRetentionDays: 30,
  skillsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-skills/main',
  connectorsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-connectors/main',
  workflowRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-workflow/main',
  publicInfoRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-public-info/main',
  maxConcurrentPipelines: 5,
  displayName: 'Forge',
  displayEmail: '',
  company: '',
  dept: '',
  favoriteProjects: [],
  defaultAgent: 'claude',
  telegramAgent: '',
  docsAgent: '',
  chatAgent: '',
  temperUrl: '',
  temperKey: '',
  temperNamespace: '',
  memoryBackend: 'auto',
  agents: {},
  apiProfiles: {},
  mcpServers: {},
  timezone: '',
  smtpHost: '',
  smtpPort: 587,
  smtpSecure: false,
  smtpUser: '',
  smtpPassword: '',
  smtpFrom: '',
  idpSavedUsername: '',
  idpSavedPassword: '',
  pipelineTmpCleanDoneImmediate: true,
  pipelineTmpKeepFailedDays: 3,
  pipelineTmpKeepCancelledDays: 3,
  pipelineTmpGcIntervalHours: 6,
  onboardingCompleted: false,
};

/** Decrypt nested apiKey fields in agents (legacy migration window) +
 *  apiProfiles (current canonical location). */
function decryptNestedSecrets(settings: Settings): void {
  // Legacy: agent entries may still carry apiKey until migration runs
  if (settings.agents) {
    for (const [id, a] of Object.entries(settings.agents)) {
      if (a.apiKey && isEncrypted(a.apiKey)) {
        a.apiKey = decryptSecret(a.apiKey);
      }
      if (a.apiKey && /^•+$/.test(a.apiKey)) {
        console.warn(`[settings] agent '${id}' has a placeholder apiKey — clearing. Re-enter it via Settings.`);
        a.apiKey = '';
      }
    }
  }
  if (settings.apiProfiles) {
    for (const [id, p] of Object.entries(settings.apiProfiles)) {
      if (p.apiKey && isEncrypted(p.apiKey)) {
        p.apiKey = decryptSecret(p.apiKey);
      }
      if (p.apiKey && /^•+$/.test(p.apiKey)) {
        console.warn(`[settings] apiProfile '${id}' has a placeholder apiKey — clearing. Re-enter it via Settings.`);
        p.apiKey = '';
      }
    }
  }
}

/** Encrypt nested apiKey fields */
function encryptNestedSecrets(settings: Settings): void {
  if (settings.agents) {
    for (const a of Object.values(settings.agents)) {
      if (a.apiKey && !isEncrypted(a.apiKey)) {
        a.apiKey = encryptSecret(a.apiKey);
      }
    }
  }
  if (settings.apiProfiles) {
    for (const p of Object.values(settings.apiProfiles)) {
      if (p.apiKey && !isEncrypted(p.apiKey)) {
        p.apiKey = encryptSecret(p.apiKey);
      }
    }
  }
}

/** Load settings with secrets decrypted (for internal use) */
export function loadSettings(): Settings {
  if (!existsSync(SETTINGS_FILE)) return { ...defaults };
  try {
    const raw = readFileSync(SETTINGS_FILE, 'utf-8');
    const parsed: any = { ...defaults, ...YAML.parse(raw) };
    // claudeHome was removed from the schema in 0.10.10. Drop any leftover
    // value so it stops round-tripping through saveSettings. If a user had
    // set a real override (rare — UI never exposed the field), surface a
    // one-time warning pointing them at the CLAUDE_HOME env var.
    if ('claudeHome' in parsed) {
      const old = parsed.claudeHome;
      delete parsed.claudeHome;
      if (old && old !== '') {
        console.warn(
          `[settings] claudeHome="${old}" is no longer read. ` +
          `If you need to override the claude config dir, set CLAUDE_HOME env var instead.`,
        );
      }
    }
    // taskModel / pipelineModel / telegramModel were removed: no UI ever
    // exposed them, and the resolution chain now goes per-task override
    // → agent scene model → agent default. Drop leftover keys; warn when
    // they held a non-default value so the user knows their override
    // stopped applying.
    for (const k of ['taskModel', 'pipelineModel', 'telegramModel'] as const) {
      if (k in parsed) {
        const old = parsed[k];
        delete parsed[k];
        if (old && old !== '' && old !== 'default' && old !== 'sonnet') {
          console.warn(
            `[settings] ${k}="${old}" is no longer read. ` +
            `Set the model on the relevant agent (settings.agents.<id>.models.task) instead.`,
          );
        }
      }
    }
    // Decrypt top-level secret fields
    for (const field of SECRET_FIELDS) {
      if (parsed[field] && isEncrypted(parsed[field])) {
        parsed[field] = decryptSecret(parsed[field]);
      }
    }
    // Decrypt nested apiKeys
    decryptNestedSecrets(parsed);
    return parsed;
  } catch {
    return { ...defaults };
  }
}

/** Load settings with secrets masked (for API response to frontend) */
export function loadSettingsMasked(): Settings & { _secretStatus: Record<string, boolean> } {
  const settings = loadSettings();
  const status: Record<string, boolean> = {};
  for (const field of SECRET_FIELDS) {
    status[field] = !!settings[field];
    settings[field] = settings[field] ? '••••••••' : '';
  }
  // Mask nested apiKeys — agents (legacy) + apiProfiles (canonical)
  if (settings.agents) {
    for (const [name, a] of Object.entries(settings.agents)) {
      if (a.apiKey) {
        status[`agents.${name}.apiKey`] = true;
        a.apiKey = '••••••••';
      }
    }
  }
  if (settings.apiProfiles) {
    for (const [name, p] of Object.entries(settings.apiProfiles)) {
      if (p.apiKey) {
        status[`apiProfiles.${name}.apiKey`] = true;
        p.apiKey = '••••••••';
      }
    }
  }
  return { ...settings, _secretStatus: status };
}

/** Save settings, encrypting secret fields */
export function saveSettings(settings: Settings) {
  const dir = dirname(SETTINGS_FILE);
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  // Deep copy to avoid mutating original
  const toSave = JSON.parse(JSON.stringify(settings));
  // Encrypt top-level secret fields
  for (const field of SECRET_FIELDS) {
    if (toSave[field] && !isEncrypted(toSave[field])) {
      toSave[field] = encryptSecret(toSave[field]);
    }
  }
  // Encrypt nested apiKeys
  encryptNestedSecrets(toSave);
  // Atomic write: serialize to a temp file in the same dir, then rename over the
  // target so a crash or concurrent reader never sees a half-written settings.yaml.
  const tmp = join(dir, `.settings.${randomBytes(6).toString('hex')}.tmp`);
  try {
    writeFileSync(tmp, YAML.stringify(toSave), 'utf-8');
    renameSync(tmp, SETTINGS_FILE);
  } catch (e) {
    try { rmSync(tmp, { force: true }); } catch { /* leave nothing behind */ }
    throw e;
  }
}

/** Verify a secret field's current value */
export function verifySecret(field: string, value: string): boolean {
  const settings = loadSettings();
  const current = (settings as any)[field] || '';
  return current === value;
}
