import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
type JsonObject = { [key: string]: Json };

const frameworkRoot = fileURLToPath(new URL('../../framework/', import.meta.url));
const basePath = `${frameworkRoot}runtime/claude/settings.json`;
const overlayPath = `${frameworkRoot}runtime/claude/lease-overlay.json`;
const gatedFixturePath = fileURLToPath(
  new URL('./fixtures/claude-settings.gated.pre-split.json', import.meta.url),
);

function readJson(path: string): JsonObject {
  return JSON.parse(readFileSync(path, 'utf8')) as JsonObject;
}

function isObject(value: unknown): value is JsonObject {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

// This fixture contract composes hook event arrays additively. It is deliberately
// limited to verifying that the split is lossless; production launch merge
// semantics remain owned by W-F1.
function deepMerge(base: Json, overlay: Json): Json {
  if (Array.isArray(base) && Array.isArray(overlay)) {
    const merged = [...base];
    for (const entry of overlay) {
      if (!isObject(entry) || !Array.isArray(entry['hooks'])) {
        merged.push(entry);
        continue;
      }
      const matchingIndex = merged.findIndex(
        (candidate) =>
          isObject(candidate) &&
          Array.isArray(candidate['hooks']) &&
          candidate['matcher'] === entry['matcher'],
      );
      if (matchingIndex === -1) merged.push(entry);
      else merged[matchingIndex] = deepMerge(merged[matchingIndex]!, entry);
    }
    return merged;
  }
  if (isObject(base) && isObject(overlay)) {
    const merged: JsonObject = { ...base };
    for (const [key, value] of Object.entries(overlay)) {
      merged[key] = key in merged ? deepMerge(merged[key]!, value) : value;
    }
    return merged;
  }
  return overlay;
}

function normalize(value: Json): Json {
  if (Array.isArray(value)) {
    return value
      .map(normalize)
      .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
  }
  if (isObject(value)) {
    return Object.fromEntries(
      Object.entries(value)
        .sort(([left], [right]) => left.localeCompare(right))
        .map(([key, nested]) => [key, normalize(nested)]),
    );
  }
  return value;
}

function hookCommands(settings: JsonObject): string[] {
  const hooks = settings['hooks'];
  if (!isObject(hooks)) return [];

  return Object.values(hooks).flatMap((event) => {
    if (!Array.isArray(event)) return [];
    return event.flatMap((entry) => {
      if (!isObject(entry) || !Array.isArray(entry['hooks'])) return [];
      return entry['hooks'].flatMap((hook) =>
        isObject(hook) && typeof hook['command'] === 'string' ? [hook['command']] : [],
      );
    });
  });
}

const sequentialThinking: JsonObject = {
  command: 'npx',
  args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
};

describe('canonical Claude base and lease-promotion overlay', () => {
  it('keeps every lease command out of the ungated base', () => {
    const base = readJson(basePath);
    const commands = hookCommands(base);

    for (const marker of ['mutator-gate', 'receipt-observer', 'promote-', 'revoke-lease']) {
      expect(
        commands.some((command) => command.includes(marker)),
        marker,
      ).toBe(false);
    }
  });

  it('reconstructs the pre-split gated hooks while retaining the canonical MCP correction', () => {
    const base = readJson(basePath);
    const overlay = readJson(overlayPath);
    const preSplit = readJson(gatedFixturePath);
    const expected: JsonObject = {
      ...preSplit,
      mcpServers: { 'sequential-thinking': sequentialThinking },
    };

    expect(normalize(deepMerge(base, overlay))).toEqual(normalize(expected));
  });

  it('ships sequential-thinking in the base', () => {
    const base = readJson(basePath);
    expect(base['mcpServers']).toEqual({ 'sequential-thinking': sequentialThinking });
  });

  it('limits the overlay to lease hook entries', () => {
    const overlay = readJson(overlayPath);
    expect(Object.keys(overlay)).toEqual(['hooks']);

    const commands = hookCommands(overlay);
    expect(commands).toHaveLength(6);
    for (const command of commands) {
      expect(command).toMatch(/mutator-gate|receipt-observer|promote-|revoke-lease/);
    }
  });
});
