#!/usr/bin/env node
// Stdio MCP server: sandboxes file operations to SANDBOX_ROOT.
// Used as a goosed extension to replace the built-in developer extension for
// regular users — enforces path boundaries at the OS level rather than relying
// on AI model compliance with text constraints.
//
// SANDBOX_ROOT is passed as argv[2] (primary) or SANDBOX_ROOT env var (fallback).
// argv[2] is preferred because some goosed versions don't forward env to stdio MCPs.
// Optional env:  ALLOWED_TOOLS — comma-separated tool whitelist (default: all tools)

import path from 'node:path';
import fs from 'node:fs';
import readline from 'node:readline';
import { execFileSync } from 'node:child_process';
import mysql from 'mysql2/promise';
import { createScheduleService } from './schedule-service.mjs';

const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
if (!SANDBOX_ROOT) {
  process.stderr.write('[mindspace-sandbox-mcp] SANDBOX_ROOT is not set — refusing to start\n');
  process.exit(1);
}

const SANDBOX = path.resolve(SANDBOX_ROOT);
const PRIVATE_DATA_DIR = path.join(SANDBOX, '.mindspace');
const PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, 'private-data.sqlite');
const SQLITE_BIN = process.env.SQLITE_BIN?.trim() || 'sqlite3';
const PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024);
const PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5000);
const PRIVATE_DATA_MAX_ROWS = Number(process.env.PRIVATE_DATA_MAX_ROWS ?? 200);
const PRIVATE_DATA_USER_ID = process.env.PRIVATE_DATA_USER_ID?.trim();

const allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim();
const ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(',').map((s) => s.trim())) : null;

/** Resolve and validate that the path is inside SANDBOX. Returns absolute path. */
function resolveSandboxed(p) {
  if (!p || typeof p !== 'string') throw new Error('路径参数无效');
  const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(SANDBOX, p);
  if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) {
    throw Object.assign(new Error(`路径越界：${p} 不在当前工作区内`), { code: 'EACCES' });
  }
  return resolved;
}

const ALL_TOOLS = [
  {
    name: 'read_file',
    description: '读取文件内容（仅限工作区内的文件）',
    inputSchema: {
      type: 'object',
      properties: {
        path: { type: 'string', description: '文件路径（相对工作区或绝对路径）' },
      },
      required: ['path'],
    },
  },
  {
    name: 'write_file',
    description: '写入文件内容（仅限工作区内的文件；不存在则创建，已存在则覆盖）',
    inputSchema: {
      type: 'object',
      properties: {
        path: { type: 'string', description: '文件路径' },
        content: { type: 'string', description: '文件内容' },
      },
      required: ['path', 'content'],
    },
  },
  {
    name: 'edit_file',
    description: '将文件中的旧内容替换为新内容（仅限工作区内的文件）',
    inputSchema: {
      type: 'object',
      properties: {
        path: { type: 'string', description: '文件路径' },
        old_str: { type: 'string', description: '要替换的原始内容（必须在文件中唯一）' },
        new_str: { type: 'string', description: '替换后的新内容' },
      },
      required: ['path', 'old_str', 'new_str'],
    },
  },
  {
    name: 'list_dir',
    description: '列出目录内容（仅限工作区内的目录）',
    inputSchema: {
      type: 'object',
      properties: {
        path: {
          type: 'string',
          description: '目录路径（省略则列出工作区根目录）',
        },
      },
    },
  },
  {
    name: 'create_dir',
    description: '创建目录（仅限工作区内；已存在不报错）',
    inputSchema: {
      type: 'object',
      properties: {
        path: { type: 'string', description: '目录路径' },
      },
      required: ['path'],
    },
  },
  {
    name: 'private_data_info',
    description:
      '查看当前用户“用户私有数据空间”的状态。每个用户只有一个私有 SQLite 数据库，适合保存问卷、表单、清单、调研数据和分析中间表；不要创建额外 SQLite 文件。',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'private_data_schema',
    description:
      '查看用户私有数据空间中的表和字段。用于了解当前用户自己的 SQLite 表结构。',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'private_data_query',
    description:
      '只读查询用户私有数据空间。仅允许 SELECT/WITH，默认最多返回 200 行，适合统计问卷回答、筛选表单数据、分析用户私有数据。',
    inputSchema: {
      type: 'object',
      properties: {
        sql: { type: 'string', description: '只读 SQL，必须是 SELECT/WITH' },
      },
      required: ['sql'],
    },
  },
  {
    name: 'private_data_execute',
    description:
      '写入或变更用户私有数据空间。可用于创建问卷/表单/清单等私有表并写入数据；禁止 ATTACH、load_extension、PRAGMA writable_schema、VACUUM INTO 等越界或危险操作。',
    inputSchema: {
      type: 'object',
      properties: {
        sql: { type: 'string', description: '要执行的 SQLite SQL' },
      },
      required: ['sql'],
    },
  },
];

let quotaPool = null;
let scheduleService = null;

function isQuotaSyncConfigured() {
  return Boolean(
    PRIVATE_DATA_USER_ID &&
      (process.env.DATABASE_URL ||
        (process.env.MYSQL_HOST && process.env.MYSQL_DATABASE)),
  );
}

function getQuotaPool() {
  if (!isQuotaSyncConfigured()) return null;
  if (quotaPool) return quotaPool;
  quotaPool = process.env.DATABASE_URL
    ? mysql.createPool(process.env.DATABASE_URL)
    : mysql.createPool({
        host: process.env.MYSQL_HOST ?? 'localhost',
        port: Number(process.env.MYSQL_PORT ?? 3306),
        user: process.env.MYSQL_USER ?? 'boot',
        password: process.env.MYSQL_PASSWORD ?? '',
        database: process.env.MYSQL_DATABASE ?? 'tkmind',
        waitForConnections: true,
        connectionLimit: 2,
      });
  return quotaPool;
}

function isScheduleConfigured() {
  return isQuotaSyncConfigured();
}

function getScheduleService() {
  if (!PRIVATE_DATA_USER_ID) throw new Error('当前会话没有用户上下文，不能操作待办');
  const pool = getQuotaPool();
  if (!pool || !isScheduleConfigured()) {
    throw new Error('当前环境未配置待办数据库连接');
  }
  if (!scheduleService) {
    scheduleService = createScheduleService(pool, {
      defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
    });
  }
  return scheduleService;
}

if (isScheduleConfigured()) {
  ALL_TOOLS.push(
    {
      name: 'schedule_create_item',
      description:
        '为当前用户创建待办或日程事项。仅写入当前用户自己的 h5_schedule_items，不可操作其他用户数据。',
      inputSchema: {
        type: 'object',
        properties: {
          kind: { type: 'string', description: 'task 或 event，默认 task' },
          title: { type: 'string', description: '事项标题' },
          description: { type: 'string', description: '事项描述，可选' },
          startAt: { type: 'number', description: '开始时间 Unix 毫秒时间戳，可选' },
          endAt: { type: 'number', description: '结束时间 Unix 毫秒时间戳，可选' },
          dueAt: { type: 'number', description: '截止时间 Unix 毫秒时间戳，可选' },
          allDay: { type: 'boolean', description: '是否全天事项，可选' },
          timezone: { type: 'string', description: '时区，可选' },
          location: { type: 'string', description: '地点，可选' },
          sourceMessageId: { type: 'string', description: '服务号消息 ID；有值时必须原样传入' },
          sourceText: { type: 'string', description: '原始用户文本，可选' },
        },
        required: ['title'],
      },
    },
    {
      name: 'schedule_create_reminder',
      description:
        '为当前用户已有事项创建单次提醒。必须先有 itemId，再写入 h5_schedule_reminders。',
      inputSchema: {
        type: 'object',
        properties: {
          itemId: { type: 'string', description: '事项 ID' },
          remindAt: { type: 'number', description: '提醒触发时间 Unix 毫秒时间戳' },
          offsetMinutes: { type: 'number', description: '相对事项时间的提前分钟数，可选' },
          channel: { type: 'string', description: '提醒通道，默认 wechat' },
        },
        required: ['itemId', 'remindAt'],
      },
    },
    {
      name: 'schedule_list_items',
      description:
        '查询当前用户的待办/日程事项列表。仅返回当前用户数据，可按时间范围过滤。',
      inputSchema: {
        type: 'object',
        properties: {
          from: { type: 'number', description: '起始时间 Unix 毫秒时间戳，可选' },
          to: { type: 'number', description: '结束时间 Unix 毫秒时间戳，可选' },
          status: { type: 'string', description: '事项状态，默认 active，可选' },
          limit: { type: 'number', description: '返回数量，默认 50，可选' },
        },
      },
    },
  );
}

const TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS;

function ensurePrivateDataDb() {
  fs.mkdirSync(PRIVATE_DATA_DIR, { recursive: true });
  if (!fs.existsSync(PRIVATE_DATA_DB)) {
    runSqlite(['-batch', PRIVATE_DATA_DB, 'PRAGMA journal_mode=WAL; PRAGMA user_version = 1;']);
  }
  return PRIVATE_DATA_DB;
}

function privateDataSize() {
  let total = 0;
  for (const file of fs.existsSync(PRIVATE_DATA_DIR) ? fs.readdirSync(PRIVATE_DATA_DIR) : []) {
    if (file === 'private-data.sqlite' || file.startsWith('private-data.sqlite-')) {
      total += fs.statSync(path.join(PRIVATE_DATA_DIR, file)).size;
    }
  }
  return total;
}

function runSqlite(args) {
  return execFileSync(SQLITE_BIN, args, {
    encoding: 'utf8',
    timeout: PRIVATE_DATA_QUERY_TIMEOUT_MS,
    maxBuffer: 1024 * 1024,
  });
}

function sqliteScalar(sql) {
  return Number(runSqlite(['-batch', '-noheader', PRIVATE_DATA_DB, sql]).trim() || 0);
}

async function getQuotaState({ forUpdate = false, conn = null } = {}) {
  const pool = getQuotaPool();
  if (!pool) return null;
  const db = conn ?? pool;
  const [rows] = await db.query(
    `SELECT id, quota_bytes, used_bytes, reserved_bytes, status
     FROM h5_user_spaces
     WHERE user_id = ?
     LIMIT 1 ${forUpdate ? 'FOR UPDATE' : ''}`,
    [PRIVATE_DATA_USER_ID],
  );
  const row = rows[0];
  if (!row) return null;
  const quotaBytes = Number(row.quota_bytes ?? 0);
  const usedBytes = Number(row.used_bytes ?? 0);
  const reservedBytes = Number(row.reserved_bytes ?? 0);
  return {
    id: row.id,
    status: row.status,
    quotaBytes,
    usedBytes,
    reservedBytes,
    availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes),
  };
}

async function sqliteMaxPagePragmaForQuota(currentBytes) {
  const quota = await getQuotaState();
  if (!quota) return '';
  if (quota.status !== 'active') throw new Error('用户空间不可写');
  if (quota.availableBytes <= 0 && currentBytes >= PRIVATE_DATA_MAX_BYTES) {
    throw new Error('用户私有数据空间配额不足');
  }
  const allowedSize = Math.min(PRIVATE_DATA_MAX_BYTES, currentBytes + quota.availableBytes);
  const pageSize = sqliteScalar('PRAGMA page_size;') || 4096;
  const currentPages = sqliteScalar('PRAGMA page_count;') || 1;
  const maxPages = Math.max(currentPages, Math.max(1, Math.floor(allowedSize / pageSize)));
  return `PRAGMA max_page_count=${maxPages};`;
}

async function syncPrivateDataQuota(deltaBytes) {
  const pool = getQuotaPool();
  if (!pool || !deltaBytes) return null;
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    const quota = await getQuotaState({ forUpdate: true, conn });
    if (!quota) {
      await conn.rollback();
      return null;
    }
    if (quota.status !== 'active') throw new Error('用户空间不可写');
    if (deltaBytes > quota.availableBytes) {
      throw new Error(`用户私有数据空间配额不足：需要 ${deltaBytes} 字节，可用 ${quota.availableBytes} 字节`);
    }
    await conn.query(
      `UPDATE h5_user_spaces
       SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ?
       WHERE id = ? AND user_id = ?`,
      [deltaBytes, Date.now(), quota.id, PRIVATE_DATA_USER_ID],
    );
    await conn.commit();
    return { deltaBytes };
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();
  }
}

function stripSqlComments(sql) {
  return String(sql ?? '')
    .replace(/--.*$/gm, '')
    .replace(/\/\*[\s\S]*?\*\//g, '')
    .trim();
}

function rejectDangerousSql(sql, { readonly = false } = {}) {
  const cleaned = stripSqlComments(sql);
  if (!cleaned) throw new Error('SQL 不能为空');
  if (cleaned.length > 20000) throw new Error('SQL 过长');
  if (/^\s*\./m.test(cleaned)) {
    throw new Error('SQL 不允许使用 sqlite3 dot command');
  }
  if (/\b(ATTACH|DETACH|LOAD_EXTENSION|VACUUM\s+INTO)\b/i.test(cleaned)) {
    throw new Error('SQL 包含用户私有数据空间不允许的操作');
  }
  if (/\bPRAGMA\s+writable_schema\b/i.test(cleaned)) {
    throw new Error('SQL 包含不允许的 PRAGMA');
  }
  if (readonly && !/^\s*(SELECT|WITH)\b/i.test(cleaned)) {
    throw new Error('private_data_query 只允许 SELECT/WITH');
  }
  return cleaned;
}

async function queryPrivateData(sql) {
  const db = ensurePrivateDataDb();
  const cleaned = rejectDangerousSql(sql, { readonly: true });
  const limited = `SELECT * FROM (${cleaned.replace(/;\s*$/, '')}) LIMIT ${PRIVATE_DATA_MAX_ROWS}`;
  const output = runSqlite(['-json', db, limited]);
  return output.trim() || '[]';
}

async function executePrivateData(sql) {
  const db = ensurePrivateDataDb();
  const before = privateDataSize();
  if (before > PRIVATE_DATA_MAX_BYTES) throw new Error('用户私有数据空间已超过大小限制');
  const cleaned = rejectDangerousSql(sql);
  const quotaPragma = await sqliteMaxPagePragmaForQuota(before);
  runSqlite(['-batch', db, `${quotaPragma}\n${cleaned}`]);
  const after = privateDataSize();
  if (after > PRIVATE_DATA_MAX_BYTES) {
    throw new Error(`用户私有数据空间超过大小限制：${after}/${PRIVATE_DATA_MAX_BYTES} 字节`);
  }
  const delta = after - before;
  const quotaSync = await syncPrivateDataQuota(delta);
  return `已执行。当前数据空间大小 ${after} 字节${quotaSync ? `，已同步空间占用 ${delta} 字节` : ''}`;
}

async function privateDataSchema() {
  const db = ensurePrivateDataDb();
  const output = runSqlite([
    '-json',
    db,
    `SELECT m.name AS table_name, p.cid, p.name AS column_name, p.type, p."notnull" AS not_null, p.pk
     FROM sqlite_master m
     JOIN pragma_table_info(m.name) p
     WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
     ORDER BY m.name, p.cid`,
  ]);
  return output.trim() || '[]';
}

async function callTool(name, args) {
  if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) {
    throw new Error(`工具 ${name} 未授权`);
  }
  switch (name) {
    case 'read_file': {
      const abs = resolveSandboxed(args.path);
      const content = fs.readFileSync(abs, 'utf8');
      return [{ type: 'text', text: content }];
    }
    case 'write_file': {
      const abs = resolveSandboxed(args.path);
      fs.mkdirSync(path.dirname(abs), { recursive: true });
      fs.writeFileSync(abs, args.content ?? '', 'utf8');
      return [{ type: 'text', text: `已写入 ${args.path}（${(args.content ?? '').length} 字节）` }];
    }
    case 'edit_file': {
      const abs = resolveSandboxed(args.path);
      const original = fs.readFileSync(abs, 'utf8');
      const oldStr = args.old_str ?? '';
      if (oldStr && !original.includes(oldStr)) {
        throw new Error('edit_file: old_str 在文件中不存在，替换失败');
      }
      const updated = oldStr ? original.replace(oldStr, args.new_str ?? '') : (args.new_str ?? '');
      fs.writeFileSync(abs, updated, 'utf8');
      return [{ type: 'text', text: `已编辑 ${args.path}` }];
    }
    case 'list_dir': {
      const target = args?.path ?? '.';
      const abs = resolveSandboxed(target);
      const entries = fs.readdirSync(abs, { withFileTypes: true });
      const lines = entries.map((e) => `${e.isDirectory() ? '[目录]' : '[文件]'} ${e.name}`);
      return [{ type: 'text', text: lines.join('\n') || '（空目录）' }];
    }
    case 'create_dir': {
      const abs = resolveSandboxed(args.path);
      fs.mkdirSync(abs, { recursive: true });
      return [{ type: 'text', text: `已创建目录 ${args.path}` }];
    }
    case 'private_data_info': {
      ensurePrivateDataDb();
      const size = privateDataSize();
      const quota = await getQuotaState().catch(() => null);
      return [
        {
          type: 'text',
          text: JSON.stringify(
            {
              name: '用户私有数据空间',
              database: '.mindspace/private-data.sqlite',
              maxBytes: PRIVATE_DATA_MAX_BYTES,
              sizeBytes: size,
              quotaSyncEnabled: Boolean(getQuotaPool()),
              quota: quota
                ? {
                    status: quota.status,
                    quotaBytes: quota.quotaBytes,
                    usedBytes: quota.usedBytes,
                    reservedBytes: quota.reservedBytes,
                    availableBytes: quota.availableBytes,
                  }
                : null,
              rules: [
                '每个用户只能使用这个唯一 SQLite 数据库',
                '适合问卷、表单、清单、调研数据和分析中间表',
                '不要存账号、计费、权限、审计、公开平台数据或跨用户数据',
              ],
            },
            null,
            2,
          ),
        },
      ];
    }
    case 'private_data_schema':
      return [{ type: 'text', text: await privateDataSchema() }];
    case 'private_data_query':
      return [{ type: 'text', text: await queryPrivateData(args.sql) }];
    case 'private_data_execute':
      return [{ type: 'text', text: await executePrivateData(args.sql) }];
    case 'schedule_create_item': {
      const item = await getScheduleService().createItem({
        userId: PRIVATE_DATA_USER_ID,
        kind: args.kind,
        title: args.title,
        description: args.description ?? null,
        startAt: args.startAt ?? null,
        endAt: args.endAt ?? null,
        dueAt: args.dueAt ?? null,
        allDay: Boolean(args.allDay),
        timezone: args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai',
        location: args.location ?? null,
        sourceChannel: 'agent',
        sourceMessageId: args.sourceMessageId ?? null,
        sourceText: args.sourceText ?? null,
        metadata: { source: 'schedule_assistant_skill' },
      });
      return [{ type: 'text', text: JSON.stringify(item, null, 2) }];
    }
    case 'schedule_create_reminder': {
      const reminder = await getScheduleService().createReminder({
        userId: PRIVATE_DATA_USER_ID,
        itemId: args.itemId,
        remindAt: args.remindAt,
        offsetMinutes: args.offsetMinutes ?? null,
        channel: args.channel ?? 'wechat',
      });
      return [{ type: 'text', text: JSON.stringify(reminder, null, 2) }];
    }
    case 'schedule_list_items': {
      const items = await getScheduleService().listItems({
        userId: PRIVATE_DATA_USER_ID,
        from: args.from ?? null,
        to: args.to ?? null,
        status: args.status ?? 'active',
        limit: args.limit ?? 50,
      });
      return [{ type: 'text', text: JSON.stringify(items, null, 2) }];
    }
    default:
      throw new Error(`未知工具：${name}`);
  }
}

// ── JSON-RPC over stdio ────────────────────────────────────────────────────

function send(obj) {
  process.stdout.write(JSON.stringify(obj) + '\n');
}

function respond(id, result) {
  send({ jsonrpc: '2.0', id, result });
}

function respondError(id, message, code = -32603) {
  send({ jsonrpc: '2.0', id, error: { code, message } });
}

const rl = readline.createInterface({ input: process.stdin, terminal: false });

rl.on('line', async (raw) => {
  const line = raw.trim();
  if (!line) return;

  let msg;
  try {
    msg = JSON.parse(line);
  } catch {
    process.stderr.write(`[mindspace-sandbox-mcp] invalid JSON: ${line}\n`);
    return;
  }

  const { id, method, params } = msg;

  switch (method) {
    case 'initialize':
      respond(id, {
        protocolVersion: '2024-11-05',
        serverInfo: { name: 'mindspace-sandbox', version: '1.0.0' },
        capabilities: { tools: {} },
      });
      break;

    case 'initialized':
      // notification — no response
      break;

    case 'tools/list':
      respond(id, { tools: TOOLS });
      break;

    case 'tools/call': {
      const { name, arguments: toolArgs } = params ?? {};
      try {
        const content = await callTool(name, toolArgs ?? {});
        respond(id, { content, isError: false });
      } catch (err) {
        respond(id, {
          content: [{ type: 'text', text: err.message }],
          isError: true,
        });
      }
      break;
    }

    default:
      if (id !== undefined && id !== null) {
        respondError(id, `Method not found: ${method}`, -32601);
      }
  }
});

rl.on('close', () => process.exit(0));
