import { describe, expect, it } from 'vitest';
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
import {
  HarnessScopeViolationError,
  HarnessService,
  type TrustedGatewayScope,
} from './harness.service.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';

const SCOPE: TrustedGatewayScope = {
  actorId: 'actor-trusted',
  tenantId: 'tenant-trusted',
  seatId: 'seat-trusted',
  correlationId: 'correlation-trusted',
};

const READ_CONTEXT: HarnessActorContext = {
  actorId: SCOPE.actorId,
  tenantId: SCOPE.tenantId,
  seatId: SCOPE.seatId,
  correlationId: SCOPE.correlationId,
};

function setup(capabilities?: readonly HarnessCapability[]) {
  const registry = new HarnessRegistry();
  const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
  registry.register(adapter);
  const service = new HarnessService(registry);
  return { registry, adapter, service };
}

async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
  const catalog = await adapter.catalog(READ_CONTEXT);
  const entry = catalog.models.find((model) => model.availability === 'available');
  if (!entry) {
    throw new Error('fixture requires an available model');
  }
  return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
}

describe('HarnessService', () => {
  it('derives the actor context from trusted scope on create', async () => {
    const { service, adapter } = setup();
    const selection = await availableSelection(adapter);

    const snapshot = await service.createSession(SCOPE, {
      conversationId: 'conversation-1',
      selection,
    });

    expect(snapshot.seatId).toBe(SCOPE.seatId);
    expect(snapshot.state).toBe('idle');
    expect(snapshot.selection).toEqual(selection);
    expect(snapshot.nativeSessionId).toBeTruthy();
  });

  it('rejects server-authority fields supplied by an external caller', async () => {
    const { service, adapter } = setup();
    const selection = await availableSelection(adapter);

    const hostile = {
      conversationId: 'conversation-1',
      selection,
      seatId: 'attacker-seat',
      executablePath: '/usr/bin/evil',
      home: '/home/attacker',
      cwd: '/tmp/attacker',
      nativeSessionPath: '/var/native/attacker.jsonl',
    } as unknown as Parameters<HarnessService['createSession']>[1];

    let error: unknown;
    try {
      await service.createSession(SCOPE, hostile);
    } catch (caught) {
      error = caught;
    }

    expect(error).toBeInstanceOf(HarnessScopeViolationError);
    expect((error as HarnessScopeViolationError).field).toBe('seatId');
  });

  it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
    const { service } = setup();
    const selection: HarnessSelection = {
      harnessId: 'ghost-harness',
      providerId: 'p',
      modelId: 'm',
    };

    let error: unknown;
    try {
      await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
    } catch (caught) {
      error = caught;
    }

    expect(error).toBeInstanceOf(HarnessOperationError);
    const dto = (error as HarnessOperationError).dto;
    expect(dto.code).toBe('adapter_unavailable');
    expect(dto.selection).toEqual(selection);
    expect(dto.correlationId).toBe(SCOPE.correlationId);
  });

  it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
    const { service } = setup();
    const selection: HarnessSelection = {
      harnessId: 'fake',
      providerId: 'ghost-provider',
      modelId: 'ghost-model',
    };

    let error: unknown;
    try {
      await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
    } catch (caught) {
      error = caught;
    }

    expect(error).toBeInstanceOf(HarnessOperationError);
    const dto = (error as HarnessOperationError).dto;
    expect(dto.code).toBe('selection_invalid');
    expect(dto.selection).toEqual(selection);
  });

  it('returns model_unavailable without falling back for a known unavailable model', async () => {
    const { service, adapter } = setup();
    const catalog = await adapter.catalog(READ_CONTEXT);
    const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
    expect(unavailable).toBeDefined();
    const selection: HarnessSelection = {
      harnessId: unavailable!.harnessId,
      providerId: unavailable!.providerId,
      modelId: unavailable!.modelId,
    };

    let error: unknown;
    try {
      await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
    } catch (caught) {
      error = caught;
    }

    expect(error).toBeInstanceOf(HarnessOperationError);
    const dto = (error as HarnessOperationError).dto;
    expect(dto.code).toBe('model_unavailable');
    // No substitution: the DTO tuple is exactly what was requested.
    expect(dto.selection).toEqual(selection);
  });

  it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
    const { service, adapter } = setup();
    const selection = await availableSelection(adapter);

    const created = await service.createSession(SCOPE, {
      conversationId: 'conversation-create',
      selection,
    });
    expect(created.state).toBe('idle');
    expect(created.processId).toBeTruthy();
    expect(created.attachedClientIds).toEqual([]);

    const resumed = await service.resumeSession(SCOPE, {
      conversationId: 'conversation-resume',
      nativeSessionId: 'native-preexisting-123',
      selection,
    });
    // Resume binds the supplied native session; create mints a fresh one.
    expect(resumed.nativeSessionId).toBe('native-preexisting-123');
    expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);

    await service.attach(SCOPE, {
      conversationId: 'conversation-create',
      clientId: 'browser-1',
    });
    const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
    expect(afterAttach.attachedClientIds).toEqual(['browser-1']);

    const afterDetach = await service.detach(SCOPE, {
      conversationId: 'conversation-create',
      clientId: 'browser-1',
    });
    // Detach removes the browser attachment only; the process stays alive.
    expect(afterDetach.attachedClientIds).toEqual([]);
    expect(afterDetach.state).toBe('idle');
    expect(afterDetach.processId).toBeTruthy();

    const afterEvict = await service.evict(SCOPE, {
      conversationId: 'conversation-create',
      reason: 'idle_timeout',
    });
    // Evict stops the process but retains the resumable native session.
    expect(afterEvict.state).toBe('evicted');
    expect(afterEvict.processId).toBeUndefined();
    expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);

    const afterEnd = await service.end(SCOPE, {
      conversationId: 'conversation-create',
      reason: 'session_ended',
    });
    // End destructively terminates the native session.
    expect(afterEnd.state).toBe('ended');
  });

  it('fails typed when an unsupported capability is exercised', async () => {
    const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
      (capability) => capability !== 'extensionUi',
    );
    const { service, adapter } = setup(withoutExtensionUi);
    const selection = await availableSelection(adapter);
    await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });

    let error: unknown;
    try {
      await service.respondInteraction(SCOPE, {
        conversationId: 'conversation-1',
        response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
      });
    } catch (caught) {
      error = caught;
    }

    expect(error).toBeInstanceOf(HarnessOperationError);
    expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
  });
});
