import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { leaseEnforcementActivatable } from './lease-activation-probe.js';
import {
  ENFORCEMENT_HOOK_MARKERS,
  FAIL_LOUD_MESSAGE,
  guardClaudeSettingsWiring,
  loudOptOutMessage,
  runInstallOrderingGuard,
  settingsHasEnforcementHooks,
  stripEnforcementHooks,
} from './install-ordering-guard.js';

/**
 * Red-first tests for issue #869 Point-1 C2 — the install-ordering guard.
 *
 * Root cause under test: `mosaic-link-runtime-assets` copies
 * `runtime/claude/settings.json` (which embeds the PreToolUse
 * `mutator-gate.py` hook and the Stop `receipt-observer-client.py` hook)
 * straight into `~/.claude/settings.json`, unconditionally. If the
 * activation half (C1: `leaseEnforcementActivatable()`) cannot be confirmed,
 * wiring those hooks bricks the host with a fail-closed gate that can never
 * be satisfied. This guard must refuse to wire in that case by default, and
 * only wire anyway on an explicit, loud opt-out.
 *
 * All fixtures use temp directories — this suite never reads or writes the
 * real `~/.claude/settings.json`.
 */

const FIXTURE_SETTINGS = {
  model: 'opus',
  hooks: {
    PreCompact: [
      {
        matcher: '.*',
        hooks: [{ type: 'command', command: 'python3 revoke-lease.py --reason pre-compact' }],
      },
    ],
    PreToolUse: [
      {
        matcher: '.*',
        hooks: [
          {
            type: 'command',
            command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
            timeout: 3,
          },
        ],
      },
      {
        matcher: 'Write|Edit|MultiEdit',
        hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/prevent-memory-write.sh' }],
      },
    ],
    PostToolUse: [
      {
        matcher: 'Edit|MultiEdit|Write',
        hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/qa-hook-stdin.sh' }],
      },
    ],
    Stop: [
      {
        hooks: [
          {
            type: 'command',
            command:
              'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude',
            timeout: 3,
          },
          { type: 'command', command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh' },
        ],
      },
    ],
  },
  enabledPlugins: { 'feature-dev@claude-plugins-official': true },
};

function fixtureJson(): string {
  return JSON.stringify(FIXTURE_SETTINGS, null, 2) + '\n';
}

describe('stripEnforcementHooks', () => {
  it('removes the PreToolUse mutator-gate trigger entirely', () => {
    const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
    const hooks = settings['hooks'] as Record<string, unknown[]>;
    const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>;
    expect(preToolUse.some((t) => t.hooks.some((h) => h.command.includes('mutator-gate.py')))).toBe(
      false,
    );
  });

  it('preserves the sibling prevent-memory-write.sh PreToolUse trigger', () => {
    const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
    const hooks = settings['hooks'] as Record<string, unknown[]>;
    const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>;
    expect(
      preToolUse.some((t) => t.hooks.some((h) => h.command.includes('prevent-memory-write.sh'))),
    ).toBe(true);
  });

  it('removes only the receipt-observer-client.py hook from Stop, keeping reflect-stop-hook.sh', () => {
    const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
    const hooks = settings['hooks'] as Record<string, unknown[]>;
    const stop = hooks['Stop'] as Array<{ hooks: Array<{ command: string }> }>;
    const commands = stop.flatMap((t) => t.hooks.map((h) => h.command));
    expect(commands.some((c) => c.includes('receipt-observer-client.py'))).toBe(false);
    expect(commands.some((c) => c.includes('reflect-stop-hook.sh'))).toBe(true);
  });

  it('leaves PreCompact/PostToolUse hooks byte-identical', () => {
    const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
    const hooks = settings['hooks'] as Record<string, unknown>;
    expect(hooks['PreCompact']).toEqual(FIXTURE_SETTINGS.hooks.PreCompact);
    expect(hooks['PostToolUse']).toEqual(FIXTURE_SETTINGS.hooks.PostToolUse);
  });

  it('reports what it removed', () => {
    const { removed } = stripEnforcementHooks(FIXTURE_SETTINGS);
    expect(removed).toContain(`PreToolUse:${ENFORCEMENT_HOOK_MARKERS.preToolUse}`);
    expect(removed).toContain(`Stop:${ENFORCEMENT_HOOK_MARKERS.stop}`);
  });
});

describe('settingsHasEnforcementHooks', () => {
  it('is true for the unmodified fixture', () => {
    expect(settingsHasEnforcementHooks(FIXTURE_SETTINGS)).toBe(true);
  });

  it('is false after stripping', () => {
    const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
    expect(settingsHasEnforcementHooks(settings)).toBe(false);
  });

  it('is false for settings with no hooks key at all', () => {
    expect(settingsHasEnforcementHooks({ model: 'opus' })).toBe(false);
  });
});

describe('guardClaudeSettingsWiring', () => {
  it('probe=false (default, no opt-out): strips enforcement hooks and reports non-zero with a loud, actionable message', () => {
    const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => false });

    expect(outcome.exitCode).toBe(1);
    expect(outcome.wired).toBe(false);
    expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record<string, unknown>)).toBe(
      false,
    );
    expect(outcome.logs).toHaveLength(1);
    expect(outcome.logs[0]?.level).toBe('error');
    expect(outcome.logs[0]?.message).toBe(FAIL_LOUD_MESSAGE);
    expect(outcome.logs[0]?.message).toMatch(/refusing to wire a dead gate/i);
    expect(outcome.logs[0]?.message).toMatch(/#869/);
    expect(outcome.logs[0]?.message).toMatch(/--allow-inactive-enforcement/);
  });

  it('probe=false + explicit opt-out flag: wires hooks as-is and emits a loud warning', () => {
    const outcome = guardClaudeSettingsWiring(
      fixtureJson(),
      { allowInactiveEnforcement: true },
      { activatable: () => false },
    );

    expect(outcome.exitCode).toBe(0);
    expect(outcome.wired).toBe(true);
    expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record<string, unknown>)).toBe(
      true,
    );
    expect(outcome.logs).toHaveLength(1);
    expect(outcome.logs[0]?.level).toBe('warn');
    expect(outcome.logs[0]?.message).toBe(loudOptOutMessage());
    expect(outcome.logs[0]?.message).toMatch(/WITHOUT confirmed activation/);
  });

  it('probe=true: wires hooks normally with no logs, regardless of opt-out', () => {
    const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => true });

    expect(outcome.exitCode).toBe(0);
    expect(outcome.wired).toBe(true);
    expect(outcome.logs).toHaveLength(0);
    expect(JSON.parse(outcome.json)).toEqual(FIXTURE_SETTINGS);
  });

  it('probe=true + opt-out flag set anyway: still wires normally, no spurious warning', () => {
    const outcome = guardClaudeSettingsWiring(
      fixtureJson(),
      { allowInactiveEnforcement: true },
      { activatable: () => true },
    );

    expect(outcome.exitCode).toBe(0);
    expect(outcome.wired).toBe(true);
    expect(outcome.logs).toHaveLength(0);
  });

  it('defaults to the real leaseEnforcementActivatable() when no activatable dep is injected', () => {
    // Deliberately does not assume a fixed true/false value for the real
    // probe (whether dist/cli.js happens to be built varies by environment —
    // asserting a hardcoded expectation here would make the test flaky, not
    // red-first). Instead it proves the wiring is genuinely delegated: the
    // no-deps call must agree with an explicit call to the same real
    // predicate, not some other hardcoded value.
    const reallyActivatable = leaseEnforcementActivatable();
    const outcome = guardClaudeSettingsWiring(fixtureJson());

    if (reallyActivatable) {
      expect(outcome.exitCode).toBe(0);
      expect(outcome.wired).toBe(true);
    } else {
      expect(outcome.exitCode).toBe(1);
      expect(outcome.wired).toBe(false);
    }
  });
});

describe('runInstallOrderingGuard (file-level, temp dirs only)', () => {
  let dir: string;

  afterEach(() => {
    if (dir) rmSync(dir, { recursive: true, force: true });
  });

  function makeSrc(): string {
    dir = mkdtempSync(join(tmpdir(), 'mosaic-install-ordering-guard-'));
    const src = join(dir, 'settings.json');
    writeFileSync(src, fixtureJson());
    return src;
  }

  it('probe=false: writes a dest settings.json with hooks stripped and returns exitCode 1', () => {
    const src = makeSrc();
    const dest = join(dir, 'claude-settings.json');

    const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => false });

    expect(result.exitCode).toBe(1);
    expect(result.destWritten).toBe(true);
    expect(existsSync(dest)).toBe(true);
    const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record<string, unknown>;
    expect(settingsHasEnforcementHooks(written)).toBe(false);
  });

  it('probe=false + opt-out: writes dest with hooks intact and returns exitCode 0', () => {
    const src = makeSrc();
    const dest = join(dir, 'claude-settings.json');

    const result = runInstallOrderingGuard(
      src,
      dest,
      { allowInactiveEnforcement: true },
      { activatable: () => false },
    );

    expect(result.exitCode).toBe(0);
    const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record<string, unknown>;
    expect(settingsHasEnforcementHooks(written)).toBe(true);
  });

  it('probe=true: writes dest with hooks intact and returns exitCode 0', () => {
    const src = makeSrc();
    const dest = join(dir, 'claude-settings.json');

    const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });

    expect(result.exitCode).toBe(0);
    const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record<string, unknown>;
    expect(settingsHasEnforcementHooks(written)).toBe(true);
  });

  it('backs up a pre-existing divergent dest before overwriting (copy_file_managed parity)', () => {
    const src = makeSrc();
    const dest = join(dir, 'claude-settings.json');
    writeFileSync(dest, JSON.stringify({ preexisting: true }));

    const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });

    expect(result.destWritten).toBe(true);
    expect(result.backupPath).toBeDefined();
    expect(existsSync(result.backupPath!)).toBe(true);
    expect(JSON.parse(readFileSync(result.backupPath!, 'utf-8'))).toEqual({ preexisting: true });
  });

  it('is a no-op write when dest already matches the guarded content (idempotent)', () => {
    const src = makeSrc();
    const dest = join(dir, 'claude-settings.json');

    const first = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
    expect(first.destWritten).toBe(true);

    const second = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
    expect(second.destWritten).toBe(false);
    expect(second.backupPath).toBeUndefined();
  });

  it('never touches the real home directory settings path used by this test file', () => {
    // Sanity guard for the suite itself: every dest path used above lives
    // under the mkdtemp() scratch dir, never under homedir()/.claude.
    expect(dir).toContain('mosaic-install-ordering-guard-');
  });
});
