/**
 * Teams + saved-agents tool family for the AgentsRoom-MCP server.
 *
 * Why a sibling module: app-mcp-server.cjs already carries five tool families
 * and adding a sixth inline would push it well past readable size. Like
 * memoryMaterializer.cjs, this file lives next to the server in dev
 * (`electron/`) and packaged (flat in `resourcesPath`), so the relative
 * require resolves in both.
 *
 * What it covers (feature request "read and manage teams over MCP"):
 *   - teams_list / teams_get   : full read access to team definitions,
 *     INCLUDING the connections between nodes (edges with conditions and
 *     labels), which previously had no agent-readable path at all.
 *   - teams_save / teams_delete: create, update and delete teams. The graph
 *     is validated with the same rules as the desktop editor (ported from
 *     desktop/src/models/teams.ts::getTeamIssues) so an agent cannot persist
 *     a team the runner could not execute; non-blocking findings (a condition
 *     no step can ever satisfy, a node with no fallback edge) come back as
 *     `warnings` on the successful save.
 *   - agents_list / agents_get : read-only saved agent configurations
 *     (name, provider, model, account profile, color, skills, worktree,
 *     capabilities, system prompt).
 *
 * Storage model mirrors the desktop exactly (server is source of truth):
 *   - Project teams: /api/project-teams row per (user, repoProjectId); the
 *     local offline cache `.agentsroom/teams-cache.json` is refreshed after
 *     every write so the desktop UI picks the change up on its next Teams
 *     panel open (same freshness contract as prompts / skills / commands).
 *   - Global teams: /api/user-teams row per user. The desktop reconciles its
 *     `~/.agentsroom/teams/` offline cache against that row on every load
 *     (updatedAt comparison), so server-side writes propagate without this
 *     process touching any local file.
 *   - Seeded templates never hit the wire (machine-local by design), so they
 *     are invisible to these tools; users duplicate them in the desktop UI.
 *
 * Keep this file dependency-free: it ships as-is inside the packaged app.
 */

'use strict';

// ── Constants mirrored from desktop/src/models/teams.ts ──────────────────────

const DEFAULT_MAX_CYCLES = 3;
const DEFAULT_CHECK_FLAG = 'checkPassed';
const NODE_KINDS = ['start', 'end', 'agent', 'group'];
const HANDOFF_MODES = ['auto', 'manual'];

// Auto-layout for nodes provided without a canvas position: simple left→right
// chain matching the ~360px spacing used by the seeded templates, so a team
// authored by an agent (which has no reason to think in pixels) still renders
// readable in the React Flow editor.
const LAYOUT_X_START = 120;
const LAYOUT_X_STEP = 360;
const LAYOUT_Y = 200;

// Visual defaults for group nodes created without dimensions.
const GROUP_DEFAULT_WIDTH = 360;
const GROUP_DEFAULT_HEIGHT = 240;
const GROUP_DEFAULT_LABEL = 'Group';

function nanoTeamId() {
  return `team-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function nanoEdgeId() {
  return `e-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
}

// ── Tool schemas ──────────────────────────────────────────────────────────────

const TEAM_NODE_SCHEMA = {
  type: 'object',
  properties: {
    id: { type: 'string', description: 'Unique node id, referenced by edges (e.g. "dev", "qa"). Required.' },
    kind: { type: 'string', enum: ['start', 'end', 'agent', 'group'], description: 'Node type. A valid team has exactly one "start", at least one "end" and at least one "agent". "group" is a purely visual box.' },
    role: { type: 'string', description: 'Agent nodes only (required): canonical role id, one of fullstack, frontend, backend, architect, qa, devops, security, mobile, pm, marketing, git, seo, i18n, brainstormer, custom.' },
    customName: { type: 'string', description: 'Agent nodes: display name shown on the node card and the agent tile.' },
    provider: { type: 'string', description: 'Agent nodes: optional CLI provider pin (e.g. "claude", "codex", "opencode"). Omit to inherit whatever the run\'s agent is executing.' },
    model: { type: 'string', description: 'Agent nodes: optional model pin. Omit to inherit.' },
    customSystemPrompt: { type: 'string', description: 'Agent nodes: persona appended to the role\'s base system prompt for this step (it does NOT replace it).' },
    customColor: { type: 'string', description: 'Agent nodes: hex colour (e.g. "#ec4899") overriding the role default on the node card and the agent tile.' },
    claudeProfileId: { type: 'string', description: 'Agent nodes: Claude account profile to run this step under. Machine-local id: unresolvable on another machine, where it falls back to the project/global default.' },
    codexProfileId: { type: 'string', description: 'Agent nodes: Codex account profile to run this step under. Machine-local id, same fallback as claudeProfileId.' },
    reasoningEffort: { type: 'string', enum: ['low', 'medium', 'high', 'xhigh', 'max'], description: 'Agent nodes: reasoning effort for providers exposing the lever (Codex maps it to `model_reasoning_effort`). Omit to inherit the effort the run is already using: only an explicit pin changes it, and a step that pins a different one is relaunched (there is no in-session command for this setting). A model id can carry its own effort ("gpt-5.6-sol-max" runs at max), in which case this field wins.' },
    stepInstructions: { type: 'string', description: 'Agent nodes: extra per-step instructions (e.g. "Focus on E2E tests").' },
    handoffMode: { type: 'string', enum: ['auto', 'manual'], description: 'Agent nodes: "auto" (default) hands off when the agent calls team_complete_step; "manual" waits for the user to click Hand off.' },
    verifyInBrowser: { type: 'boolean', description: 'Agent nodes: allow this step to drive the embedded browser (QA verification).' },
    browserUrl: { type: 'string', description: 'Agent nodes: URL hint surfaced to a browser-enabled step.' },
    skills: { type: 'array', items: { type: 'string' }, description: 'Agent nodes: Skills Library entry NAMES to load (skills_get) before starting the step.' },
    checkCommand: { type: 'string', description: 'Agent nodes: shell gate command the RUNNER executes when the step completes (e.g. "npm test"). Exit 0 sets flags[checkFlag]=true, anything else false; the measured result overrides the agent\'s self-report.' },
    checkFlag: { type: 'string', description: 'Agent nodes: flag written by checkCommand. Default "checkPassed".' },
    label: { type: 'string', description: 'End / group nodes: display label (e.g. "Done").' },
    position: {
      type: 'object',
      properties: { x: { type: 'number' }, y: { type: 'number' } },
      description: 'Canvas position. Optional: nodes without a position are laid out automatically left to right.',
    },
  },
  required: ['id', 'kind'],
};

const TEAM_EDGE_SCHEMA = {
  type: 'object',
  properties: {
    id: { type: 'string', description: 'Optional: generated when omitted.' },
    from: { type: 'string', description: 'Source node id.' },
    to: { type: 'string', description: 'Target node id.' },
    condition: {
      type: 'object',
      properties: {
        flag: { type: 'string', description: 'Handoff flag name (e.g. "qaPassed", "checkPassed", "needsRework").' },
        equals: { description: 'Value the flag must equal for the edge to fire (boolean, string or number).' },
      },
      required: ['flag'],
      description: 'Optional. A conditional edge fires only when the outgoing handoff\'s flags[flag] === equals (e.g. {flag:"qaPassed", equals:false} loops back to Dev). Edges WITHOUT a condition are the default route; 2+ unconditional edges out of the same agent node = parallel fan-out.',
    },
    label: { type: 'string', description: 'Display label rendered on the connection (e.g. "QA failed").' },
  },
  required: ['from', 'to'],
};

const TEAM_TOOLS = [
  {
    name: 'teams_list',
    description:
      'List the agent teams available to this project: project-scoped teams AND the user\'s account-wide (global) teams. A team is a directed graph of agent nodes (Dev -> QA loops, parallel fan-out, check gates) that runs as a multi-agent workflow. Use this before creating a team (avoid duplicates), or when the user asks what teams exist. Returns id, name, description, scope, maxCycles and node/edge counts per team; call `teams_get` for the full graph. Seeded template teams are machine-local and not listed (the user duplicates them in the desktop UI).',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'teams_get',
    description:
      'Return the COMPLETE definition of a single team by id or exact name: name, description, maxCycles (loop guard), every node (start / end / agent / group with role, provider, model, custom system prompt, step instructions, skills, handoff mode, check gate) and every connection between nodes (edges with source, target, optional condition flag and display label). This is the full source of truth of the workflow graph: use it to understand a team, or as the starting point before modifying it with `teams_save`.',
    inputSchema: {
      type: 'object',
      properties: {
        team: { type: 'string', description: 'Team id (from teams_list) or exact team name.' },
      },
      required: ['team'],
    },
  },
  {
    name: 'teams_save',
    description:
      'Create a new agent team or update an existing one (pass `teamId` to update). ASK THE USER FIRST unless they explicitly asked you to create or modify a team. To modify: call `teams_get`, apply your changes to the returned nodes/edges, and pass the FULL arrays back — when provided, `nodes` and `edges` REPLACE the stored graph (name / description / maxCycles can be patched individually without resending the graph). Graph rules (validated, errors are returned for you to fix and retry): exactly one start node, at least one end node and one agent node; every agent node needs an outgoing edge; a conditional edge fires when the handoff flags match and unconditional edges are the default route; 2+ unconditional edges out of one agent node = parallel fan-out (each branch is a single agent node and all branches must converge on the same join node; start cannot fan out). Node positions are optional (auto-layout). Seeded templates are read-only. Returns the saved team, including generated ids.',
    inputSchema: {
      type: 'object',
      properties: {
        teamId: { type: 'string', description: 'Id of the team to update. Omit to create a new team.' },
        name: { type: 'string', description: 'Team name. Required when creating.' },
        description: { type: 'string', description: 'What this team does. Shown in the Teams panel.' },
        maxCycles: { type: 'number', description: 'Max times a single node can be re-entered per run before the run blocks (guards infinite QA -> Dev loops). Default 3.' },
        scope: { type: 'string', enum: ['project', 'global'], description: 'Where a NEW team lives: "project" (default, this project only) or "global" (all the user\'s projects). Ignored on update.' },
        nodes: { type: 'array', items: TEAM_NODE_SCHEMA, description: 'Full node list. Required when creating; replaces the stored nodes when updating.' },
        edges: { type: 'array', items: TEAM_EDGE_SCHEMA, description: 'Full edge list (the connections between nodes). Required when creating; replaces the stored edges when updating.' },
      },
    },
  },
  {
    name: 'teams_delete',
    description:
      'Delete a team by id. DESTRUCTIVE and not undoable: only call this when the user explicitly asked to delete this team; otherwise propose first and wait for a yes. Works on project and global teams; seeded templates cannot be deleted.',
    inputSchema: {
      type: 'object',
      properties: {
        teamId: { type: 'string', description: 'Id of the team to delete (from teams_list).' },
      },
      required: ['teamId'],
    },
  },
  {
    name: 'agents_list',
    description:
      'List the saved agent configurations of this project (the agents in the desktop sidebar). Returns per agent: id, name, role, AI provider, model, account profile id, color, capabilities (browser access, always-memorize), default skill ids, worktree path, and whether a custom system prompt is set. Read-only: agents are managed from the desktop UI. Use `agents_get` for the full record including the custom system prompt body.',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'agents_get',
    description:
      'Return the full saved configuration of a single project agent by id or exact name, including its custom system prompt when one is set. When no custom prompt is set the agent uses the built-in persona of its role (on this machine those personas are materialized under ~/.agentsroom/roles/<role>.md, readable with the Read tool).',
    inputSchema: {
      type: 'object',
      properties: {
        agent: { type: 'string', description: 'Agent id (from agents_list) or exact display name.' },
      },
      required: ['agent'],
    },
  },
];

// ── Graph normalization + validation ─────────────────────────────────────────
//
// Philosophy: pass-through with validation (same as the server API). Unknown
// fields are preserved so a teams_get -> teams_save round-trip never strips
// data written by a newer desktop; known fields are checked and either fixed
// (defaults) or reported as issues for the calling agent to correct.

function normalizePosition(raw) {
  if (raw && typeof raw === 'object' && typeof raw.x === 'number' && typeof raw.y === 'number') {
    return { x: raw.x, y: raw.y };
  }
  return null;
}

function checkOptionalString(obj, key, ownerLabel, issues) {
  if (obj[key] !== undefined && typeof obj[key] !== 'string') {
    issues.push(`${ownerLabel}: "${key}" must be a string when provided.`);
    delete obj[key];
  }
}

function normalizeGraph(rawNodes, rawEdges, knownAgentRoles) {
  const issues = [];
  if (!Array.isArray(rawNodes) || !Array.isArray(rawEdges)) {
    return { nodes: [], edges: [], issues: ['nodes and edges must both be arrays.'] };
  }

  const nodes = [];
  const nodeIds = new Set();

  rawNodes.forEach((raw, i) => {
    if (!raw || typeof raw !== 'object') {
      issues.push(`Node #${i} is not an object.`);
      return;
    }
    const id = typeof raw.id === 'string' ? raw.id.trim() : '';
    if (!id) {
      issues.push(`Node #${i} is missing an id.`);
      return;
    }
    if (nodeIds.has(id)) {
      issues.push(`Duplicate node id "${id}".`);
      return;
    }
    const kind = typeof raw.kind === 'string' ? raw.kind : '';
    if (!NODE_KINDS.includes(kind)) {
      issues.push(`Node "${id}" has invalid kind "${kind}" (expected ${NODE_KINDS.join(' | ')}).`);
      return;
    }
    nodeIds.add(id);

    const node = { ...raw, id, kind };
    const position = normalizePosition(raw.position);
    if (position) node.position = position;
    else delete node.position; // auto-layout below

    const ownerLabel = `Node "${id}"`;
    if (kind === 'agent') {
      const role = typeof raw.role === 'string' ? raw.role.trim() : '';
      if (!knownAgentRoles.includes(role)) {
        issues.push(`Agent node "${id}" has invalid role "${role || '(missing)'}" (expected one of: ${knownAgentRoles.join(', ')}).`);
        return;
      }
      node.role = role;
      node.handoffMode = HANDOFF_MODES.includes(raw.handoffMode) ? raw.handoffMode : 'auto';
      for (const key of ['customName', 'model', 'provider', 'agentTemplateId', 'customSystemPrompt', 'stepInstructions', 'browserUrl', 'checkCommand', 'checkFlag', 'customColor', 'claudeProfileId', 'codexProfileId', 'reasoningEffort', 'launchCommand']) {
        checkOptionalString(node, key, ownerLabel, issues);
      }
      // Catalog identity (Expert / Community agent) driving this step. Written
      // by the desktop editor, kept opaque here beyond a shape check so a
      // malformed blob can't reach the runner.
      if (node.extraAgent !== undefined) {
        const ref = node.extraAgent;
        const valid = ref && typeof ref === 'object'
          && typeof ref.source === 'string' && ref.source.trim()
          && typeof ref.slug === 'string' && ref.slug.trim();
        if (!valid) {
          issues.push(`${ownerLabel}: "extraAgent" must be an object with at least a "source" and a "slug".`);
          delete node.extraAgent;
        }
      }
      if (node.verifyInBrowser !== undefined && typeof node.verifyInBrowser !== 'boolean') {
        issues.push(`${ownerLabel}: "verifyInBrowser" must be a boolean when provided.`);
        delete node.verifyInBrowser;
      }
      if (node.skills !== undefined) {
        if (!Array.isArray(node.skills)) {
          issues.push(`${ownerLabel}: "skills" must be an array of skill names.`);
          delete node.skills;
        } else {
          node.skills = node.skills.filter((s) => typeof s === 'string' && s.trim());
        }
      }
    } else if (kind === 'end') {
      checkOptionalString(node, 'label', ownerLabel, issues);
    } else if (kind === 'group') {
      node.label = typeof raw.label === 'string' && raw.label.trim() ? raw.label : GROUP_DEFAULT_LABEL;
      checkOptionalString(node, 'color', ownerLabel, issues);
      node.width = typeof raw.width === 'number' ? raw.width : GROUP_DEFAULT_WIDTH;
      node.height = typeof raw.height === 'number' ? raw.height : GROUP_DEFAULT_HEIGHT;
    }
    nodes.push(node);
  });

  // Auto-layout the nodes that came without a position.
  nodes.forEach((node, i) => {
    if (!node.position) node.position = { x: LAYOUT_X_START + i * LAYOUT_X_STEP, y: LAYOUT_Y };
  });

  const edges = [];
  const edgeIds = new Set();
  rawEdges.forEach((raw, i) => {
    if (!raw || typeof raw !== 'object') {
      issues.push(`Edge #${i} is not an object.`);
      return;
    }
    const from = typeof raw.from === 'string' ? raw.from.trim() : '';
    const to = typeof raw.to === 'string' ? raw.to.trim() : '';
    if (!from || !to) {
      issues.push(`Edge #${i} needs both "from" and "to" node ids.`);
      return;
    }
    if (!nodeIds.has(from)) issues.push(`Edge #${i} references unknown source node "${from}".`);
    if (!nodeIds.has(to)) issues.push(`Edge #${i} references unknown target node "${to}".`);

    let id = typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : nanoEdgeId();
    while (edgeIds.has(id)) id = `${nanoEdgeId()}-${i}`;
    edgeIds.add(id);

    const edge = { ...raw, id, from, to };
    if (raw.condition !== undefined && raw.condition !== null) {
      const flag = raw.condition && typeof raw.condition.flag === 'string' ? raw.condition.flag.trim() : '';
      const equals = raw.condition ? raw.condition.equals : undefined;
      const equalsOk = ['boolean', 'string', 'number'].includes(typeof equals);
      if (!flag || !equalsOk) {
        issues.push(`Edge #${i} has an invalid condition: expected { flag: string, equals: boolean|string|number }.`);
        delete edge.condition;
      } else {
        edge.condition = { flag, equals };
      }
    } else {
      delete edge.condition;
    }
    checkOptionalString(edge, 'label', `Edge "${id}"`, issues);
    edges.push(edge);
  });

  return { nodes, edges, issues };
}

// Canonical signature of a condition, matching desktop/src/models/teams.ts::
// conditionSignature. `matchesCondition` coerces across types at run time, so
// `equals: true` and `equals: "true"` ARE the same condition and must collide.
function conditionSignature(condition) {
  return `${String(condition.flag).trim()}=${String(condition.equals).trim().toLowerCase()}`;
}

function nodeLabelOf(nodes, nodeId) {
  const node = nodes.find((n) => n.id === nodeId);
  if (!node) return nodeId;
  if (node.kind === 'agent') return node.customName || node.role;
  if (node.kind === 'start') return 'Start';
  if (node.kind === 'end') return node.label || 'End';
  return node.label || nodeId;
}

// Static graph validation. Port of desktop/src/models/teams.ts::getTeamIssues
// (blocking issues only) — keep the two in sync so the MCP cannot persist a
// team the editor would refuse.
function validateGraph(nodes, edges) {
  const issues = [];
  const outgoing = (fromId) => edges.filter((e) => e.from === fromId);

  const starts = nodes.filter((n) => n.kind === 'start');
  const ends = nodes.filter((n) => n.kind === 'end');
  const agents = nodes.filter((n) => n.kind === 'agent');

  if (starts.length === 0) issues.push('Missing Start node (add one node with kind "start").');
  if (starts.length > 1) issues.push('Only one Start node is allowed.');
  if (ends.length === 0) issues.push('Missing End node (add one node with kind "end").');
  if (agents.length === 0) issues.push('Add at least one agent node.');

  for (const node of agents) {
    if (outgoing(node.id).length === 0) {
      issues.push(`Agent "${node.customName || node.role}" has no outgoing edge.`);
    }
  }

  // Duplicate / ambiguous edges: the runner resolves them by array order, so
  // one of them can never run and the graph does not say which.
  const edgeSignatures = new Map();
  const conditionSignatures = new Map();
  for (const edge of edges) {
    const edgeKey = `${edge.from}>${edge.to}` + (edge.condition ? `?${conditionSignature(edge.condition)}` : '');
    if (edgeSignatures.has(edgeKey)) {
      issues.push(
        `Duplicate edge from "${nodeLabelOf(nodes, edge.from)}" to "${nodeLabelOf(nodes, edge.to)}" with the same condition (edges ${edgeSignatures.get(edgeKey)} and ${edge.id}).`,
      );
      continue;
    }
    edgeSignatures.set(edgeKey, edge.id);
    if (!edge.condition) continue;

    const condKey = `${edge.from}::${conditionSignature(edge.condition)}`;
    if (conditionSignatures.has(condKey)) {
      issues.push(
        `Two outgoing edges out of "${nodeLabelOf(nodes, edge.from)}" fire on the same condition (${String(edge.condition.flag).trim()} = ${String(edge.condition.equals)}); only one of edges ${conditionSignatures.get(condKey)} and ${edge.id} can ever run.`,
      );
    } else {
      conditionSignatures.set(condKey, edge.id);
    }

    if (!String(edge.condition.flag).trim()) {
      issues.push(`Edge ${edge.id} has a condition with an empty flag name.`);
    }
    const source = nodes.find((n) => n.id === edge.from);
    if (source && source.kind === 'start') {
      issues.push(`Edge ${edge.id} leaves Start with a condition: no step has reported a flag yet, so it can never match.`);
    }
  }

  // Parallel fan-out constraints: single-agent-node branches converging on a
  // common join, never from Start.
  for (const node of nodes) {
    if (node.kind === 'end' || node.kind === 'group') continue;
    const unconditional = outgoing(node.id).filter((e) => !e.condition);
    if (unconditional.length < 2) continue;

    if (node.kind === 'start') {
      issues.push('Start cannot fan out. Give it a single outgoing edge; branch after the first agent.');
      continue;
    }

    const label = node.customName || node.role;
    const joinTargets = new Set();
    for (const edge of unconditional) {
      const target = nodes.find((n) => n.id === edge.to);
      if (!target || target.kind !== 'agent') {
        issues.push(`Parallel branches out of "${label}" must all point to agent nodes.`);
        continue;
      }
      const branchOut = outgoing(target.id).filter((e) => !e.condition);
      if (branchOut.length !== 1) {
        issues.push(`Parallel branch "${target.customName || target.role}" must have exactly one unconditional outgoing edge (the join).`);
        continue;
      }
      joinTargets.add(branchOut[0].to);
    }
    if (joinTargets.size > 1) {
      issues.push(`Parallel branches out of "${label}" must converge on the same node.`);
    }
  }

  return issues;
}

/**
 * Non-blocking findings: the graph saves, but these branches will silently
 * never fire (or the run can stop mid-graph). Port of the `warning` issues in
 * desktop/src/models/teams.ts::getTeamIssues. Returned to the calling agent so
 * it can fix its own graph instead of shipping a dead condition.
 */
function graphWarnings(nodes, edges) {
  const warnings = [];
  const outgoing = (fromId) => edges.filter((e) => e.from === fromId);

  for (const edge of edges) {
    if (!edge.condition) continue;
    const flag = String(edge.condition.flag).trim();
    if (!flag) continue;
    if (flag !== edge.condition.flag) {
      warnings.push(`Edge ${edge.id}: flag "${flag}" starts or ends with a space, so it can never match.`);
    }
    const source = nodes.find((n) => n.id === edge.from);
    if (!source || source.kind !== 'agent') continue;
    const checkFlag = String(source.checkCommand || '').trim()
      ? (String(source.checkFlag || '').trim() || DEFAULT_CHECK_FLAG)
      : null;
    const haystack = `${source.stepInstructions || ''}\n${source.customSystemPrompt || ''}`.toLowerCase();
    if (checkFlag !== flag && !haystack.includes(flag.toLowerCase())) {
      warnings.push(
        `Edge ${edge.id}: the "${source.customName || source.role}" step never sets ${flag}, so this edge will never fire. Ask for it in stepInstructions (e.g. "set flags.${flag}") or use a flag that step reports.`,
      );
    }
  }

  for (const node of nodes) {
    if (node.kind !== 'agent') continue;
    const out = outgoing(node.id);
    if (out.length === 0 || out.some((e) => !e.condition)) continue;
    warnings.push(
      `Every edge out of "${node.customName || node.role}" is conditional: if no condition matches, the run stops there. Add one edge without a condition as the fallback.`,
    );
  }

  return warnings;
}

function normalizeMaxCycles(raw, fallback) {
  if (typeof raw === 'number' && Number.isFinite(raw)) {
    return Math.max(1, Math.round(raw));
  }
  return typeof fallback === 'number' && Number.isFinite(fallback) ? fallback : DEFAULT_MAX_CYCLES;
}

function invalidTeamResult(issues) {
  return [{
    type: 'text',
    text: JSON.stringify({
      ok: false,
      error: 'Invalid team graph. Fix the issues below and call teams_save again.',
      issues,
    }, null, 2),
  }];
}

// ── Tool factory ─────────────────────────────────────────────────────────────
//
// The HTTP plumbing (auth token re-read, base URL) lives in app-mcp-server.cjs;
// this factory receives it injected so the module stays testable and free of
// env coupling.

function createTeamTools(ctx) {
  const { projectId, apiGet, apiPost, refreshTeamsCache, knownAgentRoles } = ctx;

  async function fetchProjectTeams() {
    const data = await apiGet(`/api/project-teams?projectId=${encodeURIComponent(projectId)}`);
    return {
      teams: Array.isArray(data && data.teams) ? data.teams : [],
      updatedAt: (data && data.updatedAt) || null,
    };
  }

  async function fetchGlobalTeams() {
    const data = await apiGet('/api/user-teams');
    return {
      teams: Array.isArray(data && data.teams) ? data.teams : [],
      updatedAt: (data && data.updatedAt) || null,
    };
  }

  async function pushProjectTeams(teams) {
    const pushed = await apiPost('/api/project-teams', { projectId, teams });
    const list = Array.isArray(pushed && pushed.teams) ? pushed.teams : teams;
    refreshTeamsCache(list, pushed && pushed.updatedAt);
    return list;
  }

  async function pushGlobalTeams(teams) {
    const pushed = await apiPost('/api/user-teams', { teams });
    return Array.isArray(pushed && pushed.teams) ? pushed.teams : teams;
  }

  function summarizeTeam(team, scopeKind) {
    const nodes = Array.isArray(team.nodes) ? team.nodes : [];
    return {
      id: team.id,
      name: team.name,
      description: typeof team.description === 'string' ? team.description : '',
      scope: scopeKind,
      maxCycles: team.maxCycles,
      agentNodes: nodes.filter((n) => n && n.kind === 'agent').length,
      totalNodes: nodes.length,
      edges: Array.isArray(team.edges) ? team.edges.length : 0,
      updatedAt: team.updatedAt,
    };
  }

  // Resolve a team by id first, then exact name, then unique
  // case-insensitive name. Returns { hit } | { ambiguous } | {}.
  function resolveTeamRef(entries, ref) {
    const byId = entries.find((e) => e.team.id === ref);
    if (byId) return { hit: byId };
    const exact = entries.filter((e) => e.team.name === ref);
    if (exact.length === 1) return { hit: exact[0] };
    if (exact.length > 1) return { ambiguous: exact };
    const lower = String(ref).toLowerCase();
    const ci = entries.filter((e) => typeof e.team.name === 'string' && e.team.name.toLowerCase() === lower);
    if (ci.length === 1) return { hit: ci[0] };
    if (ci.length > 1) return { ambiguous: ci };
    return {};
  }

  async function loadAllTeamEntries() {
    const [proj, glob] = await Promise.all([fetchProjectTeams(), fetchGlobalTeams()]);
    const entries = [
      ...proj.teams.map((team) => ({ team, scopeKind: 'project' })),
      ...glob.teams.map((team) => ({ team, scopeKind: 'global' })),
    ];
    return { proj, glob, entries };
  }

  async function toolTeamsList() {
    const { entries } = await loadAllTeamEntries();
    const teams = entries.map((e) => summarizeTeam(e.team, e.scopeKind));
    return [{
      type: 'text',
      text: JSON.stringify({
        teams,
        total: teams.length,
        note: 'Seeded template teams are machine-local and not listed. Use teams_get for the full graph (nodes + connections) of a team.',
      }, null, 2),
    }];
  }

  async function toolTeamsGet(args) {
    const ref = String(args.team || '').trim();
    if (!ref) return [{ type: 'text', text: 'team is required (team id or exact name).' }];
    const { entries } = await loadAllTeamEntries();
    const resolved = resolveTeamRef(entries, ref);
    if (resolved.ambiguous) {
      const ids = resolved.ambiguous.map((e) => `${e.team.id} (${e.scopeKind})`).join(', ');
      return [{ type: 'text', text: `Several teams are named "${ref}": ${ids}. Call teams_get again with the id.` }];
    }
    if (!resolved.hit) {
      return [{ type: 'text', text: `Team "${ref}" not found. Call teams_list to see what exists (seeded templates are machine-local and not accessible here).` }];
    }
    return [{
      type: 'text',
      text: JSON.stringify({ scope: resolved.hit.scopeKind, team: resolved.hit.team }, null, 2),
    }];
  }

  async function toolTeamsSave(args) {
    const teamId = typeof args.teamId === 'string' ? args.teamId.trim() : '';

    if (teamId) {
      // ── Update ──
      const { proj, glob } = await loadAllTeamEntries();
      let scopeKind = 'project';
      let existing = proj.teams.find((t) => t.id === teamId);
      if (!existing) {
        existing = glob.teams.find((t) => t.id === teamId);
        scopeKind = 'global';
      }
      if (!existing) {
        return [{ type: 'text', text: `Team ${teamId} not found. Call teams_list to see what exists, or omit teamId to create a new team.` }];
      }
      if (existing.isTemplate) {
        return [{ type: 'text', text: `Team "${existing.name}" is a read-only template. Ask the user to duplicate it in the desktop UI first.` }];
      }

      // Only touch the graph when the caller sent one: a name-only patch must
      // not round-trip (and possibly re-normalize) an untouched graph.
      let nodes = existing.nodes;
      let edges = existing.edges;
      if (args.nodes !== undefined || args.edges !== undefined) {
        const rawNodes = args.nodes !== undefined ? args.nodes : existing.nodes;
        const rawEdges = args.edges !== undefined ? args.edges : existing.edges;
        const normalized = normalizeGraph(rawNodes, rawEdges, knownAgentRoles);
        const issues = [...normalized.issues, ...validateGraph(normalized.nodes, normalized.edges)];
        if (issues.length > 0) return invalidTeamResult(issues);
        nodes = normalized.nodes;
        edges = normalized.edges;
      }

      const updated = {
        ...existing,
        name: typeof args.name === 'string' && args.name.trim() ? args.name.trim() : existing.name,
        ...(typeof args.description === 'string' ? { description: args.description } : {}),
        nodes,
        edges,
        maxCycles: normalizeMaxCycles(args.maxCycles, existing.maxCycles),
        updatedAt: new Date().toISOString(),
      };

      if (scopeKind === 'project') {
        await pushProjectTeams(proj.teams.map((t) => (t.id === teamId ? updated : t)));
      } else {
        await pushGlobalTeams(glob.teams.map((t) => (t.id === teamId ? updated : t)));
      }
      const warnings = graphWarnings(nodes, edges);
      return [{ type: 'text', text: JSON.stringify({ ok: true, updated: true, scope: scopeKind, team: updated, ...(warnings.length ? { warnings } : {}) }, null, 2) }];
    }

    // ── Create ──
    const name = String(args.name || '').trim();
    if (!name) return [{ type: 'text', text: 'name is required when creating a team.' }];
    if (!Array.isArray(args.nodes) || !Array.isArray(args.edges)) {
      return [{ type: 'text', text: 'nodes and edges are required when creating a team (pass the full graph).' }];
    }
    const scopeKind = args.scope === 'global' ? 'global' : 'project';

    const normalized = normalizeGraph(args.nodes, args.edges, knownAgentRoles);
    const issues = [...normalized.issues, ...validateGraph(normalized.nodes, normalized.edges)];
    if (issues.length > 0) return invalidTeamResult(issues);

    const now = new Date().toISOString();
    const team = {
      id: nanoTeamId(),
      name,
      ...(typeof args.description === 'string' && args.description.trim() ? { description: args.description } : {}),
      scope: scopeKind === 'global' ? { kind: 'global' } : { kind: 'project', projectId },
      nodes: normalized.nodes,
      edges: normalized.edges,
      maxCycles: normalizeMaxCycles(args.maxCycles, DEFAULT_MAX_CYCLES),
      createdAt: now,
      updatedAt: now,
    };

    if (scopeKind === 'project') {
      const proj = await fetchProjectTeams();
      await pushProjectTeams([...proj.teams, team]);
    } else {
      const glob = await fetchGlobalTeams();
      // Never write the FIRST version of the account row from here: the
      // desktop treats `updatedAt === null` as "never synced" and migrates
      // its local library up on that signal. Creating the row first would
      // make every machine's not-yet-synced global teams lose that migration.
      if (glob.updatedAt === null) {
        return [{
          type: 'text',
          text: 'The account-wide (global) team library has not been synced by the desktop app yet. Open the Teams panel once in AgentsRoom desktop (it performs the initial sync), or create the team with scope "project" instead.',
        }];
      }
      await pushGlobalTeams([...glob.teams, team]);
    }
    const warnings = graphWarnings(team.nodes, team.edges);
    return [{ type: 'text', text: JSON.stringify({ ok: true, created: true, scope: scopeKind, team, ...(warnings.length ? { warnings } : {}) }, null, 2) }];
  }

  async function toolTeamsDelete(args) {
    const teamId = String(args.teamId || '').trim();
    if (!teamId) return [{ type: 'text', text: 'teamId is required.' }];

    const { proj, glob } = await loadAllTeamEntries();

    const inProject = proj.teams.find((t) => t.id === teamId);
    if (inProject) {
      if (inProject.isTemplate) {
        return [{ type: 'text', text: 'Seeded templates cannot be deleted.' }];
      }
      await pushProjectTeams(proj.teams.filter((t) => t.id !== teamId));
      return [{ type: 'text', text: JSON.stringify({ ok: true, deleted: { id: teamId, name: inProject.name, scope: 'project' } }, null, 2) }];
    }

    const inGlobal = glob.teams.find((t) => t.id === teamId);
    if (inGlobal) {
      if (inGlobal.isTemplate) {
        return [{ type: 'text', text: 'Seeded templates cannot be deleted.' }];
      }
      await pushGlobalTeams(glob.teams.filter((t) => t.id !== teamId));
      return [{ type: 'text', text: JSON.stringify({ ok: true, deleted: { id: teamId, name: inGlobal.name, scope: 'global' } }, null, 2) }];
    }

    return [{ type: 'text', text: `Team ${teamId} not found. Call teams_list to see what exists.` }];
  }

  // ── Saved agents (read-only) ────────────────────────────────────────────────

  async function fetchAgents() {
    const data = await apiGet(`/api/project-agents?projectId=${encodeURIComponent(projectId)}`);
    return Array.isArray(data && data.agents) ? data.agents : [];
  }

  function trimAgent(a) {
    return {
      id: a.id,
      name: a.customName || a.role,
      role: a.role,
      provider: a.provider || 'claude',
      model: a.model,
      // Account profile the agent is pinned to (machine-local profile id;
      // null = the provider's default account).
      accountProfileId: a.claudeProfileId || a.codexProfileId || null,
      color: a.customColor || null,
      avatarId: a.avatarId ?? null,
      group: a.group || null,
      isPersonal: !!a.isPersonal,
      browserAccess: a.browserAccess === true,
      alwaysMemorize: a.alwaysMemorize === true,
      worktreePath: a.worktreePath || null,
      defaultSkillIds: Array.isArray(a.defaultSkillIds) ? a.defaultSkillIds : [],
      hasCustomSystemPrompt: typeof a.customSystemPrompt === 'string' && a.customSystemPrompt.trim().length > 0,
    };
  }

  async function toolAgentsList() {
    const agents = await fetchAgents();
    return [{
      type: 'text',
      text: JSON.stringify({
        agents: agents.map(trimAgent),
        total: agents.length,
        note: 'Read-only. Use agents_get(<id or name>) for the full record including the custom system prompt.',
      }, null, 2),
    }];
  }

  async function toolAgentsGet(args) {
    const ref = String(args.agent || '').trim();
    if (!ref) return [{ type: 'text', text: 'agent is required (agent id or exact display name).' }];
    const agents = await fetchAgents();

    let found = agents.find((a) => a.id === ref);
    if (!found) {
      const exact = agents.filter((a) => (a.customName || a.role) === ref);
      if (exact.length > 1) {
        return [{ type: 'text', text: `Several agents are named "${ref}": ${exact.map((a) => a.id).join(', ')}. Call agents_get again with the id.` }];
      }
      found = exact[0];
    }
    if (!found) {
      const lower = ref.toLowerCase();
      const ci = agents.filter((a) => String(a.customName || a.role).toLowerCase() === lower);
      if (ci.length > 1) {
        return [{ type: 'text', text: `Several agents are named "${ref}": ${ci.map((a) => a.id).join(', ')}. Call agents_get again with the id.` }];
      }
      found = ci[0];
    }
    if (!found) {
      return [{ type: 'text', text: `Agent "${ref}" not found. Call agents_list to see what exists.` }];
    }

    const hasCustomPrompt = typeof found.customSystemPrompt === 'string' && found.customSystemPrompt.trim().length > 0;
    return [{
      type: 'text',
      text: JSON.stringify({
        agent: found,
        ...(hasCustomPrompt ? {} : {
          systemPromptNote: `No custom system prompt: this agent uses the built-in persona of its role ("${found.role}"). On this machine the persona is readable at ~/.agentsroom/roles/${found.role}.md.`,
        }),
      }, null, 2),
    }];
  }

  return {
    toolTeamsList,
    toolTeamsGet,
    toolTeamsSave,
    toolTeamsDelete,
    toolAgentsList,
    toolAgentsGet,
  };
}

module.exports = { TEAM_TOOLS, createTeamTools };
