/*
 * priority-forge-hub — OrchestrationHub adapter contract tests
 *
 * Parameterized harness: each adapter that supports orchestration must
 * satisfy the same 7-method contract (create / claim / release / renewLease
 * / get / list / getMyClaims). We run the suite against every adapter the
 * current environment supports.
 *
 *   - dryrun runs always (in-process Map-based CAS; tests are isolated)
 *   - pgvector runs when MEMHUB_PGVECTOR_URL is set, e.g.
 *       MEMHUB_PGVECTOR_URL=postgres://postgres:pf-dev@127.0.0.1:5432/priority_forge_memhub
 *
 * noop + xmem are exempt — they explicitly return unsupported_backend
 * for all orchestration methods. We assert that contract separately.
 */

import { afterAll, beforeAll, describe, expect, it } from 'vitest';

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

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

interface OrchFixture {
  name: string;
  build: () => Promise<OrchestrationHub>;
  teardown: (hub: OrchestrationHub) => Promise<void>;
  teamId: string;
}

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

const fixtures: OrchFixture[] = [
  {
    name: 'dryrun',
    teamId: `dryrun-orch-${rs()}`,
    build: async () => new DryrunMemoryHub(),
    teardown: async () => undefined,
  },
];

if (process.env.MEMHUB_PGVECTOR_URL) {
  fixtures.push({
    name: 'pgvector',
    teamId: `pgvector-orch-${rs()}`,
    build: async () =>
      new PgvectorMemoryHub({ connectionString: process.env.MEMHUB_PGVECTOR_URL! }),
    teardown: async (hub) => {
      await (hub as PgvectorMemoryHub).close();
    },
  });
}

// ──────────────────────────────────────────────────────────────────────
// Contract — runs against every supported adapter
// ──────────────────────────────────────────────────────────────────────

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

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

  function input(overrides: Partial<{
    teamId: string;
    title: string;
    description: string;
    deadline: string;
    priority: string;
    originatorAgentId: string;
    originatorTeamId: string;
  }> = {}) {
    return {
      teamId: overrides.teamId ?? teamId,
      title: overrides.title ?? `task ${rs()}`,
      description: overrides.description,
      deadline: overrides.deadline,
      priority: overrides.priority,
      originatorAgentId: overrides.originatorAgentId ?? 'alice',
      originatorTeamId: overrides.originatorTeamId ?? teamId,
    };
  }

  it('supportsOrchestration is true for participating adapters', () => {
    expect(hub.supportsOrchestration).toBe(true);
  });

  it('create() returns SharedTask with status=claimable, no current claim, empty history', async () => {
    const task = await hub.create(input({ title: 'unique-create-test' }));
    expect(task.status).toBe('claimable');
    expect(task.currentClaim).toBeNull();
    expect(task.claimHistory).toEqual([]);
    expect(task.title).toBe('unique-create-test');
    expect(task.originatorAgentId).toBe('alice');
  });

  it('create() persists deadline as a projection seed and get() round-trips it', async () => {
    const created = await hub.create(input({ title: `dl ${rs()}`, deadline: '2026-06-01' }));
    expect(created.deadline).toBe('2026-06-01');
    const fetched = await hub.get(created.id);
    expect(fetched?.deadline).toBe('2026-06-01');
  });

  it('create() persists advisory priority as a projection seed and get() round-trips it', async () => {
    const created = await hub.create(input({ title: `pri ${rs()}`, priority: 'P0' }));
    expect(created.priority).toBe('P0');
    const fetched = await hub.get(created.id);
    expect(fetched?.priority).toBe('P0');
  });

  it('cancel() by the originator retires a claimable task', async () => {
    const task = await hub.create(input({ title: `cancel ${rs()}` }));
    const res = await hub.cancel(task.id, 'alice');
    expect(res.ok).toBe(true);
    expect(res.sharedTask?.status).toBe('cancelled');
    expect((await hub.get(task.id))?.status).toBe('cancelled');
  });

  it('cancel() by a non-originator is rejected', async () => {
    const task = await hub.create(input({ title: `cancel ${rs()}` }));
    const res = await hub.cancel(task.id, 'mallory');
    expect(res.ok).toBe(false);
    expect(res.reason).toBe('unauthorized_originator');
    // unchanged
    expect((await hub.get(task.id))?.status).toBe('claimable');
  });

  it('cancel() on a claimed task is rejected (holder must release)', async () => {
    const task = await hub.create(input({ title: `cancel ${rs()}` }));
    const claim = await hub.claim(task.id, 'bob', teamId, 60_000);
    expect(claim.ok).toBe(true);
    const res = await hub.cancel(task.id, 'alice');
    expect(res.ok).toBe(false);
    expect(res.reason).toBe('currently_claimed');
  });

  it('cancel() on an already-cancelled task is rejected', async () => {
    const task = await hub.create(input({ title: `cancel ${rs()}` }));
    await hub.cancel(task.id, 'alice');
    const res = await hub.cancel(task.id, 'alice');
    expect(res.ok).toBe(false);
    expect(res.reason).toBe('already_terminal');
  });

  it('searchSharedTasks matches title and description substrings, scoped to team', async () => {
    const uniq = rs();
    await hub.create(input({ title: `auth bug ${uniq}` }));
    await hub.create(input({ title: `unrelated ${rs()}`, description: `also mentions ${uniq} here` }));
    await hub.create(input({ title: `noise ${rs()}` }));
    const hits = await hub.searchSharedTasks(teamId, uniq);
    expect(hits.length).toBe(2);
    const empty = await hub.searchSharedTasks(teamId, `none-${rs()}`);
    expect(empty.length).toBe(0);
  });

  it('claim() on a claimable task succeeds and populates currentClaim', async () => {
    const task = await hub.create(input());
    const result = await hub.claim(task.id, 'bob', teamId, 60000);
    expect(result.ok).toBe(true);
    if (!result.ok) return; // type narrowing
    expect(result.claim.holderAgentId).toBe('bob');
    expect(result.claim.holderTeamId).toBe(teamId);
    expect(result.sharedTask.status).toBe('claimed');
    expect(new Date(result.claim.leaseUntil).getTime()).toBeGreaterThan(Date.now());
  });

  it('claim() by a different agent on an already-claimed task returns already_claimed', async () => {
    const task = await hub.create(input());
    const first = await hub.claim(task.id, 'bob', teamId, 60000);
    expect(first.ok).toBe(true);
    const second = await hub.claim(task.id, 'carol', teamId, 60000);
    expect(second.ok).toBe(false);
    if (second.ok) return;
    expect(second.reason).toBe('already_claimed');
    if (second.reason === 'already_claimed') {
      expect(second.currentClaim.holderAgentId).toBe('bob');
    }
  });

  it('same-agent re-claim returns ok=true (idempotent / extends lease)', async () => {
    const task = await hub.create(input());
    const first = await hub.claim(task.id, 'bob', teamId, 60000);
    expect(first.ok).toBe(true);
    const second = await hub.claim(task.id, 'bob', teamId, 120000);
    expect(second.ok).toBe(true);
    if (!second.ok) return;
    expect(second.claim.holderAgentId).toBe('bob');
  });

  it('release() by the holder succeeds and clears currentClaim', async () => {
    const task = await hub.create(input());
    await hub.claim(task.id, 'bob', teamId, 60000);
    const result = await hub.release(task.id, 'bob', 'completed');
    expect(result.ok).toBe(true);
    expect(result.sharedTask?.currentClaim).toBeNull();
    expect(result.sharedTask?.status).toBe('completed');
  });

  it('release() by a non-holder returns not_claim_holder', async () => {
    const task = await hub.create(input());
    await hub.claim(task.id, 'bob', teamId, 60000);
    const result = await hub.release(task.id, 'carol', 'completed');
    expect(result.ok).toBe(false);
    expect(result.reason).toBe('not_claim_holder');
  });

  it('release() with outcome=deferred returns the task to claimable state', async () => {
    const task = await hub.create(input());
    await hub.claim(task.id, 'bob', teamId, 60000);
    const released = await hub.release(task.id, 'bob', 'deferred');
    expect(released.ok).toBe(true);
    expect(released.sharedTask?.status).toBe('claimable');
    // Confirm another agent can claim it.
    const reclaim = await hub.claim(task.id, 'carol', teamId, 60000);
    expect(reclaim.ok).toBe(true);
  });

  it('renewLease() by the holder extends leaseUntil', async () => {
    const task = await hub.create(input());
    const first = await hub.claim(task.id, 'bob', teamId, 60000);
    expect(first.ok).toBe(true);
    if (!first.ok) return;
    const originalUntil = first.claim.leaseUntil;
    // Wait a tick so the new timestamp is strictly greater.
    await new Promise((r) => setTimeout(r, 10));
    const renewed = await hub.renewLease(task.id, 'bob', 300000);
    expect(renewed.ok).toBe(true);
    if (!renewed.ok) return;
    expect(new Date(renewed.claim!.leaseUntil).getTime()).toBeGreaterThan(
      new Date(originalUntil).getTime(),
    );
  });

  it('renewLease() by a non-holder returns not_claim_holder', async () => {
    const task = await hub.create(input());
    await hub.claim(task.id, 'bob', teamId, 60000);
    const result = await hub.renewLease(task.id, 'carol', 60000);
    expect(result.ok).toBe(false);
    expect(result.reason).toBe('not_claim_holder');
  });

  it('get() returns the SharedTask record; null for unknown ids', async () => {
    const task = await hub.create(input({ title: 'getter-test' }));
    const fetched = await hub.get(task.id);
    expect(fetched?.id).toBe(task.id);
    expect(fetched?.title).toBe('getter-test');
    const missing = await hub.get(`nonexistent-${rs()}`);
    expect(missing).toBeNull();
  });

  it('list() filters by status', async () => {
    const stillClaimable = await hub.create(input({ title: `claimable-${rs()}` }));
    const willComplete = await hub.create(input({ title: `complete-${rs()}` }));
    await hub.claim(willComplete.id, 'bob', teamId, 60000);
    await hub.release(willComplete.id, 'bob', 'completed');

    const claimable = await hub.list({ teamId, status: 'claimable' });
    const completed = await hub.list({ teamId, status: 'completed' });
    expect(claimable.some((t) => t.id === stillClaimable.id)).toBe(true);
    expect(claimable.some((t) => t.id === willComplete.id)).toBe(false);
    expect(completed.some((t) => t.id === willComplete.id)).toBe(true);
  });

  it('getMyClaims() returns only tasks the agent currently holds an unexpired lease on', async () => {
    const myTask = await hub.create(input());
    const otherTask = await hub.create(input());
    await hub.claim(myTask.id, 'bob', teamId, 60000);
    await hub.claim(otherTask.id, 'carol', teamId, 60000);
    const bobsClaims = await hub.getMyClaims(teamId, 'bob');
    expect(bobsClaims.some((t) => t.id === myTask.id)).toBe(true);
    expect(bobsClaims.some((t) => t.id === otherTask.id)).toBe(false);
  });

  it('claimHistory accumulates entries in order', async () => {
    const task = await hub.create(input());
    await hub.claim(task.id, 'bob', teamId, 60000);
    await hub.renewLease(task.id, 'bob', 90000);
    await hub.release(task.id, 'bob', 'deferred');
    const final = await hub.get(task.id);
    expect(final?.claimHistory.length).toBeGreaterThanOrEqual(3);
    const events = final?.claimHistory.map((e) => e.event) ?? [];
    expect(events).toContain('claimed');
    expect(events).toContain('renewed');
    expect(events).toContain('released');
  });

  it('no method throws on garbage / unknown ids — all return typed shapes', async () => {
    const fakeId = `fake-${rs()}`;
    expect(await hub.get(fakeId)).toBeNull();
    expect((await hub.claim(fakeId, 'bob', teamId, 60000)).ok).toBe(false);
    expect((await hub.release(fakeId, 'bob', 'completed')).ok).toBe(false);
    expect((await hub.renewLease(fakeId, 'bob', 60000)).ok).toBe(false);
    expect(Array.isArray(await hub.list({ teamId }))).toBe(true);
    expect(Array.isArray(await hub.getMyClaims(teamId, 'bob'))).toBe(true);
  });
});

// ──────────────────────────────────────────────────────────────────────
// Unsupported backends — separately asserted
// ──────────────────────────────────────────────────────────────────────

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

  it('supportsOrchestration is false', () => {
    expect(hub.supportsOrchestration).toBe(false);
  });

  it('create() throws (no SharedTask to return)', async () => {
    await expect(
      hub.create({
        teamId,
        title: 't',
        originatorAgentId: 'a',
        originatorTeamId: teamId,
      }),
    ).rejects.toThrow();
  });

  it('claim() returns unsupported_backend', async () => {
    const r = await hub.claim('any', 'a', teamId, 60000);
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.reason).toBe('unsupported_backend');
  });

  it('release() returns unsupported_backend', async () => {
    const r = await hub.release('any', 'a', 'completed');
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('unsupported_backend');
  });

  it('renewLease() returns unsupported_backend', async () => {
    const r = await hub.renewLease('any', 'a', 60000);
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('unsupported_backend');
  });

  it('get() returns null; list() / getMyClaims() return []', async () => {
    expect(await hub.get('any')).toBeNull();
    expect(await hub.list({ teamId })).toEqual([]);
    expect(await hub.getMyClaims(teamId, 'a')).toEqual([]);
  });
});

describe('OrchestrationHub contract — xmem adapter (stubbed unsupported)', () => {
  const hub: OrchestrationHub = new XMemMemoryHub({
    baseUrl: 'https://api.xtrace.ai/v1',
    apiKey: 'placeholder',
    orgID: 'placeholder',
  });

  it('supportsOrchestration is false (deploy pending)', () => {
    expect(hub.supportsOrchestration).toBe(false);
  });

  it('claim() returns unsupported_backend without network call', async () => {
    const r = await hub.claim('any', 'a', 'team', 60000);
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.reason).toBe('unsupported_backend');
  });
});

// Type-only assertion: SharedTask carries identity + audit only, never
// task content fields. This is a compile-time guardrail check.
//
// If anyone adds priority/effort/notes to SharedTask in types.ts, this
// won't catch it (TS is structural; we can't ASSERT a type doesn't have
// a field). But the test below confirms the runtime shape at least.
describe('SharedTask schema guardrail', () => {
  it('a freshly-created SharedTask has no PF-only content fields', async () => {
    const hub: OrchestrationHub = new DryrunMemoryHub();
    const task: SharedTask = await hub.create({
      teamId: `guardrail-${rs()}`,
      title: 'guardrail probe',
      originatorAgentId: 'a',
      originatorTeamId: 'team',
    });
    const allowedKeys = new Set([
      'id', 'teamId', 'title', 'description', 'deadline', 'priority',
      'originatorAgentId', 'originatorTeamId', 'createdAt',
      'currentClaim', 'claimHistory', 'status',
    ]);
    for (const key of Object.keys(task)) {
      expect(allowedKeys.has(key)).toBe(true);
    }
    // Specifically confirm no leakage of PF-only fields. NOTE: `priority` is an
    // allowed *advisory seed* on SharedTask (set by a lead at publish), so it is
    // NOT forbidden here; what stays forge-local is effort, work durations,
    // ranker weights/scores, and the priority CHANGE history.
    const forbidden = ['effort', 'notes', 'weights', 'priorityScore', 'startedAt', 'completedAt', 'actualWorkTime', 'version'];
    for (const f of forbidden) {
      expect((task as unknown as Record<string, unknown>)[f]).toBeUndefined();
    }
  });
});
