using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MarcusLaw.OutlookAddin.Core.Models;
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
using Polly.Timeout;
using Serilog;

namespace MarcusLaw.OutlookAddin.Core.Services
{
    public sealed class EspoCrmClient : IEspoCrmClient
    {
        private const string ApiPrefix = "api/v1/";

        private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();

        private static JsonSerializerOptions CreateJsonOptions()
        {
            var options = new JsonSerializerOptions
            {
                PropertyNameCaseInsensitive = true,
                DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
                Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
            };
            // EspoCRM emits dates as "YYYY-MM-DD HH:mm:ss" (no timezone) which
            // .NET's built-in DateTimeOffset reader rejects. Register a global
            // tolerant converter so every model that exposes a DateTimeOffset?
            // works without per-property attributes.
            options.Converters.Add(new MarcusLaw.OutlookAddin.Core.Json.FlexibleDateTimeOffsetConverter());
            return options;
        }

        private readonly HttpClient _httpClient;
        private readonly ILogger _logger;
        private readonly ResiliencePipeline<HttpResponseMessage> _pipeline;

        public EspoCrmClient(HttpClient httpClient, string baseUrl, string username, string apiKey, ILogger logger)
        {
            if (httpClient == null) throw new ArgumentNullException(nameof(httpClient));
            if (string.IsNullOrWhiteSpace(baseUrl)) throw new ArgumentException("baseUrl required", nameof(baseUrl));
            if (string.IsNullOrWhiteSpace(apiKey)) throw new ArgumentException("apiKey required", nameof(apiKey));

            _httpClient = httpClient;
            _logger = logger ?? throw new ArgumentNullException(nameof(logger));

            var trimmed = baseUrl.TrimEnd('/') + "/";
            _httpClient.BaseAddress = new Uri(trimmed, UriKind.Absolute);

            // EspoCRM auth conventions:
            //   * API-User type     → header "X-Api-Key: <apiKey>"            (apiKey is the secret)
            //   * Regular user+pwd  → header "Espo-Authorization: base64(u:p)"
            // We default to X-Api-Key since the rollout plan provisions
            // dedicated API users. If a username is supplied AND the supplied
            // "API key" actually looks like a password (no hex chars / long
            // enough), we still keep the username:apiKey Basic fallback so
            // either kind of credential pair works.
            _httpClient.DefaultRequestHeaders.Remove("X-Api-Key");
            _httpClient.DefaultRequestHeaders.Remove("Espo-Authorization");
            _httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
            if (!string.IsNullOrWhiteSpace(username))
            {
                var token = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + apiKey));
                _httpClient.DefaultRequestHeaders.Add("Espo-Authorization", token);
            }
            _httpClient.DefaultRequestHeaders.Accept.Clear();
            _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            _pipeline = BuildPipeline(logger);
        }

        private static ResiliencePipeline<HttpResponseMessage> BuildPipeline(ILogger logger)
        {
            var transientPredicate = new PredicateBuilder<HttpResponseMessage>()
                .Handle<HttpRequestException>()
                .Handle<TimeoutRejectedException>()
                .HandleResult(r => (int)r.StatusCode >= 500 || r.StatusCode == HttpStatusCode.RequestTimeout);

            return new ResiliencePipelineBuilder<HttpResponseMessage>()
                .AddTimeout(TimeSpan.FromSeconds(15))
                .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
                {
                    MaxRetryAttempts = 3,
                    Delay = TimeSpan.FromMilliseconds(500),
                    BackoffType = DelayBackoffType.Exponential,
                    UseJitter = true,
                    ShouldHandle = transientPredicate,
                    OnRetry = args =>
                    {
                        logger.Warning("EspoCRM transient failure, retry {Attempt} after {Delay}ms",
                            args.AttemptNumber + 1, args.RetryDelay.TotalMilliseconds);
                        return default;
                    }
                })
                .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
                {
                    FailureRatio = 0.5,
                    MinimumThroughput = 5,
                    BreakDuration = TimeSpan.FromSeconds(30),
                    SamplingDuration = TimeSpan.FromSeconds(30),
                    ShouldHandle = transientPredicate,
                    OnOpened = args =>
                    {
                        logger.Error("EspoCRM circuit breaker opened for {BreakDuration}s",
                            args.BreakDuration.TotalSeconds);
                        return default;
                    },
                    OnClosed = args =>
                    {
                        logger.Information("EspoCRM circuit breaker closed");
                        return default;
                    }
                })
                .Build();
        }

        public async Task<FileEmailResponse> FileMailAsync(FileEmailRequest request, CancellationToken cancellationToken = default)
        {
            if (request == null) throw new ArgumentNullException(nameof(request));

            var json = JsonSerializer.Serialize(request, JsonOptions);
            var response = await ExecuteAsync(HttpMethod.Post, "MailRouter/file", json, cancellationToken).ConfigureAwait(false);

            try
            {
                if (response.StatusCode == HttpStatusCode.Conflict)
                {
                    _logger.Information("EspoCRM /MailRouter/file returned 409 (already filed) for messageId={MessageId}", request.MessageId);
                    var conflictBody = await ReadBodyAsync(response).ConfigureAwait(false);
                    return DeserializeOrEmpty(conflictBody, request);
                }

                await EnsureSuccessAsync(response).ConfigureAwait(false);
                var body = await ReadBodyAsync(response).ConfigureAwait(false);
                return JsonSerializer.Deserialize<FileEmailResponse>(body, JsonOptions)
                    ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty FileEmailResponse from EspoCRM");
            }
            finally
            {
                response.Dispose();
            }
        }

        public Task<SearchResult> GlobalSearchAsync(string query, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrWhiteSpace(query)) throw new ArgumentException("query required", nameof(query));
            var path = "GlobalSearch?q=" + Uri.EscapeDataString(query);
            return GetJsonAsync<SearchResult>(path, cancellationToken);
        }

        public async Task<List<EmailAddressMatch>> EmailAddressSearchAsync(string emailAddress, string? entityType = null, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrWhiteSpace(emailAddress)) throw new ArgumentException("emailAddress required", nameof(emailAddress));
            var path = "EmailAddress/search?q=" + Uri.EscapeDataString(emailAddress);
            if (!string.IsNullOrWhiteSpace(entityType))
            {
                path += "&entityType=" + Uri.EscapeDataString(entityType);
            }

            // EspoCRM returns a bare JSON array here, not the {total, list}
            // envelope used by GlobalSearch.
            var raw = await GetRawJsonAsync(path, cancellationToken).ConfigureAwait(false);
            if (string.IsNullOrWhiteSpace(raw)) return new List<EmailAddressMatch>();
            try
            {
                return JsonSerializer.Deserialize<List<EmailAddressMatch>>(raw, JsonOptions)
                       ?? new List<EmailAddressMatch>();
            }
            catch (JsonException)
            {
                // Some hosted builds wrap the array in { "list": [...] } — tolerate that too.
                try
                {
                    var envelope = JsonSerializer.Deserialize<EspoListResponse<EmailAddressMatch>>(raw, JsonOptions);
                    return envelope?.List ?? new List<EmailAddressMatch>();
                }
                catch (JsonException)
                {
                    _logger.Warning("EmailAddress/search returned unexpected JSON shape: {Snippet}",
                        raw.Substring(0, Math.Min(200, raw.Length)));
                    return new List<EmailAddressMatch>();
                }
            }
        }

        public Task<CaseEntity> GetCaseAsync(string id, string? select = null, CancellationToken cancellationToken = default)
            => GetEntityAsync<CaseEntity>("Case", id, select, cancellationToken);

        public Task<AccountEntity> GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default)
            => GetEntityAsync<AccountEntity>("Account", id, select, cancellationToken);

        public Task<ContactEntity> GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default)
            => GetEntityAsync<ContactEntity>("Contact", id, select, cancellationToken);

        public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
            => GetJsonAsync<UserInfo>("App/user", cancellationToken);

        public async Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName required", nameof(fileName));
            if (string.IsNullOrWhiteSpace(base64Content)) throw new ArgumentException("base64Content required", nameof(base64Content));

            var mime = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
            var dataUri = "data:" + mime + ";base64," + base64Content;
            // Per the official EspoCRM API docs (POST /Attachment), the binary
            // is sent in the "file" field as a data-URI. The previous extra
            // "contents" alias was an undocumented legacy that strict tenants
            // reject with 400.
            var payload = new Dictionary<string, object?>
            {
                ["name"] = fileName,
                ["type"] = mime,
                ["role"] = "Attachment",
                ["relatedType"] = "Document",
                ["field"] = "file",
                ["file"] = dataUri
            };

            var json = JsonSerializer.Serialize(payload, JsonOptions);
            _logger.Information(
                "UploadAttachment POST /Attachment (name={Name}, type={Type}, base64Length={Length})",
                fileName, mime, base64Content.Length);

            var response = await ExecuteAsync(HttpMethod.Post, "Attachment", json, cancellationToken).ConfigureAwait(false);
            try
            {
                if (!response.IsSuccessStatusCode)
                {
                    var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
                    var reason = string.Join(",", response.Headers.TryGetValues("X-Status-Reason", out var v) ? v : Array.Empty<string>());
                    _logger.Warning(
                        "UploadAttachment failed: {Status} (X-Status-Reason={Reason}) body={Body}",
                        response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
                    // 401 only = auth. 403 here is content/permission, not creds.
                    if (response.StatusCode == HttpStatusCode.Unauthorized)
                    {
                        throw new EspoCrmAuthorizationException(
                            $"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
                    }
                    throw new EspoCrmHttpException(response.StatusCode, errBody,
                        $"EspoCRM Attachment upload returned {(int)response.StatusCode} {response.StatusCode}" +
                        (string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
                }
                var body = await ReadBodyAsync(response).ConfigureAwait(false);
                return JsonSerializer.Deserialize<AttachmentEntity>(body, JsonOptions)
                    ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty AttachmentEntity from EspoCRM");
            }
            finally
            {
                response.Dispose();
            }
        }

        public async Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));
            if (string.IsNullOrWhiteSpace(attachmentFileId)) throw new ArgumentException("attachmentFileId required", nameof(attachmentFileId));

            // Marcus-Law's EspoCRM has `publishDate` configured as required on
            // Document. The field accepts a plain date (YYYY-MM-DD). Default
            // to today; the user can edit it later in EspoCRM if needed.
            var todayIso = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);

            var payload = new Dictionary<string, object?>
            {
                ["name"] = name,
                ["fileId"] = attachmentFileId,
                ["status"] = "Active",
                ["publishDate"] = todayIso
            };
            if (!string.IsNullOrWhiteSpace(parentType)) payload["parentType"] = parentType;
            if (!string.IsNullOrWhiteSpace(parentId)) payload["parentId"] = parentId;
            if (!string.IsNullOrWhiteSpace(folderId)) payload["folderId"] = folderId;

            var json = JsonSerializer.Serialize(payload, JsonOptions);
            _logger.Information(
                "CreateDocument POST /Document (name={Name}, fileId={FileId}, parent={ParentType}/{ParentId}, folder={FolderId})",
                name, attachmentFileId, parentType, parentId, folderId ?? "(none)");

            var response = await ExecuteAsync(HttpMethod.Post, "Document", json, cancellationToken).ConfigureAwait(false);
            try
            {
                if (!response.IsSuccessStatusCode)
                {
                    var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
                    var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
                        ? string.Join(",", v) : "(none)";
                    _logger.Warning(
                        "CreateDocument failed: {Status} (X-Status-Reason={Reason}) body={Body}",
                        response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
                    if (response.StatusCode == HttpStatusCode.Unauthorized)
                    {
                        throw new EspoCrmAuthorizationException(
                            $"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
                    }
                    throw new EspoCrmHttpException(response.StatusCode, errBody,
                        $"EspoCRM Document create returned {(int)response.StatusCode} {response.StatusCode}" +
                        (string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
                }
                var body = await ReadBodyAsync(response).ConfigureAwait(false);
                return JsonSerializer.Deserialize<DocumentEntity>(body, JsonOptions)
                    ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty DocumentEntity from EspoCRM");
            }
            finally
            {
                response.Dispose();
            }
        }

        public async Task<List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrWhiteSpace(caseId)) return new List<DocumentFolder>();

            // Some EspoCRM installs reject filtering by polymorphic parentType
            // with a 400. ParentId is a regular string field, and an ID alone
            // is unique enough — we'll filter just by that. We additionally
            // verify parentType client-side to be safe.
            var path =
                "Document?select=" + Uri.EscapeDataString("id,folderId,folderName,parentType") +
                "&where[0][type]=equals" +
                "&where[0][attribute]=parentId" +
                "&where[0][value]=" + Uri.EscapeDataString(caseId) +
                "&maxSize=200";

            // Hand-rolled error capture (instead of GetJsonAsync) so a 400 from
            // a picky tenant ends up in the log with body + X-Status-Reason.
            HttpResponseMessage? response = null;
            try
            {
                response = await ExecuteAsync(HttpMethod.Get, path, null, cancellationToken).ConfigureAwait(false);
                if (!response.IsSuccessStatusCode)
                {
                    var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
                    var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
                        ? string.Join(",", v) : "(none)";
                    _logger.Warning(
                        "ListFoldersUsedInCase({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}",
                        caseId, response.StatusCode, reason,
                        string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
                    return new List<DocumentFolder>();
                }
                var body = await ReadBodyAsync(response).ConfigureAwait(false);
                var parsed = JsonSerializer.Deserialize<EspoListResponse<DocumentEntity>>(body, JsonOptions)
                             ?? new EspoListResponse<DocumentEntity>();

                var seen = new HashSet<string>(StringComparer.Ordinal);
                var folders = new List<DocumentFolder>();
                foreach (var doc in parsed.List)
                {
                    // Defensive: if the server returns docs from other parents
                    // (shouldn't, but ID collisions are theoretically possible),
                    // drop them.
                    if (!string.IsNullOrEmpty(doc.ParentType) &&
                        !string.Equals(doc.ParentType, "Case", StringComparison.OrdinalIgnoreCase))
                    {
                        continue;
                    }
                    if (string.IsNullOrEmpty(doc.FolderId)) continue;
                    if (!seen.Add(doc.FolderId!)) continue;
                    folders.Add(new DocumentFolder
                    {
                        Id = doc.FolderId!,
                        Name = string.IsNullOrWhiteSpace(doc.FolderName) ? doc.FolderId! : doc.FolderName!
                    });
                }
                _logger.Information(
                    "ListFoldersUsedInCase({CaseId}) → {DocumentCount} documents, {FolderCount} distinct folders",
                    caseId, parsed.List.Count, folders.Count);
                return folders;
            }
            catch (Exception ex)
            {
                _logger.Warning(ex, "ListFoldersUsedInCaseAsync({CaseId}) failed", caseId);
                return new List<DocumentFolder>();
            }
            finally
            {
                response?.Dispose();
            }
        }

        public async Task EnsureCaseSubfoldersAsync(string caseId, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
            var payload = new Dictionary<string, object?>
            {
                ["entityType"] = "Case",
                ["entityId"] = caseId
            };
            var json = JsonSerializer.Serialize(payload, JsonOptions);
            var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/createSubfolders", json, cancellationToken).ConfigureAwait(false);
            try
            {
                if (!response.IsSuccessStatusCode)
                {
                    var body = await ReadBodyAsync(response).ConfigureAwait(false);
                    var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
                    _logger.Warning(
                        "EnsureCaseSubfolders({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}",
                        caseId, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
                    if (response.StatusCode == HttpStatusCode.Unauthorized)
                    {
                        throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key.");
                    }
                    // Don't throw — folder creation is best-effort. Upload will fail
                    // with its own clear error if the parent really isn't there.
                    return;
                }
                _logger.Information("EnsureCaseSubfolders({CaseId}) OK", caseId);
            }
            finally
            {
                response.Dispose();
            }
        }

        public async Task<List<NetworkStorageItem>> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default)
        {
            var query = "NetworkStorage/folderContents";
            if (!string.IsNullOrEmpty(path))
            {
                query += "?path=" + Uri.EscapeDataString(path);
            }
            HttpResponseMessage? response = null;
            try
            {
                response = await ExecuteAsync(HttpMethod.Get, query, null, cancellationToken).ConfigureAwait(false);
                if (!response.IsSuccessStatusCode)
                {
                    var body = await ReadBodyAsync(response).ConfigureAwait(false);
                    var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
                    _logger.Warning(
                        "ListNetworkStorageFolder({Path}): {Status} (X-Status-Reason={Reason}) body={Body}",
                        path, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
                    return new List<NetworkStorageItem>();
                }
                var json = await ReadBodyAsync(response).ConfigureAwait(false);
                var items = JsonSerializer.Deserialize<List<NetworkStorageItem>>(json, JsonOptions);
                _logger.Information("ListNetworkStorageFolder({Path}) → {Count} entries", path, items?.Count ?? 0);
                return items ?? new List<NetworkStorageItem>();
            }
            catch (Exception ex)
            {
                _logger.Warning(ex, "ListNetworkStorageFolderAsync({Path}) failed", path);
                return new List<NetworkStorageItem>();
            }
            finally
            {
                response?.Dispose();
            }
        }

        public async Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrWhiteSpace(attachmentId)) throw new ArgumentException("attachmentId required", nameof(attachmentId));
            if (string.IsNullOrWhiteSpace(targetPath)) throw new ArgumentException("targetPath required", nameof(targetPath));

            var payload = new Dictionary<string, object?>
            {
                ["attachmentId"] = attachmentId,
                ["path"] = targetPath
            };
            var json = JsonSerializer.Serialize(payload, JsonOptions);
            _logger.Information(
                "UploadToNetworkStorage POST /NetworkStorage/action/upload (attachmentId={AttachmentId}, path={Path})",
                attachmentId, targetPath);

            var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/upload", json, cancellationToken).ConfigureAwait(false);
            try
            {
                if (!response.IsSuccessStatusCode)
                {
                    var body = await ReadBodyAsync(response).ConfigureAwait(false);
                    var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
                    _logger.Warning(
                        "UploadToNetworkStorage failed: {Status} (X-Status-Reason={Reason}) body={Body}",
                        response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
                    if (response.StatusCode == HttpStatusCode.Unauthorized)
                    {
                        throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key.");
                    }
                    throw new EspoCrmHttpException(response.StatusCode, body,
                        $"NetworkStorage upload returned {(int)response.StatusCode} {response.StatusCode}" +
                        (string.IsNullOrEmpty(reason) || reason == "(none)" ? "" : " (reason: " + reason + ")"));
                }
            }
            finally
            {
                response.Dispose();
            }
        }

        public async Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
        {
            try
            {
                var result = await GetJsonAsync<EspoListResponse<DocumentFolder>>(
                    "DocumentFolder?orderBy=name&order=asc&maxSize=200",
                    cancellationToken).ConfigureAwait(false);
                _logger.Information(
                    "ListDocumentFoldersAsync → {Count} folders in tenant",
                    result?.List?.Count ?? 0);
                return result ?? new EspoListResponse<DocumentFolder>();
            }
            catch (Exception ex)
            {
                _logger.Warning(ex, "ListDocumentFoldersAsync failed; returning empty list");
                return new EspoListResponse<DocumentFolder>();
            }
        }

        public async Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId));
            if (maxSize <= 0) maxSize = 5;

            var select = "id,name,number,status,createdAt,accountId";

            // Primary: walk the Contact-side relationship — the most reliable
            // shape across EspoCRM versions and custom link names.
            // GET /api/v1/Contact/{id}/cases?…
            var relatedPath =
                "Contact/" + Uri.EscapeDataString(contactId) + "/cases" +
                "?select=" + Uri.EscapeDataString(select) +
                "&orderBy=createdAt&order=desc&maxSize=" + maxSize;
            try
            {
                var relatedResult = await GetJsonAsync<EspoListResponse<CaseEntity>>(relatedPath, cancellationToken).ConfigureAwait(false);
                if (relatedResult != null && relatedResult.List.Count > 0)
                {
                    _logger.Information("ListCasesForContact({ContactId}) via /Contact/.../cases → {Count}",
                        contactId, relatedResult.List.Count);
                    return relatedResult;
                }
                _logger.Information("ListCasesForContact({ContactId}) via /Contact/.../cases → 0", contactId);
            }
            catch (Exception ex)
            {
                _logger.Warning(ex, "ListCasesForContact related-records call failed; falling back to /Case?where[]");
            }

            // Fallback: filter the Case list by linkedWith (older EspoCRM
            // installs, or custom builds where the relationship name differs).
            var wherePath =
                "Case?select=" + Uri.EscapeDataString(select) +
                "&where[0][type]=linkedWith" +
                "&where[0][attribute]=contacts" +
                "&where[0][value]=" + Uri.EscapeDataString(contactId) +
                "&orderBy=createdAt&order=desc&maxSize=" + maxSize;
            try
            {
                var fallback = await GetJsonAsync<EspoListResponse<CaseEntity>>(wherePath, cancellationToken).ConfigureAwait(false);
                _logger.Information("ListCasesForContact({ContactId}) fallback /Case?where[]= → {Count}",
                    contactId, fallback?.List.Count ?? 0);
                return fallback ?? new EspoListResponse<CaseEntity>();
            }
            catch (Exception ex)
            {
                _logger.Warning(ex, "ListCasesForContact /Case?where[]= fallback also failed");
                return new EspoListResponse<CaseEntity>();
            }
        }

        public async Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));

            var payload = new Dictionary<string, object?>
            {
                ["name"] = name
            };
            if (!string.IsNullOrWhiteSpace(emailAddress))
            {
                payload["emailAddress"] = emailAddress;
            }

            var json = JsonSerializer.Serialize(payload, JsonOptions);
            var response = await ExecuteAsync(HttpMethod.Post, "Contact", json, cancellationToken).ConfigureAwait(false);
            try
            {
                await EnsureSuccessAsync(response).ConfigureAwait(false);
                var body = await ReadBodyAsync(response).ConfigureAwait(false);
                return JsonSerializer.Deserialize<ContactEntity>(body, JsonOptions)
                    ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty ContactEntity from EspoCRM");
            }
            finally
            {
                response.Dispose();
            }
        }

        private Task<TResult> GetEntityAsync<TResult>(string entityType, string id, string? select, CancellationToken cancellationToken)
            where TResult : class
        {
            if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("id required", nameof(id));
            var path = entityType + "/" + Uri.EscapeDataString(id);
            if (!string.IsNullOrWhiteSpace(select))
            {
                path += "?select=" + Uri.EscapeDataString(select);
            }
            return GetJsonAsync<TResult>(path, cancellationToken);
        }

        private async Task<TResult> GetJsonAsync<TResult>(string relativePath, CancellationToken cancellationToken)
            where TResult : class
        {
            var response = await ExecuteAsync(HttpMethod.Get, relativePath, null, cancellationToken).ConfigureAwait(false);
            try
            {
                await EnsureSuccessAsync(response).ConfigureAwait(false);
                var body = await ReadBodyAsync(response).ConfigureAwait(false);
                return JsonSerializer.Deserialize<TResult>(body, JsonOptions)
                    ?? throw new EspoCrmHttpException(response.StatusCode, body, $"Empty {typeof(TResult).Name} from EspoCRM");
            }
            finally
            {
                response.Dispose();
            }
        }

        private async Task<string> GetRawJsonAsync(string relativePath, CancellationToken cancellationToken)
        {
            var response = await ExecuteAsync(HttpMethod.Get, relativePath, null, cancellationToken).ConfigureAwait(false);
            try
            {
                await EnsureSuccessAsync(response).ConfigureAwait(false);
                return await ReadBodyAsync(response).ConfigureAwait(false);
            }
            finally
            {
                response.Dispose();
            }
        }

        private async Task<HttpResponseMessage> ExecuteAsync(HttpMethod method, string relativePath, string? jsonBody, CancellationToken cancellationToken)
        {
            return await _pipeline.ExecuteAsync(async ct =>
            {
                var request = new HttpRequestMessage(method, ApiPrefix + relativePath);
                if (jsonBody != null)
                {
                    request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
                }
                return await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
            }, cancellationToken).ConfigureAwait(false);
        }

        private static async Task EnsureSuccessAsync(HttpResponseMessage response)
        {
            if (response.IsSuccessStatusCode) return;

            var body = await ReadBodyAsync(response).ConfigureAwait(false);
            // Only 401 is unambiguously an authentication problem. 403 in
            // EspoCRM is "you are who you say you are, but THIS operation
            // isn't allowed" — content type rejected, ACL miss, required
            // role missing, etc. Conflating them aborts whole batches.
            if (response.StatusCode == HttpStatusCode.Unauthorized)
            {
                throw new EspoCrmAuthorizationException(
                    $"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
            }

            var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
                ? " (reason: " + string.Join(",", v) + ")" : string.Empty;
            throw new EspoCrmHttpException(response.StatusCode, body,
                $"EspoCRM returned {(int)response.StatusCode} {response.StatusCode}{reason}: {Truncate(body, 500)}");
        }

        private static async Task<string> ReadBodyAsync(HttpResponseMessage response)
        {
            if (response.Content == null) return string.Empty;
            return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        }

        private static FileEmailResponse DeserializeOrEmpty(string body, FileEmailRequest request)
        {
            if (!string.IsNullOrWhiteSpace(body))
            {
                try
                {
                    var parsed = JsonSerializer.Deserialize<FileEmailResponse>(body, JsonOptions);
                    if (parsed != null) return parsed;
                }
                catch (JsonException) { }
            }
            return new FileEmailResponse
            {
                Created = false,
                ParentType = request.ParentType,
                ParentId = request.ParentId,
                MessageId = request.MessageId
            };
        }

        private static string Truncate(string s, int max)
            => string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s.Substring(0, max) + "...");
    }
}
