/**
 * 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).
 *   - the GOVERNANCE GUARD (see its own section below): while a team run is
 *     live, the definition that run executes is frozen for MCP callers, so an
 *     agent cannot rewrite the rule that governs it.
 *
 * 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';

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

// ── 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),
  }];
}

// ── Governance guard: a live run's definition is frozen for agents ───────────
//
// Why this exists: teams_save / teams_delete / skills_save reach the same
// endpoints the desktop UI uses, and they validate the GRAPH, not the
// authority of the caller. An agent executing a team run therefore had a
// straight path to rewriting the very artifacts that govern it: its own
// stepInstructions, the check gate that measures its work, the routing that
// decides what runs after it, or the body of the skill implementing a safety
// procedure. Not hypothetical: a skill body was once replaced by the literal
// string "OK", and the agents of that run then diverged on what to do with the
// degenerate text (some stopped, some treated it as the check's result).
// Restating the rule in prose protects against a MISSING skill, not against a
// corrupted one, because prose is not what the runner reads.
//
// The rule: while a run is live, the definition it executes is read-only for
// MCP callers. It separates the governed party from the author, not the app
// from its data: the desktop UI never goes through this server, so a human can
// still change anything at any moment, run or no run.
//
// Provenance comes from two independent signals, so the guard survives a CLI
// that filters the environment it hands to MCP subprocesses:
//   1. the session's own team env (AGENTSROOM_TEAM_RUN_ID + _WORKSPACE), and
//   2. an on-disk scan of the workspace's team-runs directory, which also
//      catches a SIBLING agent (or the user's other tab) editing the team a
//      run is currently executing.
// Neither signal can be set by the agent itself: the env is written by the
// desktop when it spawns the PTY, the state files by the main process.

const RUN_STATE_FILENAME = 'state.json';
const TEAM_RUNS_DIRNAME = 'team-runs';

/** Run statuses where the definition is still driving agents. */
const ACTIVE_RUN_STATUSES = ['pending', 'running', 'awaiting-handoff', 'awaiting-finalization', 'blocked'];

/** Newest N run directories inspected by the workspace scan (dir names sort chronologically). */
const MAX_SCANNED_RUN_DIRS = 25;

/**
 * A run directory whose state.json has not been touched for this long is
 * treated as abandoned by the SCAN (a crash can leave `status: "running"`
 * behind forever, and a stale marker would freeze the team for good). The
 * session signal has no such cutoff: an env-carrying agent belongs to that run
 * however long it has been idle.
 */
const STALE_RUN_MS = 24 * 60 * 60 * 1000;

/** Team-level fields that ARE the workflow. `name` / `description` are not. */
const PROTECTED_TEAM_FIELDS = ['nodes', 'edges', 'maxCycles'];

/**
 * Node-level fields carried inside `nodes` that decide what a step is told to
 * do, what measures it, and under what identity it runs. Listed so a refusal
 * can name what the payload actually addressed instead of pointing at the
 * whole array.
 */
const PROTECTED_STEP_FIELDS = [
  'role', 'provider', 'model', 'reasoningEffort', 'customSystemPrompt', 'stepInstructions',
  'skills', 'checkCommand', 'checkFlag', 'handoffMode', 'verifyInBrowser', 'browserUrl',
  'extraAgent', 'launchCommand', 'agentTemplateId', 'claudeProfileId', 'codexProfileId',
];

/** Tools the guard covers. Anything added here must also be routed through `refusalForGovernedWrite`. */
const GOVERNED_WRITE_TOOLS = ['teams_save', 'teams_delete', 'skills_save'];

function readJsonFileSafe(filePath) {
  try {
    return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
  } catch (_) {
    return null;
  }
}

function runStateFile(workspaceCwd, runId) {
  return path.join(workspaceCwd, '.agentsroom', TEAM_RUNS_DIRNAME, runId, RUN_STATE_FILENAME);
}

/**
 * Loose comparison key for a skill name. Mirrors slugifySkillName() in
 * app-mcp-server.cjs; being MORE permissive than the real slugifier is
 * deliberate here, since a near-miss must still be caught by the guard.
 */
function skillKey(raw) {
  return String(raw || '')
    .toLowerCase()
    .normalize('NFKD')
    .replace(/[̀-ͯ]/g, '')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

function governingRunFrom(state, source) {
  if (!state || typeof state !== 'object') return null;
  if (!ACTIVE_RUN_STATUSES.includes(state.status)) return null;
  if (typeof state.teamId !== 'string' || !state.teamId) return null;
  return {
    runId: typeof state.runId === 'string' ? state.runId : '',
    teamId: state.teamId,
    teamName: typeof state.teamName === 'string' ? state.teamName : '',
    // Snapshotted at run start by teamRunState.initRunState. Absent on runs
    // created before the field existed: the team stays protected, its skills
    // do not.
    governedSkills: Array.isArray(state.governedSkills) ? state.governedSkills : [],
    source,
  };
}

function scanWorkspaceRuns(projectPath) {
  if (!projectPath) return [];
  const root = path.join(projectPath, '.agentsroom', TEAM_RUNS_DIRNAME);
  let dirents;
  try {
    dirents = fs.readdirSync(root, { withFileTypes: true });
  } catch (_) {
    return [];
  }
  const dirs = dirents
    .filter((d) => d.isDirectory())
    .map((d) => d.name)
    .sort()
    .reverse()
    .slice(0, MAX_SCANNED_RUN_DIRS);

  const runs = [];
  for (const dir of dirs) {
    const file = path.join(root, dir, RUN_STATE_FILENAME);
    let stat;
    try {
      stat = fs.statSync(file);
    } catch (_) {
      continue;
    }
    if (Date.now() - stat.mtimeMs > STALE_RUN_MS) continue;
    const run = governingRunFrom(readJsonFileSafe(file), 'workspace');
    if (run) runs.push(run);
  }
  return runs;
}

/**
 * Every team run whose definition currently governs this MCP session. Pure
 * local file I/O: no network, so the guard also holds offline and adds no
 * latency to the write it is protecting.
 */
function readGoverningRuns(opts) {
  const env = (opts && opts.env) || {};
  const runs = [];
  const seen = new Set();

  const sessionRunId = env.AGENTSROOM_TEAM_RUN_ID;
  const sessionWorkspace = env.AGENTSROOM_TEAM_WORKSPACE;
  if (sessionRunId && sessionWorkspace) {
    const run = governingRunFrom(readJsonFileSafe(runStateFile(sessionWorkspace, sessionRunId)), 'session');
    if (run) {
      runs.push(run);
      seen.add(run.runId);
    }
  }
  for (const run of scanWorkspaceRuns(opts && opts.projectPath)) {
    if (seen.has(run.runId)) continue;
    seen.add(run.runId);
    runs.push(run);
  }
  return runs;
}

function runLabel(run) {
  const name = run.teamName ? `"${run.teamName}"` : `team ${run.teamId}`;
  const own = run.source === 'session' ? ', the run driving your own session' : '';
  return `${name} (run ${run.runId}${own})`;
}

/** Protected fields the payload actually addresses, top level and per step. */
function protectedFieldsInTeamPayload(args) {
  const top = PROTECTED_TEAM_FIELDS.filter((f) => args[f] !== undefined);
  const step = [];
  if (Array.isArray(args.nodes)) {
    for (const node of args.nodes) {
      if (!node || typeof node !== 'object') continue;
      for (const field of PROTECTED_STEP_FIELDS) {
        if (node[field] !== undefined && !step.includes(field)) step.push(field);
      }
    }
  }
  return { top, step };
}

function teamWriteRefusal(toolName, args, runs) {
  const teamId = typeof args.teamId === 'string' ? args.teamId.trim() : '';
  if (!teamId) return null; // creating a brand-new team is never governed
  const run = runs.find((r) => r.teamId === teamId);
  if (!run) return null;

  if (toolName === 'teams_delete') {
    return `teams_delete refused: ${runLabel(run)} is currently running, and deleting the definition a live run executes would take the whole workflow with it. Nothing was deleted: the stored team is unchanged. Wait for the run to end, or ask the user to delete the team from the desktop Teams panel (the UI is not restricted).`;
  }

  const { top, step } = protectedFieldsInTeamPayload(args);
  if (top.length === 0) return null; // a name / description patch stays allowed

  const stepPart = step.length > 0 ? ` The step fields your payload carries: ${step.join(', ')}.` : '';
  return `teams_save refused on protected field(s): ${top.join(', ')}. ${runLabel(run)} is currently running, so this definition is what governs the agents of that run: its steps, its check gates and its routing cannot be rewritten from the inside while it executes.${stepPart} Nothing was written: the stored team is byte-identical. Still allowed here: name and description. To change the workflow, ask the user to edit it in the desktop Teams panel (the UI is not restricted), or wait for the run to finish.`;
}

function skillWriteRefusal(args, runs) {
  const key = skillKey(args.name);
  if (!key) return null;
  let match = null;
  for (const run of runs) {
    const hit = (run.governedSkills || []).find((s) => skillKey(s) === key);
    if (hit) {
      match = { run, skill: hit };
      break;
    }
  }
  if (!match) return null;
  return `skills_save refused on protected skill "${match.skill}": it is loaded by a step of ${runLabel(match.run)}, so its body is part of the procedure that governs that run and cannot be rewritten from the inside while it executes. Nothing was written: the stored skill is byte-identical. Save your version under a different name, or ask the user to edit the skill in the desktop Skills Library (the UI is not restricted).`;
}

/**
 * Single door for the guard, called by app-mcp-server's dispatcher BEFORE any
 * handler runs, so a refusal can never leave a partial write behind. Returns
 * the refusal text, or null when the call is allowed.
 */
function refusalForGovernedWrite(toolName, args, opts) {
  if (!GOVERNED_WRITE_TOOLS.includes(toolName)) return null;
  const runs = readGoverningRuns(opts);
  if (runs.length === 0) return null;
  const a = args || {};
  return toolName === 'skills_save'
    ? skillWriteRefusal(a, runs)
    : teamWriteRefusal(toolName, a, runs);
}

/**
 * tools/list view of the guarded tools: when a run is live, the restriction is
 * stated in the tool description so the model reads the rule before spending a
 * call on it. Deliberately description-level and NOT schema-level: dropping the
 * protected properties from the node schema would make an honest
 * teams_get -> teams_save round-trip silently WIPE stepInstructions, skills and
 * checkCommand, which is the very corruption this guard exists to prevent.
 */
function annotateToolsForGovernance(tools, opts) {
  const runs = readGoverningRuns(opts);
  if (runs.length === 0) return tools;
  const teams = runs.map((r) => (r.teamName ? `"${r.teamName}"` : r.teamId)).join(', ');
  const note = ` GUARDRAIL ACTIVE: a team run is live in this workspace (${teams}). Its definition, and the skills its steps load, are read-only from here until the run ends. Everything else remains writable, and the user can still edit anything from the desktop UI.`;
  return tools.map((tool) => (
    GOVERNED_WRITE_TOOLS.includes(tool.name)
      ? { ...tool, description: tool.description + note }
      : tool
  ));
}

// ── 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,
  refusalForGovernedWrite,
  annotateToolsForGovernance,
  PROTECTED_TEAM_FIELDS,
  PROTECTED_STEP_FIELDS,
};
