import { describe, expect, it } from 'vitest';
import type {
  HarnessActorContext,
  HarnessAdapter,
  HarnessCatalogEntry,
  HarnessSelection,
} from '@mosaicstack/types';
import { HarnessOperationError } from '../harness.registry.js';

const CONTEXT: HarnessActorContext = {
  actorId: 'contract-actor',
  tenantId: 'contract-tenant',
  seatId: 'contract-seat',
  correlationId: 'contract-correlation',
};

function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
  return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
}

function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
  const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
  if (!entry) {
    throw new Error('contract fixture requires at least one catalog model');
  }
  return entry;
}

async function captureError(run: () => Promise<unknown>): Promise<unknown> {
  try {
    await run();
    return undefined;
  } catch (caught) {
    return caught;
  }
}

/**
 * Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
 * runs it against the fake adapter; Task 13 re-runs the identical suite against
 * the native Pi adapter so both share one behavioral contract.
 */
export function runHarnessAdapterContract(
  label: string,
  createAdapter: () => HarnessAdapter,
): void {
  describe(`harness adapter contract: ${label}`, () => {
    it('mints a fresh native session on create and binds the supplied one on resume', async () => {
      const adapter = createAdapter();
      const catalog = await adapter.catalog(CONTEXT);
      const selection = toSelection(pickAvailable(catalog.models));

      const created = await (
        await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
      ).snapshot();
      const resumed = await (
        await adapter.resume({
          context: CONTEXT,
          conversationId: 'conv-resume',
          nativeSessionId: 'native-supplied-1',
          selection,
        })
      ).snapshot();

      expect(created.nativeSessionId).toBeTruthy();
      expect(resumed.nativeSessionId).toBe('native-supplied-1');
      expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
      expect(created.seatId).toBe(CONTEXT.seatId);
    });

    it('gives detach, evict, and end distinct effects (not aliases)', async () => {
      const adapter = createAdapter();
      const catalog = await adapter.catalog(CONTEXT);
      const selection = toSelection(pickAvailable(catalog.models));
      const handle = await adapter.create({
        context: CONTEXT,
        conversationId: 'conv-lifecycle',
        selection,
      });

      await handle.attach({ clientId: 'browser-1' });
      await handle.detach('browser-1');
      const afterDetach = await handle.snapshot();
      expect(afterDetach.attachedClientIds).toEqual([]);
      expect(afterDetach.state).not.toBe('evicted');
      expect(afterDetach.state).not.toBe('ended');

      await handle.evictProcess('idle_timeout');
      const afterEvict = await handle.snapshot();
      expect(afterEvict.state).toBe('evicted');
      // The native session survives eviction (resumable); the process does not.
      expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
      expect(afterEvict.processId).toBeUndefined();

      await handle.endSession('session_ended');
      const afterEnd = await handle.snapshot();
      expect(afterEnd.state).toBe('ended');
      // End is not an alias of evict.
      expect(afterEnd.state).not.toBe(afterEvict.state);
    });

    it('never substitutes the first catalog row for an unknown selection', async () => {
      const adapter = createAdapter();
      const catalog = await adapter.catalog(CONTEXT);
      const firstRow = catalog.models[0];
      if (!firstRow) {
        throw new Error('contract fixture requires a catalog model');
      }
      const start = toSelection(pickAvailable(catalog.models));
      const handle = await adapter.create({
        context: CONTEXT,
        conversationId: 'conv-nosub',
        selection: start,
      });

      const bogus: HarnessSelection = {
        harnessId: adapter.id,
        providerId: 'contract-ghost-provider',
        modelId: 'contract-ghost-model',
      };
      const error = await captureError(() => handle.setModel(bogus));

      expect(error).toBeInstanceOf(HarnessOperationError);
      const dto = (error as HarnessOperationError).dto;
      expect(dto.code).toBe('selection_invalid');
      expect(dto.selection).toEqual(bogus);
      expect(dto.selection).not.toEqual(toSelection(firstRow));
      expect((await handle.snapshot()).selection).toEqual(start);
    });

    it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
      const adapter = createAdapter();
      const descriptor = await adapter.describe(CONTEXT);
      const catalog = await adapter.catalog(CONTEXT);
      const selection = toSelection(pickAvailable(catalog.models));
      const handle = await adapter.create({
        context: CONTEXT,
        conversationId: 'conv-interaction',
        selection,
      });

      const response = {
        requestId: 'interaction-1',
        type: 'confirm',
        accepted: true,
      } as const;

      if (descriptor.capabilities.includes('extensionUi')) {
        await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
      } else {
        const error = await captureError(() => handle.respondInteraction(response));
        expect(error).toBeInstanceOf(HarnessOperationError);
        expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
      }
    });
  });
}
