/*
 * Priority Forge — memory-hub adapter contract tests.
 *
 * Parameterized harness: each adapter that ships in this directory must satisfy
 * the same five-method contract. We run the suite against every adapter the
 * current environment can support.
 *
 *   - `dryrun` always runs (uses a temp JSONL log)
 *   - `pgvector` runs when MEMHUB_PGVECTOR_URL is set in env, e.g.
 *       MEMHUB_PGVECTOR_URL=postgres://postgres:pf-dev@127.0.0.1:5432/priority_forge_memhub
 *   - `xmem` runs when XTRACE_TEST_API_KEY + XTRACE_TEST_ORG_ID are set
 *   - `noop` is exempt — verified separately at the bottom (returns empty by design)
 */

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { randomUUID } from 'crypto';

import { MemoryHub, TeamMemoryEvent } from '../types';
import { NoopMemoryHub } from '../adapters/noop-adapter';
import { DryrunMemoryHub, DRYRUN_LOG_PATH } from '../adapters/dryrun-adapter';
import { PgvectorMemoryHub } from '../adapters/pgvector-adapter';
import { XMemMemoryHub } from '../adapters/xmem-adapter';

// ──────────────────────────────────────────────────────────────────────────
// Fixtures
// ──────────────────────────────────────────────────────────────────────────

interface AdapterFixture {
  name: string;
  build: () => Promise<MemoryHub>;
  teardown: (hub: MemoryHub) => Promise<void>;
  /** A unique team_id for this run, to keep tests hermetic against each other and prior runs. */
  teamId: string;
}

function randomSuffix(): string {
  return Math.random().toString(36).slice(2, 10);
}

function event(overrides: Partial<TeamMemoryEvent> = {}): TeamMemoryEvent {
  // Production PF emits uuid.v4() for eventId — match that here so adapters
  // with strict typing (pgvector schema uses UUID) accept the test events.
  const id = overrides.eventId ?? randomUUID();
  return {
    eventId: id,
    teamId: overrides.teamId ?? 'team-default',
    agentId: overrides.agentId ?? 'agent-alice',
    workspaceId: overrides.workspaceId ?? 'ws-1',
    eventType: overrides.eventType ?? 'task_created',
    timestamp: overrides.timestamp ?? new Date().toISOString(),
    payload: overrides.payload ?? { taskId: id, task: 'Test task' },
    summary: overrides.summary ?? 'A test task',
    metadata: overrides.metadata ?? {},
  };
}

const fixtures: AdapterFixture[] = [
  {
    name: 'dryrun',
    teamId: `dryrun-${randomSuffix()}`,
    build: async () => {
      // Point dryrun at a fresh tmp log per run so cross-test interference is impossible.
      const logPath = path.join(os.tmpdir(), `pf-memhub-contract-dryrun-${randomSuffix()}.jsonl`);
      process.env.MEMHUB_DRYRUN_LOG = logPath;
      return new DryrunMemoryHub();
    },
    teardown: async () => {
      const p = process.env.MEMHUB_DRYRUN_LOG;
      if (p && fs.existsSync(p)) {
        try { fs.unlinkSync(p); } catch { /* ignore */ }
      }
      // Also remove default path debris if a stray write happened to it.
      if (fs.existsSync(DRYRUN_LOG_PATH)) {
        // leave production log intact; this is only relevant if the env var was ignored.
      }
    },
  },
];

if (process.env.MEMHUB_PGVECTOR_URL) {
  fixtures.push({
    name: 'pgvector',
    teamId: `pgvector-${randomSuffix()}`,
    build: async () => new PgvectorMemoryHub({ connectionString: process.env.MEMHUB_PGVECTOR_URL! }),
    teardown: async (hub) => {
      // Clean up the rows this test wrote so the table doesn't bloat over time.
      const adapter = hub as PgvectorMemoryHub;
      await adapter.close();
    },
  });
}

if (process.env.XTRACE_TEST_API_KEY && process.env.XTRACE_TEST_ORG_ID) {
  fixtures.push({
    name: 'xmem',
    teamId: `xmem-test-${randomSuffix()}`,
    build: async () =>
      new XMemMemoryHub({
        baseUrl: process.env.XTRACE_TEST_BASE_URL ?? 'https://api.xtrace.ai/v1',
        apiKey: process.env.XTRACE_TEST_API_KEY!,
        orgID: process.env.XTRACE_TEST_ORG_ID!,
        environment: 'staging',
      }),
    teardown: async () => undefined,
  });
}

// ──────────────────────────────────────────────────────────────────────────
// Contract — runs against every fixture
// ──────────────────────────────────────────────────────────────────────────

describe.each(fixtures)('MemoryHub contract — $name adapter', (fx) => {
  let hub: MemoryHub;
  const teamId = fx.teamId;

  beforeAll(async () => { hub = await fx.build(); });
  afterAll(async () => { await fx.teardown(hub); });

  it('health() returns a structured result and never throws', async () => {
    const h = await hub.health();
    expect(typeof h.ok).toBe('boolean');
    expect(h.backend).toBeDefined();
  });

  it('write() returns {accepted: true, remoteId?} on a well-formed event', async () => {
    const ev = event({ teamId, summary: 'init smoke test' });
    const res = await hub.write(ev);
    expect(res.accepted).toBe(true);
  });

  it('write() is idempotent on duplicate eventId (no throw, accepted: true)', async () => {
    const ev = event({ teamId, summary: 'idempotency probe' });
    const r1 = await hub.write(ev);
    expect(r1.accepted).toBe(true);
    const r2 = await hub.write(ev);
    expect(r2.accepted).toBe(true);
  });

  it('read() returns events for the team scoped to filter.teamId', async () => {
    const ev = event({ teamId, summary: 'readable event' });
    await hub.write(ev);
    const out = await hub.read({ teamId });
    const ids = out.map((e) => e.eventId);
    expect(ids).toContain(ev.eventId);
  });

  it('read() honors sinceTs as a lower bound', async () => {
    const oldEv = event({ teamId, summary: 'old event', timestamp: '2020-01-01T00:00:00Z' });
    const newEv = event({ teamId, summary: 'new event' });
    await hub.write(oldEv);
    await hub.write(newEv);
    const cutoff = '2021-01-01T00:00:00Z';
    const out = await hub.read({ teamId, sinceTs: cutoff });
    const ids = out.map((e) => e.eventId);
    expect(ids).toContain(newEv.eventId);
    expect(ids).not.toContain(oldEv.eventId);
  });

  it('read() honors agentIds filter', async () => {
    const a = event({ teamId, agentId: 'agent-A', summary: 'from A' });
    const b = event({ teamId, agentId: 'agent-B', summary: 'from B' });
    await hub.write(a);
    await hub.write(b);
    const onlyA = await hub.read({ teamId, agentIds: ['agent-A'] });
    expect(onlyA.some((e) => e.eventId === a.eventId)).toBe(true);
    expect(onlyA.some((e) => e.eventId === b.eventId)).toBe(false);
  });

  it('read() honors eventTypes filter', async () => {
    const created = event({ teamId, eventType: 'task_created', summary: 'created' });
    const completed = event({ teamId, eventType: 'task_completed', summary: 'completed' });
    await hub.write(created);
    await hub.write(completed);
    const onlyCreated = await hub.read({ teamId, eventTypes: ['task_created'] });
    expect(onlyCreated.some((e) => e.eventId === created.eventId)).toBe(true);
    expect(onlyCreated.some((e) => e.eventId === completed.eventId)).toBe(false);
  });

  it('read() limit caps the result count', async () => {
    // Write enough events that limit must actually trim
    for (let i = 0; i < 8; i++) {
      await hub.write(event({ teamId, summary: `bulk ${i}` }));
    }
    const out = await hub.read({ teamId, limit: 3 });
    expect(out.length).toBeLessThanOrEqual(3);
  });

  it('search() returns events whose content matches the query token', async () => {
    const needle = `unique-${randomSuffix()}`;
    const ev = event({ teamId, summary: `An event with token ${needle} buried inside.` });
    await hub.write(ev);
    const out = await hub.search(teamId, needle, 10);
    const ids = out.map((e) => e.eventId);
    expect(ids).toContain(ev.eventId);
  });

  it('blockersFor() returns active edges scoped to the given agent and excludes cleared ones', async () => {
    const me = `me-${randomSuffix()}`;
    const them = `them-${randomSuffix()}`;
    const activeKey = `t-${randomSuffix()}`;
    const clearedKey = `t-${randomSuffix()}`;
    const observedAt = new Date().toISOString();
    await hub.write(event({
      teamId,
      eventType: 'derived_blocker',
      summary: 'active blocker',
      timestamp: observedAt,
      payload: {
        blockedTaskId: `blocked-${activeKey}`,
        blockedAgentId: me,
        blockingTaskId: `blocking-${activeKey}`,
        blockingAgentId: them,
        status: 'active',
        observedAt,
      },
    }));
    await hub.write(event({
      teamId,
      eventType: 'derived_blocker',
      summary: 'cleared blocker',
      timestamp: observedAt,
      payload: {
        blockedTaskId: `blocked-${clearedKey}`,
        blockedAgentId: me,
        blockingTaskId: `blocking-${clearedKey}`,
        blockingAgentId: them,
        status: 'cleared',
        observedAt,
      },
    }));
    const edges = await hub.blockersFor(teamId, me);
    expect(edges.every((e) => e.status === 'active')).toBe(true);
    expect(edges.every((e) => e.blockedAgentId === me)).toBe(true);
    // active edge present
    expect(edges.some((e) => e.blockingTaskId === `blocking-${activeKey}`)).toBe(true);
    // cleared edge absent
    expect(edges.some((e) => e.blockingTaskId === `blocking-${clearedKey}`)).toBe(false);
  });

  it('never throws — all five methods return their typed shape even on garbage input', async () => {
    // write with empty-but-valid event still returns a shape
    const r1 = await hub.write(event({ teamId, summary: '' }));
    expect(typeof r1.accepted).toBe('boolean');
    // read on an unknown team returns array (possibly empty)
    const r2 = await hub.read({ teamId: `nonexistent-${randomSuffix()}` });
    expect(Array.isArray(r2)).toBe(true);
    // search on empty query returns array (possibly empty)
    const r3 = await hub.search(teamId, '');
    expect(Array.isArray(r3)).toBe(true);
    // blockersFor on unknown agent returns array
    const r4 = await hub.blockersFor(teamId, 'no-such-agent');
    expect(Array.isArray(r4)).toBe(true);
    // health always returns a shape
    const r5 = await hub.health();
    expect(typeof r5.ok).toBe('boolean');
  });
});

// ──────────────────────────────────────────────────────────────────────────
// Noop — separately asserted because by design it returns empty everywhere
// ──────────────────────────────────────────────────────────────────────────

describe('MemoryHub contract — noop adapter (sentinel behavior)', () => {
  const hub: MemoryHub = new NoopMemoryHub();
  const teamId = 'noop-test';

  it('write returns {accepted: false} and persists nothing', async () => {
    const ev = event({ teamId });
    const r = await hub.write(ev);
    expect(r.accepted).toBe(false);
    const out = await hub.read({ teamId });
    expect(out).toEqual([]);
  });

  it('read / search / blockersFor return empty arrays', async () => {
    expect(await hub.read({ teamId })).toEqual([]);
    expect(await hub.search(teamId, 'anything')).toEqual([]);
    expect(await hub.blockersFor(teamId, 'whoever')).toEqual([]);
  });

  it('health reports ok: true', async () => {
    const h = await hub.health();
    expect(h.ok).toBe(true);
    expect(h.backend).toBe('noop');
  });
});
