// PRE Web GUI — ServiceNow tool
// Talks to ServiceNow Table API (https://docs.servicenow.com/.../Table-API)
// Supports both basic auth (username + password) and OAuth 2.0 (refresh-token flow).

const https = require('https');
const http = require('http');
const fs = require('fs');
const { URL, URLSearchParams } = require('url');
const { CONNECTIONS_FILE } = require('../constants');

function loadRaw() {
  try { return JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf-8')); } catch { return {}; }
}
function saveRaw(data) {
  fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
}

function getServiceNowConfig() {
  const data = loadRaw();
  if (!data.servicenow_url) return null;
  const url = data.servicenow_url.replace(/\/+$/, '');
  const authType = data.servicenow_auth_type || (data.servicenow_username ? 'basic' : 'oauth');
  if (authType === 'basic') {
    if (!data.servicenow_username || !data.servicenow_password) return null;
    return { url, authType, username: data.servicenow_username, password: data.servicenow_password };
  }
  if (authType === 'oauth') {
    if (!data.servicenow_client_id || !data.servicenow_client_secret) return null;
    return {
      url, authType,
      clientId: data.servicenow_client_id,
      clientSecret: data.servicenow_client_secret,
      accessToken: data.servicenow_access_token || null,
      refreshToken: data.servicenow_refresh_token || null,
      tokenExpiry: data.servicenow_token_expiry || 0,
    };
  }
  return null;
}

function buildAuthHeader(config) {
  if (config.authType === 'basic') {
    return 'Basic ' + Buffer.from(`${config.username}:${config.password}`).toString('base64');
  }
  return `Bearer ${config.accessToken}`;
}

// Refresh OAuth token via the refresh_token grant against {instance}/oauth_token.do.
function refreshOAuthToken(config) {
  return new Promise((resolve, reject) => {
    if (!config.refreshToken) {
      return reject(new Error('ServiceNow OAuth token expired and no refresh token available; reauthorize in Settings'));
    }
    const tokenUrl = new URL(`${config.url}/oauth_token.do`);
    const postData = new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: config.refreshToken,
      client_id: config.clientId,
      client_secret: config.clientSecret,
    }).toString();
    const transport = tokenUrl.protocol === 'https:' ? https : http;
    const req = transport.request({
      hostname: tokenUrl.hostname,
      port: tokenUrl.port || (tokenUrl.protocol === 'https:' ? 443 : 80),
      path: tokenUrl.pathname,
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': Buffer.byteLength(postData),
        'Accept': 'application/json',
      },
    }, (res) => {
      let body = '';
      res.on('data', (c) => body += c);
      res.on('end', () => {
        try {
          const tokens = JSON.parse(body);
          if (tokens.error) return reject(new Error(tokens.error_description || tokens.error));
          const data = loadRaw();
          data.servicenow_access_token = tokens.access_token;
          if (tokens.refresh_token) data.servicenow_refresh_token = tokens.refresh_token;
          data.servicenow_token_expiry = Math.floor(Date.now() / 1000) + (tokens.expires_in || 1800);
          saveRaw(data);
          config.accessToken = tokens.access_token;
          if (tokens.refresh_token) config.refreshToken = tokens.refresh_token;
          config.tokenExpiry = data.servicenow_token_expiry;
          resolve();
        } catch (err) {
          reject(err);
        }
      });
    });
    req.on('error', reject);
    req.setTimeout(30000, () => { req.destroy(); reject(new Error('ServiceNow token refresh timed out')); });
    req.write(postData);
    req.end();
  });
}

async function ensureFreshToken(config) {
  if (config.authType !== 'oauth') return;
  const now = Math.floor(Date.now() / 1000);
  if (!config.accessToken || now >= (config.tokenExpiry - 60)) {
    await refreshOAuthToken(config);
  }
}

function rawRequest(method, fullPath, config, body) {
  return new Promise((resolve, reject) => {
    const baseUrl = new URL(config.url);
    const isHttps = baseUrl.protocol === 'https:';
    const transport = isHttps ? https : http;
    const options = {
      hostname: baseUrl.hostname,
      port: baseUrl.port || (isHttps ? 443 : 80),
      path: fullPath,
      method,
      headers: {
        'Authorization': buildAuthHeader(config),
        'Accept': 'application/json',
        'User-Agent': 'PRE-Web-GUI',
      },
    };
    if (body) {
      const bodyStr = JSON.stringify(body);
      options.headers['Content-Type'] = 'application/json';
      options.headers['Content-Length'] = Buffer.byteLength(bodyStr);
    }
    const req = transport.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => data += chunk);
      res.on('end', () => resolve({ status: res.statusCode, body: data }));
    });
    req.on('error', reject);
    req.setTimeout(30000, () => { req.destroy(); reject(new Error('ServiceNow request timed out')); });
    if (body) req.write(JSON.stringify(body));
    req.end();
  });
}

async function snRequest(method, table, sysId, params, body, config) {
  await ensureFreshToken(config);
  let path = `/api/now/table/${table}`;
  if (sysId) path += `/${encodeURIComponent(sysId)}`;
  const qs = params ? '?' + new URLSearchParams(params).toString() : '';
  let res = await rawRequest(method, path + qs, config, body);
  // OAuth tokens can be revoked server-side; one retry after a refresh.
  if (res.status === 401 && config.authType === 'oauth' && config.refreshToken) {
    await refreshOAuthToken(config);
    res = await rawRequest(method, path + qs, config, body);
  }
  if (res.status >= 400) {
    let msg = `ServiceNow API ${res.status}`;
    try { const j = JSON.parse(res.body); msg += ': ' + (j.error?.message || j.error?.detail || res.body.slice(0, 200)); } catch { msg += ': ' + res.body.slice(0, 200); }
    throw new Error(msg);
  }
  try { return JSON.parse(res.body); } catch { return res.body; }
}

// ── Field shapes -------------------------------------------------------------
// ServiceNow returns reference fields either as a sys_id string or, when
// sysparm_display_value=all is set, as { value, display_value, link }. These
// helpers normalise both.

function dv(field) {
  if (field == null) return '';
  if (typeof field === 'object') return field.display_value || field.value || '';
  return String(field);
}
function id(field) {
  if (field == null) return '';
  if (typeof field === 'object') return field.value || '';
  return String(field);
}

// Common field projections per table
const FIELDS = {
  incident: 'number,short_description,state,priority,urgency,impact,caller_id,assigned_to,assignment_group,category,subcategory,opened_at,resolved_at,sys_updated_on,sys_id',
  change_request: 'number,short_description,state,type,category,risk,impact,priority,assigned_to,assignment_group,planned_start_date,planned_end_date,sys_updated_on,sys_id',
  problem: 'number,short_description,state,priority,known_error,assigned_to,assignment_group,sys_updated_on,sys_id',
  sc_request: 'number,short_description,state,requested_for,opened_by,sys_updated_on,sys_id',
  sc_req_item: 'number,short_description,state,request,cat_item,requested_for,assigned_to,sys_updated_on,sys_id',
  sc_task: 'number,short_description,state,request_item,assigned_to,sys_updated_on,sys_id',
  sys_user: 'user_name,name,email,employee_number,department,manager,title,active,sys_id',
  sys_user_group: 'name,description,manager,email,active,sys_id',
  cmdb_ci: 'name,sys_class_name,asset_tag,serial_number,model_id,owned_by,assigned_to,install_status,location,sys_updated_on,sys_id',
  kb_knowledge: 'number,short_description,kb_category,workflow_state,view_count,sys_updated_on,sys_id',
};

const DISPLAY_PARAMS = { sysparm_display_value: 'all', sysparm_exclude_reference_link: 'true' };

function buildUrl(config, sysId, table) {
  return sysId ? `${config.url}/nav_to.do?uri=${table}.do?sys_id=${sysId}` : config.url;
}

// ── Formatters --------------------------------------------------------------

function formatIncidentRow(r) {
  return `${dv(r.number)} [${dv(r.state)}] ${dv(r.short_description)}\n  Priority: ${dv(r.priority) || 'None'} | Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Group: ${dv(r.assignment_group) || '-'} | Updated: ${dv(r.sys_updated_on)?.slice(0,10)}`;
}
function formatChangeRow(r) {
  return `${dv(r.number)} [${dv(r.state)}] ${dv(r.short_description)}\n  Type: ${dv(r.type)} | Risk: ${dv(r.risk)} | Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Planned: ${dv(r.planned_start_date)?.slice(0,10)}`;
}
function formatProblemRow(r) {
  return `${dv(r.number)} [${dv(r.state)}] ${dv(r.short_description)}\n  Priority: ${dv(r.priority) || 'None'} | Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Known error: ${dv(r.known_error) === 'true' ? 'Yes' : 'No'}`;
}
function formatScRequestRow(r) {
  return `${dv(r.number)} [${dv(r.state)}] ${dv(r.short_description)}\n  Requested for: ${dv(r.requested_for) || '-'}`;
}
function formatScReqItemRow(r) {
  return `${dv(r.number)} [${dv(r.state)}] ${dv(r.short_description)}\n  Catalog item: ${dv(r.cat_item) || '-'} | Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Parent: ${dv(r.request) || '-'}`;
}
function formatScTaskRow(r) {
  return `${dv(r.number)} [${dv(r.state)}] ${dv(r.short_description)}\n  Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Parent item: ${dv(r.request_item) || '-'}`;
}
function formatKbRow(r) {
  return `${dv(r.number)} [${dv(r.workflow_state)}] ${dv(r.short_description)}\n  Category: ${dv(r.kb_category) || '-'} | Views: ${dv(r.view_count) || 0} | Updated: ${dv(r.sys_updated_on)?.slice(0,10)}`;
}
function formatCiRow(r) {
  return `${dv(r.name)} (${dv(r.sys_class_name)})\n  Asset tag: ${dv(r.asset_tag) || '-'} | Serial: ${dv(r.serial_number) || '-'} | Owned by: ${dv(r.owned_by) || '-'} | Assigned to: ${dv(r.assigned_to) || 'Unassigned'} | Status: ${dv(r.install_status) || '-'}`;
}

function formatRecord(table, r) {
  switch (table) {
    case 'incident': return formatIncidentRow(r);
    case 'change_request': return formatChangeRow(r);
    case 'problem': return formatProblemRow(r);
    case 'sc_request': return formatScRequestRow(r);
    case 'sc_req_item': return formatScReqItemRow(r);
    case 'sc_task': return formatScTaskRow(r);
    case 'kb_knowledge': return formatKbRow(r);
    case 'cmdb_ci': return formatCiRow(r);
    default: return JSON.stringify(r);
  }
}

// ── Search helpers ----------------------------------------------------------

async function searchTable(table, args, config) {
  const limit = Math.min(args.count || 20, 100);
  const params = {
    ...DISPLAY_PARAMS,
    sysparm_limit: String(limit),
    sysparm_fields: FIELDS[table] || '',
  };
  if (args.query) params.sysparm_query = args.query;
  const data = await snRequest('GET', table, null, params, null, config);
  const results = data.result || [];
  if (results.length === 0) return `No ${table} records found${args.query ? ` for query: ${args.query}` : ''}`;
  return `Found ${results.length} ${table} record(s):\n\n` + results.map(r => formatRecord(table, r)).join('\n\n');
}

async function getRecord(table, args, config, formatter) {
  const sysId = args.sys_id || args.id;
  const number = args.number || args.key;
  let record;
  if (sysId) {
    const data = await snRequest('GET', table, sysId, DISPLAY_PARAMS, null, config);
    record = data.result;
  } else if (number) {
    const data = await snRequest('GET', table, null, {
      ...DISPLAY_PARAMS,
      sysparm_query: `number=${number}`,
      sysparm_limit: '1',
    }, null, config);
    record = (data.result || [])[0];
  } else {
    return `Error: sys_id or number required for ${table}`;
  }
  if (!record) return `${table} record not found`;
  return formatter(record, config);
}

// Detailed formatters used by single-record getters

function formatIncidentDetail(r, config) {
  let out = `${dv(r.number)}: ${dv(r.short_description)}\n`;
  out += `State: ${dv(r.state)} | Priority: ${dv(r.priority)} | Urgency: ${dv(r.urgency)} | Impact: ${dv(r.impact)}\n`;
  out += `Caller: ${dv(r.caller_id) || '-'} | Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Group: ${dv(r.assignment_group) || '-'}\n`;
  out += `Category: ${dv(r.category) || '-'} / ${dv(r.subcategory) || '-'}\n`;
  out += `Opened: ${dv(r.opened_at)?.slice(0,16)}${r.resolved_at ? ` | Resolved: ${dv(r.resolved_at)?.slice(0,16)}` : ''}\n`;
  out += `\nDescription:\n${dv(r.description) || '(none)'}`;
  if (r.comments) out += `\n\nComments (caller-visible):\n${dv(r.comments)}`;
  if (r.work_notes) out += `\n\nWork notes (internal):\n${dv(r.work_notes)}`;
  out += `\n\nURL: ${buildUrl(config, id(r.sys_id), 'incident')}`;
  return out;
}

function formatChangeDetail(r, config) {
  let out = `${dv(r.number)}: ${dv(r.short_description)}\n`;
  out += `State: ${dv(r.state)} | Type: ${dv(r.type)} | Category: ${dv(r.category)} | Priority: ${dv(r.priority)}\n`;
  out += `Risk: ${dv(r.risk)} | Impact: ${dv(r.impact)}\n`;
  out += `Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Group: ${dv(r.assignment_group) || '-'}\n`;
  out += `Planned: ${dv(r.planned_start_date)?.slice(0,16)} → ${dv(r.planned_end_date)?.slice(0,16)}\n`;
  out += `\nDescription:\n${dv(r.description) || '(none)'}`;
  if (r.justification) out += `\n\nJustification:\n${dv(r.justification)}`;
  out += `\n\nURL: ${buildUrl(config, id(r.sys_id), 'change_request')}`;
  return out;
}

function formatProblemDetail(r, config) {
  let out = `${dv(r.number)}: ${dv(r.short_description)}\n`;
  out += `State: ${dv(r.state)} | Priority: ${dv(r.priority)} | Known error: ${dv(r.known_error) === 'true' ? 'Yes' : 'No'}\n`;
  out += `Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Group: ${dv(r.assignment_group) || '-'}\n`;
  out += `\nDescription:\n${dv(r.description) || '(none)'}`;
  if (r.cause_notes) out += `\n\nRoot cause:\n${dv(r.cause_notes)}`;
  if (r.fix_notes) out += `\n\nFix notes:\n${dv(r.fix_notes)}`;
  if (r.workaround) out += `\n\nWorkaround:\n${dv(r.workaround)}`;
  out += `\n\nURL: ${buildUrl(config, id(r.sys_id), 'problem')}`;
  return out;
}

function formatScRequestDetail(r, config) {
  let out = `${dv(r.number)}: ${dv(r.short_description)}\n`;
  out += `State: ${dv(r.state)} | Requested for: ${dv(r.requested_for) || '-'} | Opened by: ${dv(r.opened_by) || '-'}\n`;
  if (r.description) out += `\nDescription:\n${dv(r.description)}`;
  out += `\n\nURL: ${buildUrl(config, id(r.sys_id), 'sc_request')}`;
  return out;
}

function formatScReqItemDetail(r, config) {
  let out = `${dv(r.number)}: ${dv(r.short_description)}\n`;
  out += `State: ${dv(r.state)} | Catalog item: ${dv(r.cat_item) || '-'}\n`;
  out += `Requested for: ${dv(r.requested_for) || '-'} | Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Parent request: ${dv(r.request) || '-'}\n`;
  if (r.description) out += `\nDescription:\n${dv(r.description)}`;
  out += `\n\nURL: ${buildUrl(config, id(r.sys_id), 'sc_req_item')}`;
  return out;
}

function formatScTaskDetail(r, config) {
  let out = `${dv(r.number)}: ${dv(r.short_description)}\n`;
  out += `State: ${dv(r.state)} | Assignee: ${dv(r.assigned_to) || 'Unassigned'} | Parent item: ${dv(r.request_item) || '-'}\n`;
  if (r.description) out += `\nDescription:\n${dv(r.description)}`;
  if (r.work_notes) out += `\n\nWork notes:\n${dv(r.work_notes)}`;
  out += `\n\nURL: ${buildUrl(config, id(r.sys_id), 'sc_task')}`;
  return out;
}

function formatKbDetail(r, config) {
  let out = `${dv(r.number)}: ${dv(r.short_description)}\n`;
  out += `State: ${dv(r.workflow_state)} | Category: ${dv(r.kb_category) || '-'} | Views: ${dv(r.view_count) || 0}\n`;
  if (r.text) out += `\n${dv(r.text)}\n`;
  out += `\nURL: ${config.url}/kb_view.do?sysparm_article=${dv(r.number)}`;
  return out;
}

function formatUserDetail(r) {
  let out = `${dv(r.name)} (${dv(r.user_name)})\n`;
  out += `Email: ${dv(r.email) || '-'} | Title: ${dv(r.title) || '-'}\n`;
  out += `Department: ${dv(r.department) || '-'} | Manager: ${dv(r.manager) || '-'}\n`;
  out += `Employee #: ${dv(r.employee_number) || '-'} | Active: ${dv(r.active)}\n`;
  out += `sys_id: ${id(r.sys_id)}`;
  return out;
}

function formatGroupDetail(r) {
  return `${dv(r.name)}\nManager: ${dv(r.manager) || '-'} | Email: ${dv(r.email) || '-'} | Active: ${dv(r.active)}\n${dv(r.description) || ''}\nsys_id: ${id(r.sys_id)}`;
}

function formatCiDetail(r, config) {
  let out = `${dv(r.name)} (${dv(r.sys_class_name)})\n`;
  out += `Asset tag: ${dv(r.asset_tag) || '-'} | Serial: ${dv(r.serial_number) || '-'}\n`;
  out += `Model: ${dv(r.model_id) || '-'} | Location: ${dv(r.location) || '-'}\n`;
  out += `Owned by: ${dv(r.owned_by) || '-'} | Assigned to: ${dv(r.assigned_to) || 'Unassigned'}\n`;
  out += `Status: ${dv(r.install_status) || '-'}\n`;
  out += `URL: ${buildUrl(config, id(r.sys_id), 'cmdb_ci')}`;
  return out;
}

// ── Tool entry point --------------------------------------------------------

async function servicenow(args) {
  const action = args.action;
  if (!action) {
    return 'Error: action required. Try: search_incidents, get_incident, create_incident, update_incident, comment_incident, resolve_incident, my_incidents, search_kb, get_kb_article, search_changes, get_change, create_change, update_change, search_problems, get_problem, search_requests, get_request, search_request_items, get_request_item, search_catalog_tasks, get_catalog_task, get_user, search_users, list_groups, search_ci, get_ci';
  }
  const config = getServiceNowConfig();
  if (!config) {
    return 'Error: ServiceNow not configured. Use Settings → ServiceNow to add your instance URL and credentials.';
  }

  switch (action) {
    // ── Incidents ──────────────────────────────────────────────────────────
    case 'search_incidents': {
      const filters = [];
      if (args.query) filters.push(args.query);
      if (args.state) filters.push(`state=${encodeURIComponent(args.state)}`);
      if (args.assignment_group) filters.push(`assignment_group.name=${encodeURIComponent(args.assignment_group)}`);
      if (args.assigned_to) filters.push(`assigned_to.user_name=${encodeURIComponent(args.assigned_to)}`);
      if (args.caller) filters.push(`caller_id.user_name=${encodeURIComponent(args.caller)}`);
      if (args.text) filters.push(`short_descriptionLIKE${encodeURIComponent(args.text)}^ORdescriptionLIKE${encodeURIComponent(args.text)}`);
      const merged = filters.length ? filters.join('^') + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
      return searchTable('incident', { ...args, query: merged }, config);
    }

    case 'my_incidents': {
      const query = 'assigned_to=javascript:gs.getUserID()^stateNOT IN6,7,8^ORDERBYDESCsys_updated_on';
      return searchTable('incident', { ...args, query }, config);
    }

    case 'get_incident':
      return getRecord('incident', args, config, formatIncidentDetail);

    case 'create_incident': {
      if (!args.short_description) return 'Error: short_description required';
      const fields = { short_description: args.short_description };
      if (args.description) fields.description = args.description;
      if (args.caller_id) fields.caller_id = args.caller_id;
      if (args.assignment_group) fields.assignment_group = args.assignment_group;
      if (args.assigned_to) fields.assigned_to = args.assigned_to;
      if (args.priority) fields.priority = String(args.priority);
      if (args.urgency) fields.urgency = String(args.urgency);
      if (args.impact) fields.impact = String(args.impact);
      if (args.category) fields.category = args.category;
      if (args.subcategory) fields.subcategory = args.subcategory;
      const data = await snRequest('POST', 'incident', null, DISPLAY_PARAMS, fields, config);
      const r = data.result;
      return `Incident created: ${dv(r.number)}\nURL: ${buildUrl(config, id(r.sys_id), 'incident')}`;
    }

    case 'update_incident': {
      const sysId = args.sys_id || args.id;
      const number = args.number || args.key;
      let target = sysId;
      if (!target && number) {
        const lookup = await snRequest('GET', 'incident', null, { sysparm_query: `number=${number}`, sysparm_limit: '1', sysparm_fields: 'sys_id' }, null, config);
        target = (lookup.result || [])[0]?.sys_id;
      }
      if (!target) return 'Error: sys_id or number required';
      const fields = {};
      for (const k of ['short_description','description','state','priority','urgency','impact','assignment_group','assigned_to','category','subcategory','close_code','close_notes','work_notes','comments']) {
        if (args[k] !== undefined) fields[k] = String(args[k]);
      }
      if (Object.keys(fields).length === 0) return 'Error: no fields to update';
      const data = await snRequest('PATCH', 'incident', target, DISPLAY_PARAMS, fields, config);
      const r = data.result;
      return `Updated ${dv(r.number)}: state=${dv(r.state)}, assignee=${dv(r.assigned_to) || 'Unassigned'}`;
    }

    case 'comment_incident': {
      const sysId = args.sys_id || args.id;
      const number = args.number || args.key;
      let target = sysId;
      if (!target && number) {
        const lookup = await snRequest('GET', 'incident', null, { sysparm_query: `number=${number}`, sysparm_limit: '1', sysparm_fields: 'sys_id' }, null, config);
        target = (lookup.result || [])[0]?.sys_id;
      }
      if (!target) return 'Error: sys_id or number required';
      const text = args.body || args.text || args.comment;
      if (!text) return 'Error: body required';
      const field = args.internal ? 'work_notes' : 'comments';
      const data = await snRequest('PATCH', 'incident', target, DISPLAY_PARAMS, { [field]: text }, config);
      return `Added ${field === 'work_notes' ? 'internal work note' : 'customer-visible comment'} to ${dv(data.result.number)}`;
    }

    case 'resolve_incident': {
      const sysId = args.sys_id || args.id;
      const number = args.number || args.key;
      let target = sysId;
      if (!target && number) {
        const lookup = await snRequest('GET', 'incident', null, { sysparm_query: `number=${number}`, sysparm_limit: '1', sysparm_fields: 'sys_id' }, null, config);
        target = (lookup.result || [])[0]?.sys_id;
      }
      if (!target) return 'Error: sys_id or number required';
      const closeCode = args.close_code || 'Solved (Permanently)';
      const closeNotes = args.close_notes || args.notes || args.resolution;
      if (!closeNotes) return 'Error: close_notes/resolution required';
      const data = await snRequest('PATCH', 'incident', target, DISPLAY_PARAMS, {
        state: '6',
        close_code: closeCode,
        close_notes: closeNotes,
      }, config);
      return `Resolved ${dv(data.result.number)} with code "${closeCode}"`;
    }

    // ── Knowledge Base ────────────────────────────────────────────────────
    case 'search_kb': {
      const filters = [];
      if (args.text) filters.push(`short_descriptionLIKE${encodeURIComponent(args.text)}^ORtextLIKE${encodeURIComponent(args.text)}`);
      if (args.category) filters.push(`kb_category.label=${encodeURIComponent(args.category)}`);
      if (args.query) filters.push(args.query);
      filters.push('workflow_state=published');
      const merged = filters.join('^') + '^ORDERBYDESCview_count';
      return searchTable('kb_knowledge', { ...args, query: merged }, config);
    }

    case 'get_kb_article':
      return getRecord('kb_knowledge', args, config, formatKbDetail);

    // ── Changes ───────────────────────────────────────────────────────────
    case 'search_changes': {
      const filters = [];
      if (args.query) filters.push(args.query);
      if (args.state) filters.push(`state=${encodeURIComponent(args.state)}`);
      if (args.type) filters.push(`type=${encodeURIComponent(args.type)}`);
      if (args.assigned_to) filters.push(`assigned_to.user_name=${encodeURIComponent(args.assigned_to)}`);
      const merged = filters.length ? filters.join('^') + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
      return searchTable('change_request', { ...args, query: merged }, config);
    }

    case 'get_change':
      return getRecord('change_request', args, config, formatChangeDetail);

    case 'create_change': {
      if (!args.short_description) return 'Error: short_description required';
      const fields = { short_description: args.short_description };
      for (const k of ['description','type','category','risk','impact','priority','assigned_to','assignment_group','planned_start_date','planned_end_date','justification']) {
        if (args[k] !== undefined) fields[k] = String(args[k]);
      }
      const data = await snRequest('POST', 'change_request', null, DISPLAY_PARAMS, fields, config);
      const r = data.result;
      return `Change created: ${dv(r.number)}\nURL: ${buildUrl(config, id(r.sys_id), 'change_request')}`;
    }

    case 'update_change': {
      const sysId = args.sys_id || args.id;
      const number = args.number || args.key;
      let target = sysId;
      if (!target && number) {
        const lookup = await snRequest('GET', 'change_request', null, { sysparm_query: `number=${number}`, sysparm_limit: '1', sysparm_fields: 'sys_id' }, null, config);
        target = (lookup.result || [])[0]?.sys_id;
      }
      if (!target) return 'Error: sys_id or number required';
      const fields = {};
      for (const k of ['short_description','description','state','type','category','risk','impact','priority','assignment_group','assigned_to','planned_start_date','planned_end_date','justification','work_notes']) {
        if (args[k] !== undefined) fields[k] = String(args[k]);
      }
      if (Object.keys(fields).length === 0) return 'Error: no fields to update';
      const data = await snRequest('PATCH', 'change_request', target, DISPLAY_PARAMS, fields, config);
      return `Updated ${dv(data.result.number)}: state=${dv(data.result.state)}`;
    }

    // ── Problems ──────────────────────────────────────────────────────────
    case 'search_problems': {
      const filters = [];
      if (args.query) filters.push(args.query);
      if (args.state) filters.push(`state=${encodeURIComponent(args.state)}`);
      if (args.known_error) filters.push('known_error=true');
      const merged = filters.length ? filters.join('^') + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
      return searchTable('problem', { ...args, query: merged }, config);
    }

    case 'get_problem':
      return getRecord('problem', args, config, formatProblemDetail);

    // ── Service Catalog ───────────────────────────────────────────────────
    case 'search_requests':
      return searchTable('sc_request', { ...args, query: args.query || 'ORDERBYDESCsys_updated_on' }, config);
    case 'get_request':
      return getRecord('sc_request', args, config, formatScRequestDetail);

    case 'search_request_items': {
      const filters = [];
      if (args.query) filters.push(args.query);
      if (args.state) filters.push(`state=${encodeURIComponent(args.state)}`);
      if (args.assigned_to) filters.push(`assigned_to.user_name=${encodeURIComponent(args.assigned_to)}`);
      if (args.request) filters.push(`request.number=${encodeURIComponent(args.request)}`);
      const merged = filters.length ? filters.join('^') + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
      return searchTable('sc_req_item', { ...args, query: merged }, config);
    }
    case 'get_request_item':
      return getRecord('sc_req_item', args, config, formatScReqItemDetail);

    case 'search_catalog_tasks': {
      const filters = [];
      if (args.query) filters.push(args.query);
      if (args.state) filters.push(`state=${encodeURIComponent(args.state)}`);
      if (args.assigned_to) filters.push(`assigned_to.user_name=${encodeURIComponent(args.assigned_to)}`);
      const merged = filters.length ? filters.join('^') + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
      return searchTable('sc_task', { ...args, query: merged }, config);
    }
    case 'get_catalog_task':
      return getRecord('sc_task', args, config, formatScTaskDetail);

    // ── Users & groups ────────────────────────────────────────────────────
    case 'get_user': {
      const lookup = args.user_name || args.username || args.email || args.sys_id || args.id;
      if (!lookup) return 'Error: user_name, email, or sys_id required';
      if (args.sys_id || args.id) {
        const data = await snRequest('GET', 'sys_user', args.sys_id || args.id, DISPLAY_PARAMS, null, config);
        return data.result ? formatUserDetail(data.result) : 'User not found';
      }
      const field = args.email ? 'email' : 'user_name';
      const data = await snRequest('GET', 'sys_user', null, {
        ...DISPLAY_PARAMS,
        sysparm_query: `${field}=${lookup}`,
        sysparm_limit: '1',
      }, null, config);
      const r = (data.result || [])[0];
      return r ? formatUserDetail(r) : `No user found with ${field}=${lookup}`;
    }

    case 'search_users': {
      const text = args.text || args.query;
      if (!text) return 'Error: text required';
      const query = `nameLIKE${text}^ORuser_nameLIKE${text}^ORemailLIKE${text}^active=true`;
      return searchTable('sys_user', { ...args, query }, config);
    }

    case 'list_groups': {
      const filters = ['active=true'];
      if (args.text) filters.push(`nameLIKE${encodeURIComponent(args.text)}`);
      const data = await snRequest('GET', 'sys_user_group', null, {
        ...DISPLAY_PARAMS,
        sysparm_query: filters.join('^') + '^ORDERBYname',
        sysparm_limit: String(Math.min(args.count || 50, 200)),
        sysparm_fields: FIELDS.sys_user_group,
      }, null, config);
      const results = data.result || [];
      if (results.length === 0) return 'No groups found';
      return `Found ${results.length} group(s):\n\n` + results.map(formatGroupDetail).join('\n\n');
    }

    // ── CMDB ──────────────────────────────────────────────────────────────
    case 'search_ci': {
      const filters = [];
      if (args.query) filters.push(args.query);
      if (args.text) filters.push(`nameLIKE${encodeURIComponent(args.text)}^ORasset_tag=${encodeURIComponent(args.text)}^ORserial_number=${encodeURIComponent(args.text)}`);
      if (args.assigned_to) filters.push(`assigned_to.user_name=${encodeURIComponent(args.assigned_to)}`);
      if (args.class) filters.push(`sys_class_name=${encodeURIComponent(args.class)}`);
      const merged = filters.length ? filters.join('^') + '^ORDERBYDESCsys_updated_on' : 'ORDERBYname';
      return searchTable('cmdb_ci', { ...args, query: merged }, config);
    }
    case 'get_ci':
      return getRecord('cmdb_ci', args, config, formatCiDetail);

    default:
      return `Error: unknown servicenow action '${action}'.`;
  }
}

module.exports = { servicenow };
