using JobTrackerApi.Data;
using JobTrackerApi.Models;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using MimeKit;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;

namespace JobTrackerApi.Services;

public interface IImapService
{
    Task<ImapConnectResult> ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken);
    Task<ImapConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
    Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
    Task<IReadOnlyList<ImapMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
    Task<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken);
    Task<ImapMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}

public sealed record ImapConnectResult(string Username);
public sealed record ImapMessageSummary(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record ImapMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? ContentId, bool Inline);
public sealed record ImapMessageDetail(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<ImapMessageAttachment> Attachments);

/// <summary>
/// Generic IMAP mail access for "any provider not explicitly supported" (Gmail/Microsoft have
/// their own OAuth-based providers). Auth is direct host/username/password rather than OAuth —
/// there's no connect-url/callback dance, the caller submits credentials once and they're
/// encrypted at rest the same way Gmail/Microsoft's refresh tokens are.
///
/// ponytail: scoped to INBOX only, and "thread" is approximated from the References/In-Reply-To
/// headers (the root Message-Id) rather than the server-side IMAP THREAD extension, which not
/// every provider implements. Good enough for "show me the other messages in this conversation";
/// upgrade to THREAD/SORT if a target provider needs cross-folder or extension-grade threading.
/// External message ids are "{UID}" scoped to INBOX under the connection's current UIDVALIDITY —
/// they are not stable across a UIDVALIDITY change (rare: a full mailbox reset on the server).
/// </summary>
public sealed class ImapService : IImapService
{
    private const int ThreadScanWindow = 200;
    private readonly JobTrackerContext _db;
    private readonly IDataProtector _protector;

    public ImapService(JobTrackerContext db, IDataProtectionProvider protectionProvider)
    {
        _db = db;
        _protector = protectionProvider.CreateProtector("imap-credentials-v1");
    }

    public async Task<ImapConnectResult> ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken)
    {
        host = host.Trim();
        username = username.Trim();
        if (host.Length == 0) throw new InvalidOperationException("IMAP host is required.");
        if (username.Length == 0) throw new InvalidOperationException("IMAP username is required.");
        if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("IMAP password is required.");

        // Verify the credentials actually work before persisting them. Failure detail is
        // intentionally generic (not the raw MailKit exception) so a "connect" attempt can't be
        // used as a distinguishable oracle to fingerprint what's listening on a given host:port.
        using (var client = new ImapClient())
        {
            try
            {
                await EnsureHostIsExternalAsync(host, cancellationToken);
                await client.ConnectAsync(host, port, useSsl, cancellationToken);
                await client.AuthenticateAsync(username, password, cancellationToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                throw new InvalidOperationException("Could not connect to that IMAP server with the given credentials. Check host, port, and password.");
            }
            finally
            {
                if (client.IsConnected)
                {
                    await client.DisconnectAsync(true, cancellationToken);
                }
            }
        }

        var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
        if (existing is null)
        {
            existing = new ImapConnection { OwnerUserId = ownerUserId };
            _db.ImapConnections.Add(existing);
        }

        existing.Host = host;
        existing.Port = port;
        existing.UseSsl = useSsl;
        existing.Username = username;
        existing.EncryptedPassword = _protector.Protect(password);
        existing.ConnectedAt = DateTimeOffset.UtcNow;
        existing.LastSyncStatus = "connected";
        existing.LastSyncSource = "connect";
        existing.LastSyncMode = "connect";
        existing.LastSyncError = null;
        existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
        existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;

        await _db.SaveChangesAsync(cancellationToken);
        return new ImapConnectResult(existing.Username);
    }

    public Task<ImapConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
        => _db.ImapConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);

    public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
    {
        var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
        if (existing is null) return;
        _db.ImapConnections.Remove(existing);
        await _db.SaveChangesAsync(cancellationToken);
    }

    public async Task<IReadOnlyList<ImapMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
    {
        maxResults = Math.Clamp(maxResults, 1, 25);
        try
        {
            using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
            var searchQuery = string.IsNullOrWhiteSpace(query)
                ? SearchQuery.All
                : SearchQuery.SubjectContains(query.Trim()).Or(SearchQuery.FromContains(query.Trim())).Or(SearchQuery.BodyContains(query.Trim()));

            var uids = await client.Inbox.SearchAsync(searchQuery, cancellationToken);
            var window = uids.OrderByDescending(u => u.Id).Take(maxResults).ToList();
            var summaries = await FetchSummariesAsync(client, window, cancellationToken);

            await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
            return summaries;
        }
        catch (Exception ex)
        {
            await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
            throw;
        }
    }

    public async Task<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(threadKey))
        {
            return Array.Empty<ImapMessageSummary>();
        }

        try
        {
            using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
            var recentUids = (await client.Inbox.SearchAsync(SearchQuery.All, cancellationToken))
                .OrderByDescending(u => u.Id)
                .Take(ThreadScanWindow)
                .ToList();

            var items = await client.Inbox.FetchAsync(recentUids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
            var matches = items.Where(item => ComputeThreadKey(item) == threadKey.Trim()).ToList();
            var summaries = matches.Select(ToSummary).OrderBy(s => s.Date).ToList();

            await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", true, null, cancellationToken);
            return summaries;
        }
        catch (Exception ex)
        {
            await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", false, ex.Message, cancellationToken);
            throw;
        }
    }

    public async Task<ImapMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
    {
        try
        {
            using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
            var uid = ParseUid(messageId);

            var summaryItems = await client.Inbox.FetchAsync(new[] { uid }, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
            var summary = summaryItems.FirstOrDefault() ?? throw new InvalidOperationException($"IMAP message {messageId} was not found.");

            var mime = await client.Inbox.GetMessageAsync(uid, cancellationToken);
            var bodyText = mime.TextBody ?? (mime.HtmlBody is null ? "" : StripHtml(mime.HtmlBody));
            var attachments = mime.Attachments.Select(a => new ImapMessageAttachment(
                a.ContentDisposition?.FileName ?? a.ContentType?.Name,
                a.ContentType?.MimeType,
                a is MimePart part ? part.Content?.Stream?.Length : null,
                a.ContentId,
                a.IsAttachment == false
            )).ToList();

            await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", true, null, cancellationToken);
            return new ImapMessageDetail(
                messageId,
                ComputeThreadKey(summary),
                summary.Envelope?.Subject ?? "",
                FormatAddresses(summary.Envelope?.From),
                FormatAddresses(summary.Envelope?.To),
                summary.Envelope?.Date,
                bodyText.Length > 200 ? bodyText[..200] : bodyText,
                bodyText.Trim(),
                mime.HtmlBody,
                Array.Empty<string>(),
                attachments);
        }
        catch (Exception ex)
        {
            await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", false, ex.Message, cancellationToken);
            throw;
        }
    }

    private static async Task<IReadOnlyList<ImapMessageSummary>> FetchSummariesAsync(ImapClient client, IList<UniqueId> uids, CancellationToken cancellationToken)
    {
        if (uids.Count == 0) return Array.Empty<ImapMessageSummary>();
        var items = await client.Inbox.FetchAsync(uids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
        return items.Select(ToSummary).ToList();
    }

    private static ImapMessageSummary ToSummary(IMessageSummary item) => new(
        item.UniqueId.Id.ToString(),
        ComputeThreadKey(item),
        item.Envelope?.Subject ?? "",
        FormatAddresses(item.Envelope?.From),
        FormatAddresses(item.Envelope?.To),
        item.Envelope?.Date,
        "");

    // The root Message-Id of the References chain, or this message's own Message-Id if it
    // starts no chain — a stand-in "thread id" that works without the IMAP THREAD extension.
    private static string ComputeThreadKey(IMessageSummary item)
    {
        if (item.References is { Count: > 0 })
        {
            return item.References[0];
        }
        return item.Envelope?.MessageId ?? item.UniqueId.Id.ToString();
    }

    private static string FormatAddresses(InternetAddressList? list)
        => list is null ? "" : string.Join(", ", list.Mailboxes.Select(m => m.Address));

    private static string StripHtml(string html)
        => System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim();

    private static UniqueId ParseUid(string messageId)
        => uint.TryParse(messageId, out var id) ? new UniqueId(id) : throw new InvalidOperationException($"Invalid IMAP message id: {messageId}");

    private async Task<ImapClient> OpenInboxAsync(string ownerUserId, bool writable, CancellationToken cancellationToken)
    {
        var connection = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken)
            ?? throw new InvalidOperationException("IMAP is not connected for this account.");

        string password;
        try
        {
            password = _protector.Unprotect(connection.EncryptedPassword);
        }
        catch (CryptographicException)
        {
            throw new InvalidOperationException("Your stored IMAP connection can no longer be decrypted after a server key change. Disconnect and reconnect IMAP.");
        }

        await EnsureHostIsExternalAsync(connection.Host, cancellationToken);
        var client = new ImapClient();
        await client.ConnectAsync(connection.Host, connection.Port, connection.UseSsl, cancellationToken);
        await client.AuthenticateAsync(connection.Username, password, cancellationToken);
        await client.Inbox.OpenAsync(writable ? FolderAccess.ReadWrite : FolderAccess.ReadOnly, cancellationToken);
        return client;
    }

    // SSRF guard: a user-supplied IMAP host resolves to an IP the server then opens a socket to.
    // Without this check an authenticated user could point "their mailbox" at loopback, RFC1918/
    // link-local ranges, or the cloud metadata address to probe internal infrastructure. Re-run on
    // every connect (not just the initial one) so a DNS record that resolves externally at connect
    // time can't be rebound internally for a later reconnect.
    private static async Task EnsureHostIsExternalAsync(string host, CancellationToken cancellationToken)
    {
        IPAddress[] addresses;
        try
        {
            addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
        }
        catch (SocketException)
        {
            throw new InvalidOperationException("Could not resolve that IMAP host.");
        }

        if (addresses.Length == 0 || addresses.Any(IsInternalAddress))
        {
            throw new InvalidOperationException("That IMAP host is not reachable.");
        }
    }

    private static bool IsInternalAddress(IPAddress address)
    {
        if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();

        if (IPAddress.IsLoopback(address)) return true;
        if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)) return true;

        if (address.AddressFamily == AddressFamily.InterNetwork)
        {
            var bytes = address.GetAddressBytes();
            if (bytes[0] == 10) return true; // 10.0.0.0/8
            if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; // 172.16.0.0/12
            if (bytes[0] == 192 && bytes[1] == 168) return true; // 192.168.0.0/16
            if (bytes[0] == 169 && bytes[1] == 254) return true; // 169.254.0.0/16 (incl. cloud metadata)
            if (bytes[0] == 127) return true; // 127.0.0.0/8
            return false;
        }

        if (address.AddressFamily == AddressFamily.InterNetworkV6)
        {
            if (address.IsIPv6LinkLocal || address.IsIPv6SiteLocal) return true;
            var bytes = address.GetAddressBytes();
            if ((bytes[0] & 0xFE) == 0xFC) return true; // fc00::/7 (unique local)
            return false;
        }

        return true; // unknown address family: fail closed
    }

    private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
    {
        var connection = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
        if (connection is null) return;

        connection.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
        connection.LastSyncMode = mode;
        connection.LastSyncSource = source;
        connection.LastSyncStatus = succeeded ? "ok" : "error";
        connection.LastSyncError = succeeded ? null : error;
        if (succeeded)
        {
            connection.LastSyncedAt = DateTimeOffset.UtcNow;
            connection.LastSyncSucceededAt = connection.LastSyncedAt;
        }

        await _db.SaveChangesAsync(cancellationToken);
    }
}
