import { describe, expect, it } from 'vitest';
import type {
  AgentRuntimeProvider,
  RuntimeAttachHandle,
  RuntimeAttachMode,
  RuntimeCapability,
  RuntimeCapabilitySet,
  RuntimeHealth,
  RuntimeMessage,
  RuntimeScope,
  RuntimeSession,
  RuntimeSessionTree,
  RuntimeStreamEvent,
} from '@mosaicstack/types';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import type { ActorTenantScope } from '../../auth/session-scope.js';
import {
  RuntimeProviderAuditService,
  RuntimeProviderService,
  type RuntimeAuditEvent,
  type RuntimeAuditSink,
  type RuntimeApprovalVerifier,
} from '../runtime-provider-registry.service.js';

process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';

const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
const CONTEXT = {
  actorScope: OWNER_SCOPE,
  channelId: 'cli',
  correlationId: 'correlation-1',
};

class RecordingRuntimeProvider implements AgentRuntimeProvider {
  readonly id = 'fleet';
  readonly receivedScopes: RuntimeScope[] = [];
  readonly sentMessages: RuntimeMessage[] = [];
  terminateCalls = 0;
  throwAfterSend = false;
  throwAuthorization = false;

  constructor(private readonly supported: RuntimeCapability[]) {}

  async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
    this.receivedScopes.push(scope);
    return { supported: this.supported };
  }

  async health(scope: RuntimeScope): Promise<RuntimeHealth> {
    this.receivedScopes.push(scope);
    return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
  }

  async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
    this.receivedScopes.push(scope);
    return [];
  }

  async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
    this.receivedScopes.push(scope);
    return [];
  }

  async *streamSession(
    _sessionId: string,
    _cursor: string | undefined,
    scope: RuntimeScope,
  ): AsyncIterable<RuntimeStreamEvent> {
    this.receivedScopes.push(scope);
    return;
  }

  async sendMessage(
    _sessionId: string,
    message: RuntimeMessage,
    scope: RuntimeScope,
  ): Promise<void> {
    this.receivedScopes.push(scope);
    this.sentMessages.push(message);
    if (this.throwAuthorization) {
      throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
    }
    if (this.throwAfterSend) {
      throw new Error('provider acknowledgement failed');
    }
  }

  async attach(
    sessionId: string,
    mode: RuntimeAttachMode,
    scope: RuntimeScope,
  ): Promise<RuntimeAttachHandle> {
    this.receivedScopes.push(scope);
    return {
      attachmentId: 'attachment-1',
      sessionId,
      mode,
      expiresAt: '2026-07-12T00:00:00.000Z',
    };
  }

  async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
    this.receivedScopes.push(scope);
  }

  async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
    this.receivedScopes.push(scope);
    this.terminateCalls += 1;
  }
}

class RecordingAuditSink implements RuntimeAuditSink {
  readonly events: RuntimeAuditEvent[] = [];

  async record(event: RuntimeAuditEvent): Promise<void> {
    this.events.push(event);
  }
}

class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
  async consume(): Promise<boolean> {
    return false;
  }
}

class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
  consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;

  async consume(
    _approvalRef: string,
    action: Parameters<RuntimeApprovalVerifier['consume']>[1],
  ): Promise<boolean> {
    this.consumedAction = action;
    return true;
  }
}

function makeService(
  provider: RecordingRuntimeProvider,
  audit: RuntimeAuditSink = new RecordingAuditSink(),
  approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
): RuntimeProviderService {
  const registry = new AgentRuntimeProviderRegistry();
  registry.register(provider);
  return new RuntimeProviderService(registry, audit, approval);
}

describe('RuntimeProviderService security boundary', (): void => {
  it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
    const provider = new RecordingRuntimeProvider(['session.send']);
    const audit = new RecordingAuditSink();
    const service = makeService(provider, audit);

    await service.sendMessage(
      'fleet',
      'session-1',
      { content: 'hello', idempotencyKey: 'key-1' },
      CONTEXT,
    );

    const providerScope = provider.receivedScopes[0];
    expect(providerScope).toEqual({
      actorId: OWNER_SCOPE.userId,
      tenantId: OWNER_SCOPE.tenantId,
      channelId: CONTEXT.channelId,
      correlationId: CONTEXT.correlationId,
    });
    expect(Object.isFrozen(providerScope)).toBe(true);
    expect(audit.events).toContainEqual(
      expect.objectContaining({
        providerId: 'fleet',
        operation: 'session.send',
        outcome: 'succeeded',
        actorId: OWNER_SCOPE.userId,
        tenantId: OWNER_SCOPE.tenantId,
        channelId: CONTEXT.channelId,
        correlationId: CONTEXT.correlationId,
        resourceId: 'session-1',
        durationMs: expect.any(Number),
      }),
    );
    expect(JSON.stringify(audit.events)).not.toContain('hello');
    expect(JSON.stringify(audit.events)).not.toContain('key-1');
  });

  it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise<void> => {
    const provider = new RecordingRuntimeProvider(['session.send']);
    let persisted: unknown;
    const durableAudit = new RuntimeProviderAuditService({
      logs: {
        ingest: async (entry: unknown): Promise<unknown> => {
          persisted = entry;
          return entry;
        },
      },
    } as never);
    const service = makeService(provider, durableAudit);

    await service.sendMessage(
      'fleet',
      'session/credential-canary=secret-value',
      { content: 'safe message', idempotencyKey: 'key-1' },
      CONTEXT,
    );

    expect(provider.sentMessages).toHaveLength(1);
    expect(JSON.stringify(persisted)).not.toContain('secret-value');
  });

  it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
    const provider = new RecordingRuntimeProvider([]);
    const service = makeService(provider);

    await expect(
      service.sendMessage(
        'fleet',
        'session-1',
        { content: 'hello', idempotencyKey: 'key-1' },
        CONTEXT,
      ),
    ).rejects.toThrow(/capability denied/);
    expect(provider.sentMessages).toEqual([]);
  });

  it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
    const provider = new RecordingRuntimeProvider(['session.terminate']);
    const approval = new DenyingApprovalVerifier();
    const audit = new RecordingAuditSink();
    const service = makeService(provider, audit, approval);

    await expect(
      service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
    ).rejects.toThrow(/approval denied/);
    expect(provider.terminateCalls).toBe(0);
    expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
  });

  it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
    const provider = new RecordingRuntimeProvider(['session.terminate']);
    const approval = new AcceptingApprovalVerifier();
    const service = makeService(provider, new RecordingAuditSink(), approval);

    await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);

    expect(approval.consumedAction).toEqual({
      providerId: 'fleet',
      sessionId: 'session-1',
      actorId: OWNER_SCOPE.userId,
      tenantId: OWNER_SCOPE.tenantId,
      channelId: CONTEXT.channelId,
      correlationId: CONTEXT.correlationId,
      agentName: process.env['MOSAIC_AGENT_NAME'],
    });
    expect(provider.terminateCalls).toBe(1);
  });

  it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
    const provider = new RecordingRuntimeProvider(['session.send']);
    const unavailableAudit: RuntimeAuditSink = {
      async record(): Promise<void> {
        throw new Error('audit unavailable');
      },
    };
    const service = makeService(provider, unavailableAudit);

    await expect(
      service.sendMessage(
        'fleet',
        'session-1',
        { content: 'hello', idempotencyKey: 'key-1' },
        CONTEXT,
      ),
    ).rejects.toThrow(/audit unavailable/);
    expect(provider.sentMessages).toEqual([]);
  });

  it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
    const provider = new RecordingRuntimeProvider(['session.send']);
    provider.throwAfterSend = true;
    const audit = new RecordingAuditSink();
    const service = makeService(provider, audit);

    await expect(
      service.sendMessage(
        'fleet',
        'session-1',
        { content: 'hello', idempotencyKey: 'key-1' },
        CONTEXT,
      ),
    ).rejects.toThrow(/provider acknowledgement failed/);
    expect(provider.sentMessages).toHaveLength(1);
    expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
      'requested',
      'failed',
    ]);
    expect(audit.events.at(-1)).toMatchObject({
      errorCode: 'provider_error',
      durationMs: expect.any(Number),
    });
  });

  it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
    const provider = new RecordingRuntimeProvider(['session.send']);
    provider.throwAuthorization = true;
    const audit = new RecordingAuditSink();
    const service = makeService(provider, audit);

    await expect(
      service.sendMessage(
        'fleet',
        'session-1',
        { content: 'hello', idempotencyKey: 'key-1' },
        CONTEXT,
      ),
    ).rejects.toThrow(/provider authorization denied/);
    expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
  });

  it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
    let persisted: unknown;
    const ingest = async (entry: unknown): Promise<unknown> => {
      persisted = entry;
      return entry;
    };
    const service = new RuntimeProviderAuditService({ logs: { ingest } } as never);

    await service.record({
      providerId: 'fleet',
      operation: 'session.send',
      outcome: 'succeeded',
      actorId: 'owner-1',
      tenantId: 'tenant-1',
      channelId: 'cli',
      correlationId: 'correlation-1',
      resourceId: 'session-1',
      durationMs: 12,
    });

    expect(persisted).toMatchObject({
      content: 'runtime.provider.audit',
      metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }),
    });
    expect(JSON.stringify(persisted)).not.toContain('approval');
  });

  it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
    const provider = new RecordingRuntimeProvider(['session.send']);
    let auditCalls = 0;
    const audit: RuntimeAuditSink = {
      async record(): Promise<void> {
        auditCalls += 1;
        if (auditCalls === 2) {
          throw new Error('completion audit unavailable');
        }
      },
    };
    const service = makeService(provider, audit);

    await expect(
      service.sendMessage(
        'fleet',
        'session-1',
        { content: 'hello', idempotencyKey: 'key-1' },
        CONTEXT,
      ),
    ).resolves.toBeUndefined();
    expect(provider.sentMessages).toHaveLength(1);
    expect(auditCalls).toBe(2);
  });
});
