using Deal.Grpc.Hosting;
using Deal.Grpc.Telegram;
using Deal.Telegram.Sessions;
using Grpc.Core;
using Grpc.Net.Client;
namespace Deal.Telegram.Core;
///
/// Исходящий gRPC-канал в ядро: клиент Deal.Grpc.Telegram.IngressService (план Task 10, Core/
/// CoreIngressClient.cs; Ruling 1/7). Каждый RPC несёт metadata tenant-id + service-token (Ruling 1);
/// принадлежность сообщений тенанту — только по metadata (ядро не доверяет полю). Сбой связи →
/// UNAVAILABLE «Ядро недоступно…» с логом: буфер недоставленных
/// сообщений не ведётся — неподтверждённые сообщения не помечаются прочитанными и догоняются
/// realtime_sweep (упущенное после рестарта/разрыва, Ruling 7/план Task 10).
///
public sealed class CoreIngressClient : ICoreIngressClient
{
///
/// Ключ gRPC-metadata с id тенанта (зеркало интерцепторов, Ruling 1).
///
public const string TenantIdMetadataKey = "tenant-id";
///
/// Ключ gRPC-metadata с service-token (зеркало интерцепторов, Ruling 1).
///
public const string ServiceTokenMetadataKey = "service-token";
private readonly CoreIngressOptions _options;
private readonly ILogger _logger;
private readonly MtlsCertificates? _mtlsCertificates;
private readonly object _channelGate = new();
private IngressService.IngressServiceClient? _client;
private GrpcChannel? _channel;
///
/// Создаёт клиент ингресса ядра.
///
/// Конфигурация (адрес из env, service-token).
/// Логгер.
/// Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал (dev).
public CoreIngressClient(CoreIngressOptions options, ILogger logger, MtlsCertificates? mtlsCertificates = null)
{
_options = options;
_logger = logger;
_mtlsCertificates = mtlsCertificates;
}
///
public async Task PushMessageAsync(string tenantId, PushMessageRequest message, CancellationToken cancellationToken)
{
IngressService.IngressServiceClient client = GetClient();
try
{
return await client.PushMessageAsync(message, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw Fail("PushMessage", exception);
}
}
///
public async Task> SyncDialogsAsync(string tenantId, IReadOnlyList entries, CancellationToken cancellationToken)
{
IngressService.IngressServiceClient client = GetClient();
var request = new SyncDialogsRequest();
request.Entries.AddRange(entries);
try
{
SyncDialogsReply reply = await client.SyncDialogsAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
return reply.MonitoredIds;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw Fail("SyncDialogs", exception);
}
}
// Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса).
private IngressService.IngressServiceClient GetClient()
{
lock (_channelGate)
{
if (_client is null)
{
// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским
// сертификатом и проверяет CA ядра; dev — plaintext-канал (Ruling 2 этапа 6).
if (_mtlsCertificates is not null)
{
_channel = GrpcChannel.ForAddress(
_options.IngressEndpoint,
new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() });
}
else
{
_channel = GrpcChannel.ForAddress(_options.IngressEndpoint);
}
_client = new IngressService.IngressServiceClient(_channel);
}
return _client;
}
}
// Metadata вызова (tenant-id + service-token) и deadline (Ruling 1).
// tenantId: Id тенанта.
private CallOptions CallOptions(string tenantId)
{
var metadata = new Metadata
{
{ TenantIdMetadataKey, tenantId },
{ ServiceTokenMetadataKey, _options.ServiceToken },
};
return new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(CoreIngressOptions.RpcTimeoutSeconds));
}
// Переводит сбой вызова в SessionException (UNAVAILABLE) со структурированным логом.
// action: Действие (PushMessage/SyncDialogs) для лога аудита (Ruling 13).
// exception: Исключение вызова.
private SessionException Fail(string action, Exception exception)
{
_logger.LogWarning(exception, "Аудит: ингресс ядра {Action} → сбой ({Endpoint})", action, _options.IngressEndpoint);
return new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable, exception);
}
}