/**
 * App-settings tool family for the AgentsRoom-MCP server.
 *
 * Why a sibling module: app-mcp-server.cjs already carries seven tool
 * families; the settings catalog alone is ~90 entries. Like
 * teamsMcpTools.cjs / memoryMaterializer.cjs, this file lives next to the
 * server in dev (`electron/`) and packaged (flat in `resourcesPath`), so the
 * relative require resolves in both.
 *
 * PRODUCT RULE (do not regress): EVERY setting the desktop exposes must be
 * readable and (unless it is a secret or machine-managed record) writable
 * from here. When a key is added to `AppSettings` in
 * `desktop/src/stores/settingsStore.ts`, it must be added to SETTINGS_SPEC in
 * the same change. `desktop/scripts/check-settings-mcp.mjs` fails the build
 * when the two drift.
 *
 * Storage model:
 *   - Source of truth is the machine settings file (`AR_SETTINGS_FILE`,
 *     i.e. `~/.agentsroom/settings.json`). Reads hit it directly, so they
 *     work with the desktop closed.
 *   - Writes CANNOT just rewrite that file: an open window rewrites the whole
 *     file from its in-memory snapshot on its next save and would silently
 *     revert us. So a write goes through the desktop bridge (`settings:set`),
 *     which does an atomic read-modify-write in the main process AND pushes
 *     the patch into every live window. The direct file write is only used as
 *     a fallback when no desktop is running.
 *
 * Keep this file dependency-free: it ships as-is inside the packaged app.
 */

'use strict';

const fs = require('node:fs');

// ── Enum sources, mirrored from the renderer models ───────────────────────
// Kept as literals (this file must not import TS). check-settings-mcp.mjs
// only enforces key parity, so re-check these lists when a model gains a
// value.

const UI_LANGUAGES = [
  'en', 'fr', 'es', 'pt', 'ru', 'ja', 'de', 'ko',
  'zh', 'zh-TW', 'pl', 'hi', 'tr', 'it', 'ar', 'id', 'vi', 'th', 'uk', 'nl',
];
/**
 * `src/models/composer.ts::SttLanguageId`. No longer derived from UI_LANGUAGES:
 * speech-to-text takes ISO-639-1 codes, so the UI locale `zh-TW` has no STT
 * counterpart (Whisper does not distinguish the two Chinese scripts).
 */
const STT_LANGUAGES = ['auto', ...UI_LANGUAGES.filter((lang) => lang !== 'zh-TW')];
/** `src/models/types.ts::ALL_PROVIDER_IDS` */
const PROVIDER_IDS = [
  'claude', 'codex', 'copilot', 'antigravity', 'opencode',
  'aider', 'grok', 'mistral', 'kimi',
];
/** `src/island/islandTypes.ts::IslandPosition` */
const ISLAND_POSITIONS = [
  'top', 'top-left', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right',
];
/** `src/models/composer.ts::ComposerActionKey` */
const COMPOSER_ACTIONS = [
  'mic', 'voiceMode', 'clear', 'library', 'backlog',
  'sketch', 'screenshot', 'chromeExtension', 'expand',
];

// ── The catalog ───────────────────────────────────────────────────────────
//
// type: boolean | number | string | enum | stringOrNull | numberOrNull |
//       stringArray | object
// secret:   never leaves the machine, neither read nor write.
// readOnly: readable, refused on write (machine-managed records, counters,
//           one-shot funnel flags: they are app state, not user options).
// internal: hidden from `settings_list` unless includeInternal is passed.

const SETTINGS_SPEC = [
  // ── Providers & agents ──────────────────────────────────────────────────
  { key: 'defaultProvider', type: 'enum', values: PROVIDER_IDS, group: 'providers', desc: 'Agent CLI provider used for new agents.' },
  { key: 'claudeCliOptions', type: 'string', group: 'providers', desc: 'Extra CLI flags appended to every Claude call (empty = auto mode).' },
  { key: 'providerCliOptions', type: 'object', group: 'providers', desc: 'Per-provider extra CLI flags, keyed by provider id.' },
  { key: 'cliPresenceOverrides', type: 'object', group: 'providers', desc: 'Per-provider "I already have this CLI installed" overrides (provider id -> boolean).' },
  { key: 'agentPermissionMode', type: 'object', group: 'providers', desc: 'Per-provider permission mode: provider id -> "autonomous" | "manual".' },
  { key: 'codexFastMode', type: 'enum', values: ['inherit', 'on', 'off'], group: 'providers', desc: 'Codex Fast mode policy for every Codex agent: "inherit" respects the user\'s own ~/.codex/config.toml, "on"/"off" force it. Overridable per project and per agent.' },
  { key: 'usageBadgeWindow', type: 'object', group: 'providers', desc: 'Quota window shown in the footer usage badge: provider id -> "session" | "daily" | "weekly" | "monthly" (unset = shortest available).' },
  { key: 'claudeProfiles', type: 'object', group: 'providers', readOnly: true, desc: 'Claude account profiles (managed by the sign-in flow, machine-specific config dirs).' },
  { key: 'defaultClaudeProfileId', type: 'stringOrNull', group: 'providers', desc: 'Claude profile id used for new agents.' },
  { key: 'codexProfiles', type: 'object', group: 'providers', readOnly: true, desc: 'Codex account profiles (managed by the sign-in flow, machine-specific CODEX_HOME).' },
  { key: 'defaultCodexProfileId', type: 'stringOrNull', group: 'providers', desc: 'Codex profile id used for new agents.' },
  { key: 'adaptiveModelEnabled', type: 'boolean', group: 'providers', desc: 'Suggest a better-suited model from the composer draft.' },

  // ── Language ────────────────────────────────────────────────────────────
  { key: 'language', type: 'enum', values: [...UI_LANGUAGES, 'system'], group: 'language', desc: 'App interface language ("system" follows the OS).' },
  { key: 'agentLanguage', type: 'enum', values: [...UI_LANGUAGES, 'ui', 'prompt'], group: 'language', desc: 'Language agents answer in ("ui" = follow the interface, "prompt" = mirror each prompt).' },

  // ── Terminal ────────────────────────────────────────────────────────────
  { key: 'terminalStartupCommand', type: 'string', group: 'terminal', desc: 'Command run automatically at the start of every terminal.' },
  { key: 'terminalFontFamily', type: 'string', group: 'terminal', desc: 'Terminal font id (empty = IBM Plex Mono).' },
  { key: 'terminalThemeOverride', type: 'enum', values: ['auto', 'dark', 'light'], group: 'terminal', desc: 'Terminal theme: follow the app, or force dark/light.' },
  { key: 'terminalGpuAcceleration', type: 'boolean', group: 'terminal', desc: 'Use the WebGL terminal renderer.' },
  { key: 'terminalLinkTarget', type: 'enum', values: ['system', 'app'], group: 'terminal', desc: 'Where links clicked in a terminal open: system browser or embedded browser.' },
  { key: 'alwaysOpenDefaultTerminal', type: 'boolean', group: 'terminal', desc: 'Auto-open the default shell as a bottom tab when a project opens.' },
  { key: 'neverRestoreSessions', type: 'boolean', group: 'terminal', desc: 'Global kill-switch: never offer to resume a previous CLI session.' },
  { key: 'restoreOnExit', type: 'enum', values: ['ask', 'always', 'never'], group: 'terminal', desc: 'Restore the previous layout on launch: ask each time, always, or never.' },
  { key: 'autoLaunchAgentsAtStartup', type: 'boolean', group: 'terminal', desc: 'Allow agents to start by themselves at launch (restored terminals, interrupted team runs, scheduled tasks missed while closed). Off means nothing starts until the user asks.' },
  { key: 'terminalCommandAlerts', type: 'boolean', group: 'terminal', desc: 'Notify when a long dev-command finishes.' },

  // ── Dev tools (dev commands, SSH, commit messages) ──────────────────────
  { key: 'openCommandsInSamePane', type: 'boolean', group: 'devtools', desc: 'Dev commands and SSH connections open as a split of the focused pane instead of a new tab.' },
  { key: 'openCommandsSplitDirection', type: 'enum', values: ['horizontal', 'vertical'], group: 'devtools', desc: 'Split direction used when openCommandsInSamePane is on.' },
  { key: 'restoreDevCommands', type: 'boolean', group: 'devtools', desc: 'Re-launch the dev-command terminals that were running when the app closed. Global default, synced with the user account; a project can override it.' },
  { key: 'commitFormatRules', type: 'string', group: 'devtools', desc: 'Free-form rules the AI commit-message generator must follow. Global default, synced with the user account; a project with its own rules uses those instead.' },
  { key: 'attachConversationToCommit', type: 'boolean', group: 'devtools', desc: 'Attach the agent conversation (gist) to commits.' },
  { key: 'gistConsentGiven', type: 'boolean', group: 'devtools', desc: 'Consent given for uploading conversation gists.' },
  { key: 'autoGitignoreArtifacts', type: 'boolean', group: 'devtools', desc: 'Let the app add its own artifacts to the project .gitignore.' },
  { key: 'tunnelWsUrl', type: 'string', group: 'devtools', desc: 'WebSocket URL of the preview tunnel service.' },
  { key: 'translateServiceUrl', type: 'string', group: 'devtools', desc: 'URL of the translation service.' },
  { key: 'chromeUrlAutoSwitchEnabled', type: 'boolean', group: 'devtools', desc: 'Switch the active project automatically when Chrome navigates to a matching URL.' },

  // ── Composer ────────────────────────────────────────────────────────────
  { key: 'composerHiddenActions', type: 'stringArray', values: COMPOSER_ACTIONS, group: 'composer', desc: 'Composer toolbar actions hidden by the user.' },
  { key: 'composerDefaultHeight', type: 'number', min: 40, max: 600, group: 'composer', desc: 'Max height (px) of the compact composer.' },
  { key: 'composerFontSize', type: 'number', min: 9, max: 32, group: 'composer', desc: 'Composer font size (px).' },
  { key: 'promptShortcutTriggers', type: 'string', group: 'composer', desc: 'Characters that open the prompt autocomplete.' },
  { key: 'regionScreenshotEnabled', type: 'boolean', group: 'composer', desc: 'Enable the global region-screenshot shortcut.' },
  { key: 'regionScreenshotShortcut', type: 'string', group: 'composer', desc: 'Electron accelerator for the region screenshot (e.g. "CommandOrControl+Shift+2").' },
  { key: 'sttLanguage', type: 'enum', values: STT_LANGUAGES, group: 'composer', desc: 'Dictation language ("auto" = detect).' },
  { key: 'dictationSounds', type: 'boolean', group: 'composer', desc: 'Play start/stop cues when dictating.' },
  { key: 'stopMusicWhenRecording', type: 'boolean', group: 'composer', desc: 'Pause music while recording dictation.' },

  // ── Voice mode ──────────────────────────────────────────────────────────
  { key: 'voiceModeAutoSend', type: 'boolean', group: 'voice', desc: 'Hands-free voice mode (send automatically) instead of push-to-talk.' },
  { key: 'voiceModeVoice', type: 'string', group: 'voice', desc: 'Text-to-speech voice id.' },
  { key: 'voiceModeLanguage', type: 'enum', values: STT_LANGUAGES, group: 'voice', desc: 'Spoken language used by voice mode.' },
  { key: 'voiceThinkingBeep', type: 'boolean', group: 'voice', desc: 'Play a "still thinking" beep while the agent works.' },

  // ── Notifications ───────────────────────────────────────────────────────
  { key: 'notificationSound', type: 'boolean', group: 'notifications', desc: 'Play a sound when an agent finishes its turn.' },
  { key: 'needsInputSound', type: 'boolean', group: 'notifications', desc: 'Play a sound when an agent needs input.' },
  { key: 'sendNotificationsToPhone', type: 'boolean', group: 'notifications', desc: 'Mirror notifications to the paired mobile app.' },
  { key: 'relayEnabled', type: 'boolean', group: 'notifications', desc: 'Keep the encrypted relay connection to the mobile app open.' },

  // ── Dynamic Island ──────────────────────────────────────────────────────
  { key: 'dynamicIslandDisplayId', type: 'numberOrNull', group: 'island', desc: 'Display the island is pinned to (null = main display).' },
  { key: 'dynamicIslandPosition', type: 'enum', values: ISLAND_POSITIONS, group: 'island', desc: 'Island anchor position on screen.' },
  { key: 'dynamicIslandAlwaysOn', type: 'boolean', group: 'island', desc: 'Keep the island visible even when idle.' },
  { key: 'dynamicIslandSizePercent', type: 'number', min: 20, max: 300, group: 'island', desc: 'Island size, in percent of its default.' },
  { key: 'dynamicIslandHideVideo', type: 'boolean', group: 'island', desc: 'Hide the island video (keep the rest).' },
  { key: 'dynamicIslandUseLogo', type: 'boolean', group: 'island', desc: 'Show the logo instead of the video.' },
  { key: 'dynamicIslandHideSingleScreen', type: 'boolean', group: 'island', desc: 'Hide the island on single-screen setups.' },
  { key: 'dynamicIslandHideWhenFocused', type: 'boolean', group: 'island', desc: 'Hide the island while the app is focused.' },

  // ── View & navigation ───────────────────────────────────────────────────
  { key: 'projectSidebarCollapsedByDefault', type: 'boolean', group: 'view', desc: 'Start every launch with the projects sidebar collapsed.' },
  { key: 'rightPanelClosedByDefault', type: 'boolean', group: 'view', desc: 'Start every launch with the right panel closed.' },
  { key: 'uiZoom', type: 'number', min: 0.75, max: 2, group: 'view', desc: 'Interface zoom factor (1 = 100%), also stepped by Cmd/Ctrl + and Cmd/Ctrl -.' },
  { key: 'calmRendering', type: 'boolean', group: 'view', desc: 'Calm rendering: stop decorative animation loops (glows, sheens, status pulses). Loaders keep spinning. Cuts GPU usage a lot on large or high-refresh displays.' },
  { key: 'tabCyclesProjectAgents', type: 'boolean', group: 'view', desc: 'Use Tab / Shift+Tab to cycle through a project\'s active agents.' },
  { key: 'projectAgentCycleShortcut', type: 'string', group: 'view', desc: 'Shortcut used to cycle to the next active agent.' },
  { key: 'hideWorkProjects', type: 'boolean', group: 'view', desc: 'Hide projects in the "Work" zone (dev/demo aid).' },

  // ── Backlog AI autofill ─────────────────────────────────────────────────
  { key: 'aiGenerateTitle', type: 'boolean', group: 'backlog', desc: 'Let the AI fill a new ticket\'s title.' },
  { key: 'aiGenerateType', type: 'boolean', group: 'backlog', desc: 'Let the AI fill a new ticket\'s type.' },
  { key: 'aiGenerateAgent', type: 'boolean', group: 'backlog', desc: 'Let the AI pick the agent for a new ticket.' },
  { key: 'aiGenerateTags', type: 'boolean', group: 'backlog', desc: 'Let the AI fill a new ticket\'s tags.' },
  { key: 'aiGenerateModel', type: 'boolean', group: 'backlog', desc: 'Let the AI pick the model for a new ticket.' },

  // ── Hints & guidance ────────────────────────────────────────────────────
  { key: 'smartHintsEnabled', type: 'boolean', group: 'hints', desc: 'Master switch for contextual Smart Hints.' },
  { key: 'cliDoctorAutoEnabled', type: 'boolean', group: 'hints', desc: 'Automatically run the AI diagnosis when an agent CLI fails to launch (sends the redacted failing terminal tail to the AI service); off = diagnosis on click only.' },
  { key: 'dismissedHints', type: 'stringArray', group: 'hints', desc: 'Ids of hints permanently dismissed (empty the list to see them again).' },
  { key: 'walkthroughCompleted', type: 'boolean', group: 'hints', desc: 'Onboarding walkthrough done (set to false to replay it).' },
  { key: 'skipMemorizeOnClose', type: 'boolean', group: 'hints', desc: 'Never show the "memorize this session" dialog on close.' },
  { key: 'userProfile', type: 'object', group: 'hints', desc: 'Onboarding profile: audience and experience level driving the adaptive guide.' },
  { key: 'featureFlags', type: 'object', group: 'hints', desc: 'Per-feature on/off map (Settings > Features).' },

  // ── Account / infrastructure ────────────────────────────────────────────
  { key: 'apiBaseUrl', type: 'string', group: 'account', desc: 'Base URL of the AgentsRoom cloud API.' },
  { key: 'authToken', type: 'string', group: 'account', secret: true, desc: 'Session token (never exposed).' },
  { key: 'refreshToken', type: 'string', group: 'account', secret: true, desc: 'Refresh token (never exposed).' },
  { key: 'mobilePushToken', type: 'string', group: 'account', secret: true, desc: 'Device push token (never exposed).' },

  // ── App state / telemetry counters (read-only) ──────────────────────────
  { key: 'appLaunchCount', type: 'number', group: 'state', readOnly: true, internal: true, desc: 'Number of app launches.' },
  { key: 'firstLaunchAt', type: 'stringOrNull', group: 'state', readOnly: true, internal: true, desc: 'ISO date of the first launch.' },
  { key: 'firstOpenAt', type: 'numberOrNull', group: 'state', readOnly: true, internal: true, desc: 'Timestamp (ms) of the very first launch.' },
  { key: 'trialNudgeLastShownAt', type: 'numberOrNull', group: 'state', readOnly: true, internal: true, desc: 'Timestamp (ms) of the last trial nudge.' },
  { key: 'lastSeenAppVersion', type: 'stringOrNull', group: 'state', readOnly: true, internal: true, desc: 'Last app version this install ran.' },
  { key: 'earlyExitSurveyShown', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'Early-exit survey already shown.' },
  { key: 'composerBacklogSeeded', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'One-time seed marker for the composer backlog action.' },
  { key: 'firstAppOpenTracked', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'Onboarding funnel flag.' },
  { key: 'firstWalkthroughCompletedTracked', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'Onboarding funnel flag.' },
  { key: 'firstProjectOpenedTracked', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'Onboarding funnel flag.' },
  { key: 'firstAgentOpenedTracked', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'Onboarding funnel flag.' },
  { key: 'firstProviderDetectedTracked', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'Onboarding funnel flag.' },
  { key: 'firstDemoGeneratedTracked', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'Onboarding funnel flag.' },
  { key: 'firstPromptSentTracked', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'Onboarding funnel flag.' },
  { key: 'firstAgentRepliedTracked', type: 'boolean', group: 'state', readOnly: true, internal: true, desc: 'Onboarding funnel flag.' },
  { key: 'onboardingVariant', type: 'stringOrNull', group: 'state', desc: "First-run path assigned to this install: 'guided' (tutorials) or 'short' (launchpad hint). Set it to force a variant when testing." },
  { key: 'firstRunHintDismissed', type: 'boolean', group: 'state', internal: true, desc: 'Whether the first-run launchpad hint was dismissed.' },
  { key: 'agentTurnsCompleted', type: 'number', group: 'state', readOnly: true, internal: true, desc: 'Completed agent turns on this install.' },
  { key: 'accountPromptCount', type: 'number', group: 'state', readOnly: true, internal: true, desc: 'How many times the account proposal was shown.' },
  { key: 'accountPromptLastShownAt', type: 'numberOrNull', group: 'state', readOnly: true, internal: true, desc: 'When the account proposal was last shown (epoch ms).' },
  { key: 'usedFeatures', type: 'stringArray', group: 'state', readOnly: true, internal: true, desc: 'Features used at least once.' },
  { key: 'hintImpressions', type: 'object', group: 'state', readOnly: true, internal: true, desc: 'Impression counters per hint rule.' },
  { key: 'tooltipUsage', type: 'object', group: 'state', readOnly: true, internal: true, desc: 'Per-action counters behind the "learned" tooltips.' },
];

const SPEC_BY_KEY = new Map(SETTINGS_SPEC.map((s) => [s.key, s]));

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

function typeLabel(spec) {
  if (spec.type === 'enum') return `one of: ${spec.values.join(', ')}`;
  if (spec.type === 'stringArray') {
    return spec.values ? `array of: ${spec.values.join(', ')}` : 'array of strings';
  }
  if (spec.type === 'stringOrNull') return 'string or null';
  if (spec.type === 'numberOrNull') return 'number or null';
  if (spec.type === 'number' && (spec.min !== undefined || spec.max !== undefined)) {
    return `number between ${spec.min ?? '-inf'} and ${spec.max ?? '+inf'}`;
  }
  return spec.type;
}

/** @returns {{ok: true, value: any} | {ok: false, error: string}} */
function validateValue(spec, value) {
  const bad = () => ({ ok: false, error: `invalid value for "${spec.key}": expected ${typeLabel(spec)}` });
  switch (spec.type) {
    case 'boolean':
      return typeof value === 'boolean' ? { ok: true, value } : bad();
    case 'number':
      if (typeof value !== 'number' || !Number.isFinite(value)) return bad();
      if (spec.min !== undefined && value < spec.min) return bad();
      if (spec.max !== undefined && value > spec.max) return bad();
      return { ok: true, value };
    case 'string':
      return typeof value === 'string' ? { ok: true, value } : bad();
    case 'enum':
      return typeof value === 'string' && spec.values.includes(value) ? { ok: true, value } : bad();
    case 'stringOrNull':
      return value === null || typeof value === 'string' ? { ok: true, value } : bad();
    case 'numberOrNull':
      if (value === null) return { ok: true, value };
      return typeof value === 'number' && Number.isFinite(value) ? { ok: true, value } : bad();
    case 'stringArray': {
      if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) return bad();
      if (spec.values && value.some((v) => !spec.values.includes(v))) return bad();
      return { ok: true, value };
    }
    case 'object':
      return value && typeof value === 'object' && !Array.isArray(value)
        ? { ok: true, value }
        : bad();
    default:
      return bad();
  }
}

// ── Tool declarations ─────────────────────────────────────────────────────

const SETTINGS_TOOLS = [
  {
    name: 'settings_list',
    description:
      'List every AgentsRoom desktop setting with its current value, type and accepted values. '
      + 'Use it BEFORE settings_set to discover the exact key name and the value shape, and to '
      + 'answer questions like "how is the app configured?". Optional `group` narrows the output '
      + '(providers, language, terminal, devtools, composer, voice, notifications, island, view, '
      + 'backlog, hints, account, state). Secrets (tokens) are never returned.',
    inputSchema: {
      type: 'object',
      properties: {
        group: { type: 'string', description: 'Only return settings of this group.' },
        includeInternal: { type: 'boolean', description: 'Include app-state counters and one-shot flags (default false).' },
      },
    },
  },
  {
    name: 'settings_get',
    description:
      'Read one AgentsRoom desktop setting: its current value, its default-ness, its type and '
      + 'whether it can be written. Call settings_list first if unsure about the key name.',
    inputSchema: {
      type: 'object',
      properties: { key: { type: 'string', description: 'Setting key, e.g. "openCommandsInSamePane".' } },
      required: ['key'],
    },
  },
  {
    name: 'settings_set',
    description:
      'Change one AgentsRoom desktop setting. The change applies immediately to every open '
      + 'window and persists to the machine settings file. ETIQUETTE: this rewires the user\'s '
      + 'whole app, so apply the GOLDEN RULE: an explicit instruction ("switch the app to dark '
      + 'terminals", "open dev commands in the same pane") means act; your own idea means ask '
      + 'once and drop it if declined. Never flip privacy, account or provider settings on your '
      + 'own initiative. `value` must match the type reported by settings_list.',
    inputSchema: {
      type: 'object',
      properties: {
        key: { type: 'string', description: 'Setting key, e.g. "terminalThemeOverride".' },
        value: { description: 'New value, matching the type reported by settings_list (boolean, number, string, array or object).' },
      },
      required: ['key', 'value'],
    },
  },
];

// ── Implementation ────────────────────────────────────────────────────────

/**
 * @param {object} deps
 * @param {string} deps.settingsFile Absolute path of the machine settings file.
 * @param {(op: string, params: object) => Promise<any>} deps.bridgeCall Desktop bridge call.
 * @param {boolean} deps.bridgeAvailable Whether AR_BRIDGE_FILE was provided.
 */
function createSettingsTools({ settingsFile, bridgeCall, bridgeAvailable }) {
  const readSettings = () => {
    try {
      const parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
      return parsed && typeof parsed === 'object' ? parsed : {};
    } catch (_) {
      return {};
    }
  };

  const describe = (spec, stored) => ({
    key: spec.key,
    group: spec.group,
    type: typeLabel(spec),
    value: Object.prototype.hasOwnProperty.call(stored, spec.key) ? stored[spec.key] : null,
    isSet: Object.prototype.hasOwnProperty.call(stored, spec.key),
    writable: !spec.readOnly,
    description: spec.desc,
  });

  const toolSettingsList = async (args = {}) => {
    const stored = readSettings();
    const group = typeof args.group === 'string' ? args.group : null;
    const rows = SETTINGS_SPEC
      .filter((s) => !s.secret)
      .filter((s) => (args.includeInternal ? true : !s.internal))
      .filter((s) => (group ? s.group === group : true))
      .map((s) => describe(s, stored));
    if (group && rows.length === 0) {
      const groups = [...new Set(SETTINGS_SPEC.filter((s) => !s.secret).map((s) => s.group))];
      return [{ type: 'text', text: `unknown group "${group}". Known groups: ${groups.join(', ')}` }];
    }
    return [{ type: 'text', text: JSON.stringify({ count: rows.length, settings: rows }, null, 2) }];
  };

  const toolSettingsGet = async (args = {}) => {
    const key = typeof args.key === 'string' ? args.key.trim() : '';
    if (!key) return [{ type: 'text', text: 'key is required' }];
    const spec = SPEC_BY_KEY.get(key);
    if (!spec) return [{ type: 'text', text: `unknown setting "${key}". Call settings_list to see the catalog.` }];
    if (spec.secret) return [{ type: 'text', text: `"${key}" is a secret and is never exposed.` }];
    return [{ type: 'text', text: JSON.stringify(describe(spec, readSettings()), null, 2) }];
  };

  const toolSettingsSet = async (args = {}) => {
    const key = typeof args.key === 'string' ? args.key.trim() : '';
    if (!key) return [{ type: 'text', text: 'key is required' }];
    if (!Object.prototype.hasOwnProperty.call(args, 'value')) {
      return [{ type: 'text', text: 'value is required' }];
    }
    const spec = SPEC_BY_KEY.get(key);
    if (!spec) return [{ type: 'text', text: `unknown setting "${key}". Call settings_list to see the catalog.` }];
    if (spec.secret) return [{ type: 'text', text: `"${key}" is a secret and cannot be written from here.` }];
    if (spec.readOnly) {
      return [{ type: 'text', text: `"${key}" is read-only (app state / machine-managed record), it can only be read.` }];
    }
    const checked = validateValue(spec, args.value);
    if (!checked.ok) return [{ type: 'text', text: checked.error }];

    const previous = readSettings()[key] ?? null;

    // The desktop must own the write: an open window rewrites the whole
    // settings file from memory on its next save and would revert a direct
    // file write. Only fall back to the file when there is no desktop.
    if (bridgeAvailable) {
      try {
        const result = await bridgeCall('settings:set', { key, value: checked.value });
        if (result && result.ok === false) {
          return [{ type: 'text', text: `settings_set failed: ${result.error || 'unknown error'}` }];
        }
        return [{
          type: 'text',
          text: JSON.stringify({
            ok: true, key, previous, value: checked.value, appliedTo: 'desktop + settings file',
            message: `"${key}" is now ${JSON.stringify(checked.value)}. The change is live in the app.`,
          }, null, 2),
        }];
      } catch (err) {
        // Desktop closed or bridge stale: fall through to the file write,
        // which is authoritative when no window can overwrite it.
        if (!/bridge unavailable|ECONNREFUSED|not registered/i.test(String(err && err.message))) {
          return [{ type: 'text', text: `settings_set failed: ${err.message}` }];
        }
      }
    }

    const written = writeSettingKeyToFile(settingsFile, key, checked.value);
    if (!written.ok) return [{ type: 'text', text: `settings_set failed: ${written.error}` }];
    return [{
      type: 'text',
      text: JSON.stringify({
        ok: true, key, previous, value: checked.value, appliedTo: 'settings file (desktop not running)',
        message: `"${key}" is now ${JSON.stringify(checked.value)}. It will be picked up the next time AgentsRoom starts.`,
      }, null, 2),
    }];
  };

  return { toolSettingsList, toolSettingsGet, toolSettingsSet };
}

/** Atomic read-modify-write of a single key (fallback path, desktop closed). */
function writeSettingKeyToFile(settingsFile, key, value) {
  try {
    let current = {};
    try {
      const parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
      if (parsed && typeof parsed === 'object') current = parsed;
    } catch (_) { /* first write */ }
    const next = { ...current, [key]: value };
    const tmp = `${settingsFile}.tmp`;
    fs.writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf-8');
    fs.renameSync(tmp, settingsFile);
    return { ok: true };
  } catch (err) {
    return { ok: false, error: err.message };
  }
}

module.exports = {
  SETTINGS_SPEC,
  SETTINGS_TOOLS,
  createSettingsTools,
  validateValue,
};
