/**
 * Brain cache spec internal-consistency invariants (T14 / D2).
 *
 * Asserts that scripts/brain-cache-spec.ts is self-consistent:
 *   - Every skill's subset only references entities that exist.
 *   - Per-skill budget cap is achievable given per-entity caps.
 *   - Cross-project entities are clearly distinguished from per-project.
 *   - Invalidation graph has no dangling skill references.
 *   - Helper functions throw on unknown names (defensive).
 *
 * Gate-tier, free, pure import + assertion. Runs in <100ms.
 */

import { describe, test, expect, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
  BRAIN_CACHE_ENTITIES,
  SKILL_DIGEST_SUBSETS,
  SKILL_PREFLIGHT_BUDGET_BYTES,
  AUTOPLAN_PREFLIGHT_BUDGET_BYTES,
  SALIENCE_DEFAULT_ALLOWLIST,
  SKILL_CALIBRATION_WEIGHTS,
  TRANSPORT_DEFAULT_POLICY,
  USER_SLUG_RESOLUTION_ORDER,
  GSTACK_SCHEMA_PACK_NAME,
  GSTACK_SCHEMA_PACK_VERSION,
  CACHE_REFRESH_LOCK_TIMEOUT_MS,
  SKILL_RUN_RETENTION_DAYS,
  getCacheFile,
  getSkillSubset,
  getSkillBudget,
  getInvalidationTargets,
  getPreflightSkills,
  getMaxSubsetBytes,
} from '../scripts/brain-cache-spec';

describe('brain-cache-spec internal consistency', () => {
  test('every skill subset references only known entities', () => {
    const entityNames = new Set(Object.keys(BRAIN_CACHE_ENTITIES));
    for (const [skill, subset] of Object.entries(SKILL_DIGEST_SUBSETS)) {
      for (const name of subset) {
        expect(entityNames.has(name)).toBe(true);
      }
    }
  });

  test('every skill with a subset has a budget', () => {
    for (const skill of Object.keys(SKILL_DIGEST_SUBSETS)) {
      expect(SKILL_PREFLIGHT_BUDGET_BYTES[skill]).toBeGreaterThan(0);
    }
  });

  test('per-skill budget is achievable given per-entity budgets', () => {
    // Per-entity budgets are hard ceilings on each digest's own file size.
    // Per-skill budget is enforced by the compressor on the SUM injected into
    // the skill's preflight context — the same entity may be sampled (top-N)
    // rather than verbatim. So sum may legitimately exceed skill budget; the
    // compressor trims at write time. We allow up to 3x as a sanity ceiling
    // (caught test/skill-preflight-budget.test.ts enforces the real cap).
    for (const skill of Object.keys(SKILL_DIGEST_SUBSETS)) {
      const maxBytes = getMaxSubsetBytes(skill);
      const skillBudget = getSkillBudget(skill);
      expect(maxBytes).toBeLessThanOrEqual(skillBudget * 3);
    }
  });

  test('autoplan total budget covers the 4 plan-* skills (excluding office-hours)', () => {
    const autoplanSkills = ['plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'plan-devex-review'];
    const sum = autoplanSkills.reduce((acc, s) => acc + getSkillBudget(s), 0);
    expect(sum).toBeLessThanOrEqual(AUTOPLAN_PREFLIGHT_BUDGET_BYTES);
  });

  test('every entity has a positive TTL and a positive budget', () => {
    for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
      expect(entity.ttl_ms).toBeGreaterThan(0);
      expect(entity.budget_bytes).toBeGreaterThan(0);
      expect(entity.file).toMatch(/\.md$/);
      expect(['cross-project', 'per-project']).toContain(entity.scope);
    }
  });

  test('user-profile is the only cross-project entity', () => {
    const crossProject = Object.entries(BRAIN_CACHE_ENTITIES)
      .filter(([_, e]) => e.scope === 'cross-project')
      .map(([n]) => n);
    expect(crossProject).toEqual(['user-profile']);
  });

  test('salience entity has shortest TTL (changes hourly)', () => {
    const ttls = Object.values(BRAIN_CACHE_ENTITIES).map((e) => e.ttl_ms);
    expect(BRAIN_CACHE_ENTITIES.salience.ttl_ms).toBe(Math.min(...ttls));
  });

  test('salience allowlist has sane defaults (no personal/family/therapy)', () => {
    const blocked = ['personal/', 'family/', 'therapy/', 'reflection'];
    for (const prefix of blocked) {
      expect(SALIENCE_DEFAULT_ALLOWLIST.some((p) => p.startsWith(prefix))).toBe(false);
    }
    // Must contain at least projects/ + gstack/ (work-flow surfaces)
    expect(SALIENCE_DEFAULT_ALLOWLIST).toContain('projects/');
    expect(SALIENCE_DEFAULT_ALLOWLIST).toContain('gstack/');
  });

  test('calibration weights are bounded 0-1 and present for all preflight skills', () => {
    for (const skill of getPreflightSkills()) {
      const weight = SKILL_CALIBRATION_WEIGHTS[skill];
      expect(weight).toBeGreaterThan(0);
      expect(weight).toBeLessThanOrEqual(1);
    }
  });

  test('transport policy defaults exist for all transport modes', () => {
    const required = ['local-pglite', 'local-stdio', 'remote-http-single-tenant', 'remote-http-ambiguous'];
    for (const transport of required) {
      expect(TRANSPORT_DEFAULT_POLICY[transport]).toBeDefined();
    }
    // Local transports must default personal (D4 / Phase 1.5 default rule)
    expect(TRANSPORT_DEFAULT_POLICY['local-pglite']).toBe('personal');
    expect(TRANSPORT_DEFAULT_POLICY['local-stdio']).toBe('personal');
    // Ambiguous remote MUST require explicit ask (never silent default)
    expect(TRANSPORT_DEFAULT_POLICY['remote-http-ambiguous']).toBe('unset');
  });

  test('user-slug resolution chain has 4 deterministic fallbacks ending in non-empty', () => {
    expect(USER_SLUG_RESOLUTION_ORDER.length).toBe(4);
    expect(USER_SLUG_RESOLUTION_ORDER[USER_SLUG_RESOLUTION_ORDER.length - 1]).toBe('anonymous_hostname_sha8');
  });

  test('schema pack identity is stable strings', () => {
    expect(GSTACK_SCHEMA_PACK_NAME).toBe('gstack-core');
    expect(GSTACK_SCHEMA_PACK_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
  });

  test('refresh lock timeout matches /sync-gbrain convention (5 min)', () => {
    expect(CACHE_REFRESH_LOCK_TIMEOUT_MS).toBe(5 * 60_000);
  });

  test('skill-run retention is 90 days per D10 lifecycle policy', () => {
    expect(SKILL_RUN_RETENTION_DAYS).toBe(90);
  });

  test('invalidation graph: every "skill-run-write" target also depends on it', () => {
    // recent-decisions invalidates on skill-run-write — verify the contract holds
    const targets = getInvalidationTargets('skill-run-write');
    expect(targets).toContain('recent-decisions');
  });

  test('invalidation graph: /plan-ceo-review invalidates product + goals + recent-decisions chain', () => {
    const targets = getInvalidationTargets('/plan-ceo-review');
    expect(targets).toContain('product');
    expect(targets).toContain('goals');
  });

  test('helpers throw on unknown names (defensive)', () => {
    expect(() => getCacheFile('nonsense-entity')).toThrow();
    expect(() => getSkillSubset('not-a-skill')).toThrow();
    expect(() => getSkillBudget('not-a-skill')).toThrow();
  });

  test('helpers return correct values for known names', () => {
    expect(getCacheFile('product')).toBe('product.md');
    expect(getSkillSubset('plan-eng-review')).toEqual(['product', 'recent-decisions']);
    expect(getSkillBudget('office-hours')).toBe(5120);
  });

  test('all 5 preflight skills are real planning-skill names', () => {
    const expected = ['office-hours', 'plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'plan-devex-review'];
    expect(getPreflightSkills().sort()).toEqual(expected.sort());
  });
});

describe('brain-cache MCP scope precedence (C15 pin)', () => {
  // Claude Code resolves a same-name MCP conflict in favor of the
  // PROJECT-LOCAL entry (.projects[cwd].mcpServers) over the user-scope
  // entry (.mcpServers). Verified empirically against claude 2.1.233 with a
  // hermetic fake $HOME: `claude mcp get gbrain` reported "Scope: Local
  // config" and the project-local URL when both scopes defined gbrain.
  // detectEndpointHash must hash the endpoint the project actually talks
  // to, or a brain switch would never invalidate the cache.
  const TMP = mkdtempSync(join(tmpdir(), 'brain-cache-precedence-'));
  afterAll(() => rmSync(TMP, { recursive: true, force: true }));

  const cache = () => import('../bin/gstack-brain-cache');
  const writeFixture = (name: string, cfg: object): string => {
    const p = join(TMP, name);
    writeFileSync(p, JSON.stringify(cfg));
    return p;
  };
  const USER_URL = { type: 'http', url: 'https://user.example/mcp' };
  const PROJ_URL = { type: 'http', url: 'https://proj.example/mcp' };

  test('project-local gbrain entry beats user scope for a cwd inside the project', async () => {
    const mod = await cache();
    const conflict = writeFixture('claude-conflict.json', {
      mcpServers: { gbrain: USER_URL },
      projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
    });
    const conflictHash = mod.detectEndpointHash(conflict, '/w/repo/src');
    // Same hash as the project entry alone → the project-local entry won.
    const projOnly = writeFixture('claude-proj-only.json', {
      projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
    });
    expect(conflictHash).toBe(mod.detectEndpointHash(projOnly, '/w/repo/src'));
    // And NOT the user entry's hash.
    const userOnly = writeFixture('claude-user-only.json', {
      mcpServers: { gbrain: USER_URL },
    });
    expect(conflictHash).not.toBe(mod.detectEndpointHash(userOnly, '/w/repo/src'));
  });

  test('user scope still resolves when the cwd has no project-local entry', async () => {
    const mod = await cache();
    const cj = writeFixture('claude-user-fallback.json', {
      mcpServers: { gbrain: USER_URL },
      projects: { '/other/repo': { mcpServers: { gbrain: PROJ_URL } } },
    });
    const hash = mod.detectEndpointHash(cj, '/w/unrelated');
    expect(hash).toHaveLength(8);
    // Matches the user-only hash — the OTHER project's entry is invisible
    // outside its own tree.
    const userOnly = writeFixture('claude-user-only-2.json', {
      mcpServers: { gbrain: USER_URL },
    });
    expect(hash).toBe(mod.detectEndpointHash(userOnly, '/w/unrelated'));
  });
});
