'use strict';

const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { PassThrough } = require('stream');
const { pathToFileURL } = require('url');

const SERVER = path.join(__dirname, '..', '..', 'scripts', 'memory-mcp.mjs');
const {
  MAX_RESULTS,
  resolveVaultRoots,
  saveMemory,
} = require('../../scripts/lib/memory-vault');

let passed = 0;
let failed = 0;

async function test(name, fn) {
  try {
    await fn();
    console.log(`  PASS ${name}`);
    passed += 1;
  } catch (error) {
    console.log(`  FAIL ${name}`);
    console.log(`    ${error.stack || error.message}`);
    failed += 1;
  }
}

function createFixture(extraEnv = {}) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-mcp-'));
  const projectRoot = path.join(root, 'project');
  const homeDir = path.join(root, 'home');
  fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
  fs.mkdirSync(homeDir, { recursive: true });
  return {
    root,
    projectRoot,
    env: Object.fromEntries(
      Object.entries({
        ...process.env,
        HOME: homeDir,
        USERPROFILE: homeDir,
        ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'),
        ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'),
        ECC_MEMORY_HARNESS: 'claude',
        ...extraEnv,
      }).filter(([, value]) => typeof value === 'string')
    ),
  };
}

function parseTextResult(result) {
  const text = result.content?.find(item => item.type === 'text')?.text;
  assert.ok(text, 'MCP result should contain text');
  return JSON.parse(text);
}

async function withClient(fn, options = {}) {
  const fixture = createFixture(options.env);
  const child = spawn(process.execPath, [options.server || SERVER], {
    cwd: fixture.projectRoot,
    env: fixture.env,
    stdio: ['pipe', 'pipe', 'pipe'],
  });
  const pending = new Map();
  let nextId = 1;
  let stdout = '';
  let stderr = '';

  child.stdout.on('data', chunk => {
    stdout += chunk.toString('utf8');
    let newlineIndex = stdout.indexOf('\n');
    while (newlineIndex >= 0) {
      const line = stdout.slice(0, newlineIndex);
      stdout = stdout.slice(newlineIndex + 1);
      if (line.trim()) {
        const message = JSON.parse(line);
        const waiter = pending.get(message.id);
        if (waiter) {
          pending.delete(message.id);
          if (message.error) {
            waiter.reject(new Error(`${message.error.code}: ${message.error.message}`));
          } else {
            waiter.resolve(message.result);
          }
        }
      }
      newlineIndex = stdout.indexOf('\n');
    }
  });
  child.stderr.on('data', chunk => {
    stderr += chunk.toString('utf8');
  });

  function send(message) {
    child.stdin.write(`${JSON.stringify(message)}\n`);
  }

  function request(method, params = {}) {
    const id = nextId;
    nextId += 1;
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        pending.delete(id);
        reject(new Error(`Timed out waiting for ${method}. stderr: ${stderr}`));
      }, 5000);
      pending.set(id, {
        resolve: value => {
          clearTimeout(timeout);
          resolve(value);
        },
        reject: error => {
          clearTimeout(timeout);
          reject(error);
        },
      });
      send({ jsonrpc: '2.0', id, method, params });
    });
  }

  const initialized = await request('initialize', {
    protocolVersion: '2025-11-25',
    capabilities: {},
    clientInfo: { name: 'ecc-memory-test', version: '1.0.0' },
  });
  assert.strictEqual(initialized.protocolVersion, '2025-11-25');
  send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });

  const client = {
    listTools: () => request('tools/list'),
    callTool: ({ name, arguments: toolArguments }) => request(
      'tools/call',
      { name, arguments: toolArguments }
    ),
  };

  try {
    await fn(client, fixture);
  } finally {
    child.stdin.end();
    await new Promise(resolve => {
      if (child.exitCode !== null) {
        resolve();
        return;
      }
      const timeout = setTimeout(() => {
        child.kill();
        resolve();
      }, 2000);
      child.once('exit', () => {
        clearTimeout(timeout);
        resolve();
      });
    });
    fs.rmSync(fixture.root, { recursive: true, force: true });
  }
}

async function main() {
  console.log('\n=== Testing ECC memory MCP server ===\n');

  await test('registers the bounded read/write/search/doctor tool surface', async () => {
    await withClient(async client => {
      const tools = await client.listTools();
      assert.deepStrictEqual(
        tools.tools.map(tool => tool.name).sort(),
        ['memory_doctor', 'memory_read', 'memory_save', 'memory_search']
      );
      const save = tools.tools.find(tool => tool.name === 'memory_save');
      const search = tools.tools.find(tool => tool.name === 'memory_search');
      assert.ok(save.description.includes('unreviewed'));
      assert.ok(!JSON.stringify(save.inputSchema).includes('trust'));
      assert.ok(!JSON.stringify(save.inputSchema).includes('sourceHarness'));
      assert.ok(!JSON.stringify(search.inputSchema).includes('targetHarness'));
      assert.strictEqual(save.inputSchema.properties.body.minLength, 1);
    });
  });

  await test('starts when the npm bin invokes the server through a symlink', async () => {
    const binRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-bin-'));
    const binPath = path.join(binRoot, 'ecc-memory-mcp');
    fs.symlinkSync(SERVER, binPath);
    try {
      await withClient(async client => {
        const tools = await client.listTools();
        assert.strictEqual(tools.tools.length, 4);
      }, { server: binPath });
    } finally {
      fs.rmSync(binRoot, { recursive: true, force: true });
    }
  });

  await test('rejects an oversized partial line and recovers at the next message boundary', async () => {
    const {
      MAX_MESSAGE_BYTES,
      runStdioServer,
    } = await import(pathToFileURL(SERVER).href);
    const input = new PassThrough();
    const output = new PassThrough();
    let rawOutput = '';
    output.on('data', chunk => {
      rawOutput += chunk.toString('utf8');
    });
    runStdioServer({
      input,
      output,
      serviceOptions: { harness: 'claude' },
    });

    input.write(Buffer.alloc(MAX_MESSAGE_BYTES + 1, 0x78));
    input.write(`\n${JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'initialize',
      params: {
        protocolVersion: '2025-11-25',
        capabilities: {},
        clientInfo: { name: 'bounded-test', version: '1.0.0' },
      },
    })}\n`);
    input.end();

    await new Promise((resolve, reject) => {
      const timeout = setTimeout(() => reject(new Error('Timed out waiting for bounded output.')), 3000);
      const poll = () => {
        if (rawOutput.trim().split('\n').length >= 2) {
          clearTimeout(timeout);
          resolve();
        } else {
          setImmediate(poll);
        }
      };
      poll();
    });

    const messages = rawOutput.trim().split('\n').map(line => JSON.parse(line));
    assert.strictEqual(messages.length, 2);
    assert.strictEqual(messages[0].error.code, -32700);
    assert.strictEqual(messages[1].result.protocolVersion, '2025-11-25');
  });

  await test('shares a saved handoff through MCP search and read', async () => {
    await withClient(async client => {
      const savedResult = await client.callTool({
        name: 'memory_save',
        arguments: {
          title: 'Codex to Claude handoff',
          body: 'The migration is green; review the rollout note.',
          kind: 'handoff',
          scope: 'project',
          targetHarnesses: ['claude'],
          tags: ['migration'],
        },
      });
      assert.strictEqual(savedResult.isError, undefined);
      const saved = parseTextResult(savedResult);
      assert.strictEqual(saved.memory.trust, 'unreviewed');
      assert.strictEqual(saved.memory.sourceHarness, 'claude');
      assert.strictEqual(Object.hasOwn(saved.memory, 'body'), false);

      const searchResult = await client.callTool({
        name: 'memory_search',
        arguments: {
          query: 'migration rollout',
          limit: 5,
        },
      });
      const search = parseTextResult(searchResult);
      assert.strictEqual(search.results.length, 1);
      assert.strictEqual(search.results[0].memory.id, saved.memory.id);

      const readResult = await client.callTool({
        name: 'memory_read',
        arguments: { id: saved.memory.id },
      });
      const read = parseTextResult(readResult);
      assert.strictEqual(read.memory.body, 'The migration is green; review the rollout note.');

      const doctorResult = await client.callTool({
        name: 'memory_doctor',
        arguments: {},
      });
      const doctor = parseTextResult(doctorResult);
      assert.strictEqual(doctor.ok, true);
      assert.strictEqual(doctor.memoryCount, 1);
    });
  });

  await test('rejects caller identity spoofing and hides other-harness memories', async () => {
    await withClient(async client => {
      await assert.rejects(
        () => client.callTool({
          name: 'memory_save',
          arguments: {
            title: 'Spoofed source',
            body: 'This must not be accepted.',
            sourceHarness: 'hermes',
          },
        }),
        /-32602/
      );
      await assert.rejects(
        () => client.callTool({
          name: 'memory_search',
          arguments: {
            query: '',
            targetHarness: 'hermes',
          },
        }),
        /-32602/
      );

      const savedResult = await client.callTool({
        name: 'memory_save',
        arguments: {
          title: 'Hermes-only handoff',
          body: 'Only Hermes should receive this context.',
          kind: 'handoff',
          targetHarnesses: ['hermes'],
        },
      });
      const saved = parseTextResult(savedResult);
      assert.strictEqual(saved.memory.sourceHarness, 'claude');

      const searchResult = await client.callTool({
        name: 'memory_search',
        arguments: { query: 'Hermes-only' },
      });
      assert.strictEqual(parseTextResult(searchResult).results.length, 0);

      const readResult = await client.callTool({
        name: 'memory_read',
        arguments: { id: saved.memory.id },
      });
      assert.strictEqual(readResult.isError, true);
      assert.strictEqual(parseTextResult(readResult).error.code, 'MEMORY_READ_FAILED');

      const doctor = parseTextResult(await client.callTool({
        name: 'memory_doctor',
        arguments: {},
      }));
      assert.strictEqual(doctor.memoryCount, 0);
      assert.strictEqual(Object.hasOwn(doctor, 'brokenLinks'), false);
      assert.strictEqual(Object.hasOwn(doctor, 'invalidFiles'), false);
      assert.strictEqual(JSON.stringify(doctor).includes(saved.memory.id), false);
    });
  });

  await test('filters harness-visible backlinks before applying the response cap', async () => {
    await withClient(async (client, fixture) => {
      const roots = resolveVaultRoots({
        cwd: fixture.projectRoot,
        env: fixture.env,
      });
      const saveWithId = (input, id) => saveMemory(input, {
        roots,
        now: () => '2026-07-26T20:00:00.000Z',
        idFactory: () => id,
      });
      const targetId = 'mem_backlink_target';
      saveWithId({
        title: 'Backlink target',
        body: 'Visible target body.',
        targetHarnesses: ['claude'],
      }, targetId);

      for (let index = 0; index < MAX_RESULTS; index += 1) {
        saveWithId({
          title: `Hidden backlink ${index}`,
          body: 'Only Hermes may see this backlink.',
          targetHarnesses: ['hermes'],
          links: [targetId],
        }, `mem_backlink_hidden_${String(index).padStart(3, '0')}`);
      }
      saveWithId({
        title: 'Visible backlink',
        body: 'Claude must still receive this backlink.',
        targetHarnesses: ['claude'],
        links: [targetId],
      }, 'mem_backlink_visible_zzz');

      const read = parseTextResult(await client.callTool({
        name: 'memory_read',
        arguments: { id: targetId },
      }));
      assert.deepStrictEqual(
        read.backlinks.map(memory => memory.id),
        ['mem_backlink_visible_zzz']
      );
      assert.strictEqual(read.backlinksTruncated, false);
    });
  });

  await test('denies user scope unless the server explicitly grants it', async () => {
    await withClient(async client => {
      await assert.rejects(
        () => client.callTool({
          name: 'memory_save',
          arguments: {
            title: 'Private preference',
            body: 'Keep this in the user vault.',
            scope: 'user',
          },
        }),
        /user memory scope is disabled/
      );
      await assert.rejects(
        () => client.callTool({
          name: 'memory_search',
          arguments: { scopes: ['user'] },
        }),
        /user memory scope is disabled/
      );
      await assert.rejects(
        () => client.callTool({
          name: 'memory_read',
          arguments: {
            id: 'mem_20260726_user_scope_denied',
            scope: 'user',
          },
        }),
        /user memory scope is disabled/
      );
    });

    await withClient(async client => {
      const savedResult = await client.callTool({
        name: 'memory_save',
        arguments: {
          title: 'Private preference',
          body: 'Keep this in the user vault.',
          scope: 'user',
        },
      });
      const saved = parseTextResult(savedResult);
      assert.strictEqual(saved.memory.scope, 'user');

      const defaultSearch = parseTextResult(await client.callTool({
        name: 'memory_search',
        arguments: { query: 'Private preference' },
      }));
      assert.strictEqual(defaultSearch.results.length, 0);

      const userSearch = parseTextResult(await client.callTool({
        name: 'memory_search',
        arguments: {
          query: 'Private preference',
          scopes: ['user'],
        },
      }));
      assert.strictEqual(userSearch.results[0].memory.id, saved.memory.id);

      const userRead = parseTextResult(await client.callTool({
        name: 'memory_read',
        arguments: {
          id: saved.memory.id,
          scope: 'user',
        },
      }));
      assert.strictEqual(userRead.memory.id, saved.memory.id);
    }, { env: { ECC_MEMORY_ALLOW_USER_SCOPE: '1' } });
  });

  await test('requires server identity and strictly validates JSON-RPC envelopes', async () => {
    const { createMemoryMcpService } = await import(pathToFileURL(SERVER).href);
    assert.throws(
      () => createMemoryMcpService({ env: {} }),
      /ECC_MEMORY_HARNESS/
    );

    const fixture = createFixture({ ECC_MEMORY_HARNESS: undefined });
    try {
      const started = spawnSync(process.execPath, [SERVER], {
        cwd: fixture.projectRoot,
        env: fixture.env,
        encoding: 'utf8',
      });
      assert.strictEqual(started.error, undefined);
      assert.strictEqual(started.status, 1);
      assert.match(started.stderr, /ECC_MEMORY_HARNESS/);
      assert.ok(!started.stderr.includes('\n    at '));
    } finally {
      fs.rmSync(fixture.root, { recursive: true, force: true });
    }

    const service = createMemoryMcpService({ harness: 'claude' });
    for (const id of [null, false, {}, [], 1.5, Number.MAX_SAFE_INTEGER + 1, '']) {
      const response = await service.handle({
        jsonrpc: '2.0',
        id,
        method: 'initialize',
        params: {},
      });
      assert.strictEqual(response.id, null);
      assert.strictEqual(response.error.code, -32600);
    }

    const initialized = await service.handle({
      jsonrpc: '2.0',
      id: 0,
      method: 'initialize',
      params: {
        protocolVersion: '2025-11-25',
        capabilities: {},
        clientInfo: { name: 'strict-test', version: '1.0.0' },
      },
    });
    assert.strictEqual(initialized.id, 0);
    await service.handle({
      jsonrpc: '2.0',
      method: 'notifications/initialized',
      params: {},
    });

    for (const toolArguments of [null, false, 0, '', []]) {
      const response = await service.handle({
        jsonrpc: '2.0',
        id: `args-${String(toolArguments)}`,
        method: 'tools/call',
        params: {
          name: 'memory_doctor',
          arguments: toolArguments,
        },
      });
      assert.strictEqual(response.error.code, -32602);
    }
    const invalidParams = await service.handle({
      jsonrpc: '2.0',
      id: 2,
      method: 'tools/call',
      params: [],
    });
    assert.strictEqual(invalidParams.error.code, -32600);
  });

  await test('bounds queued transport work under a single-chunk request flood', async () => {
    const {
      MAX_PENDING_MESSAGES,
      runStdioServer,
    } = await import(pathToFileURL(SERVER).href);
    const input = new PassThrough();
    const output = new PassThrough();
    let rawOutput = '';
    output.on('data', chunk => {
      rawOutput += chunk.toString('utf8');
    });
    runStdioServer({
      input,
      output,
      serviceOptions: { harness: 'claude' },
    });

    const requests = [
      {
        jsonrpc: '2.0',
        id: 'init',
        method: 'initialize',
        params: {
          protocolVersion: '2025-11-25',
          capabilities: {},
          clientInfo: { name: 'flood-test', version: '1.0.0' },
        },
      },
      {
        jsonrpc: '2.0',
        method: 'notifications/initialized',
        params: {},
      },
      ...Array.from({ length: MAX_PENDING_MESSAGES * 4 }, (_, index) => ({
        jsonrpc: '2.0',
        id: `ping-${index}`,
        method: 'ping',
        params: {},
      })),
    ];
    input.end(`${requests.map(JSON.stringify).join('\n')}\n`);

    await new Promise((resolve, reject) => {
      const timeout = setTimeout(
        () => reject(new Error('Timed out waiting for queue-limit response.')),
        3000
      );
      const poll = () => {
        if (rawOutput.includes('queue limit exceeded')) {
          clearTimeout(timeout);
          resolve();
        } else {
          setImmediate(poll);
        }
      };
      poll();
    });

    const messages = rawOutput.trim().split('\n').map(line => JSON.parse(line));
    assert.ok(messages.some(message => message.error?.code === -32000));
    assert.ok(messages.length <= MAX_PENDING_MESSAGES + 2);
  });

  await test('bounds serialized tool responses before writing to stdout', async () => {
    const {
      MAX_RESPONSE_BYTES,
      textResult,
    } = await import(pathToFileURL(SERVER).href);
    assert.throws(
      () => textResult({ body: 'x'.repeat(MAX_RESPONSE_BYTES + 1) }),
      /bounded output limit/
    );
  });

  await test('returns a structured tool error without a stack trace for secret-bearing writes', async () => {
    await withClient(async client => {
      await assert.rejects(
        () => client.callTool({
          name: 'memory_save',
          arguments: {
            title: 'Empty body',
            body: '',
          },
        }),
        /-32602/
      );
      const secret = `ghp_${'A1'.repeat(12)}`;
      const result = await client.callTool({
        name: 'memory_save',
        arguments: {
          title: 'Do not persist this',
          body: `credential ${secret}`,
        },
      });
      assert.strictEqual(result.isError, true);
      const error = parseTextResult(result);
      assert.strictEqual(error.error.code, 'MEMORY_WRITE_REJECTED');
      assert.ok(error.error.message.includes('suspected secret'));
      assert.ok(!JSON.stringify(error).includes(secret));
      assert.ok(!JSON.stringify(error).includes('\n    at '));
    });
  });

  console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
  if (failed > 0) {
    process.exit(1);
  }
}

main().catch(error => {
  console.error(error);
  process.exit(1);
});
