using System.Security.Claims;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Services.EmailProviders;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using static JobTrackerApi.Services.GmailParsing;

namespace JobTrackerApi.Controllers;

[ApiController]
[Route("api/gmail")]
[Authorize]
public sealed class GmailController : ControllerBase
{
    private readonly IGmailOAuthService _gmail;
    private readonly IGmailJobMatchingService _matching;
    private readonly JobTrackerContext _db;
    private readonly IEmailProviderRegistry _providers;
    private readonly ExternalOrigin _externalOrigin;

    public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null, ExternalOrigin? externalOrigin = null)
    {
        _gmail = gmail;
        _matching = matching;
        _db = db;
        _externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
        // Fall back to a single-Gmail registry so direct construction (tests) keeps working.
        _providers = providers ?? new EmailProviderRegistry(new IEmailProvider[] { new GmailProvider(gmail) });
    }

    // The email provider backing this controller's read paths (search + thread listing),
    // via the provider-neutral seam. OAuth and Gmail-specific candidate ranking still use
    // IGmailOAuthService directly until they are generalised.
    private IEmailProvider Email => _providers.Get("gmail")
        ?? throw new InvalidOperationException("Gmail email provider is not registered.");

    [HttpGet("status")]
    public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
    {
        var ownerUserId = GetRequiredOwnerUserId();
        var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
        return Ok(new GmailConnectionStatusDto(
            connection is not null,
            connection?.GmailAddress,
            connection?.ConnectedAt,
            connection?.LastSyncedAt,
            connection?.LastSyncAttemptedAt,
            connection?.LastSyncSucceededAt,
            connection?.LastSyncMode,
            connection?.LastSyncSource,
            connection?.LastSyncStatus,
            connection?.LastSyncError));
    }

    [HttpGet("connect-url")]
    public IActionResult ConnectUrl()
    {
        var ownerUserId = GetRequiredOwnerUserId();
        var url = _gmail.BuildAuthorizationUrl(ownerUserId, GetRedirectUri());
        return Ok(new { url });
    }

    [HttpGet("job-candidates")]
    public async Task<ActionResult<GmailJobMatchesResponseDto>> JobCandidates(
        [FromQuery] int jobApplicationId,
        [FromQuery] string? queryOverride,
        [FromQuery] int maxResultsPerQuery = 6,
        CancellationToken cancellationToken = default)
    {
        if (jobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");

        var ownerUserId = GetRequiredOwnerUserId();
        var job = await _db.JobApplications
            .Where(x => x.OwnerUserId == ownerUserId)
            .Include(x => x.Company)
            .FirstOrDefaultAsync(x => x.Id == jobApplicationId, cancellationToken);
        if (job is null) return NotFound("Job application not found.");

        var importedMessageIds = await _db.Correspondences
            .Where(message => message.JobApplicationId == job.Id && !string.IsNullOrWhiteSpace(message.ExternalMessageId))
            .Select(message => message.ExternalMessageId!)
            .ToListAsync(cancellationToken);
        var importedThreadIds = await _db.Correspondences
            .Where(message => message.JobApplicationId == job.Id && !string.IsNullOrWhiteSpace(message.ExternalThreadId))
            .Select(message => message.ExternalThreadId!)
            .ToListAsync(cancellationToken);
        var importedMessageIdSet = importedMessageIds.ToHashSet(StringComparer.Ordinal);
        var importedThreadIdSet = importedThreadIds.ToHashSet(StringComparer.Ordinal);

        var queries = BuildJobQueries(job, queryOverride);
        var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);

        var rankedMessages = candidateMessages
            .Select(message => _matching.ScoreMessage(job, message, importedMessageIdSet.Contains(message.Message.Id), importedThreadIdSet.Contains(message.Message.ThreadId)))
            .Where(result => result.Score > 0 || result.AlreadyImported)
            .OrderByDescending(result => result.Score)
            .ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue)
            .ToList();

        var threads = rankedMessages
            .GroupBy(result => string.IsNullOrWhiteSpace(result.Message.ThreadId) ? result.Message.Id : result.Message.ThreadId, StringComparer.Ordinal)
            .Select(group =>
            {
                var ordered = group
                    .OrderByDescending(item => item.Score)
                    .ThenByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue)
                    .ToList();
                var latestDate = ordered
                    .Select(item => item.Message.Date)
                    .OrderByDescending(item => item ?? DateTimeOffset.MinValue)
                    .FirstOrDefault();
                var combinedReasons = ordered
                    .SelectMany(item => item.Reasons)
                    .GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
                    .Select(reason => reason.First())
                    .OrderByDescending(reason => reason.Points)
                    .ThenBy(reason => reason.Label, StringComparer.Ordinal)
                    .ThenBy(reason => reason.Value, StringComparer.Ordinal)
                    .Take(8)
                    .Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points))
                    .ToList();
                var matchedQueries = ordered
                    .SelectMany(item => item.MatchedQueries)
                    .Distinct(StringComparer.OrdinalIgnoreCase)
                    .OrderBy(query => query, StringComparer.OrdinalIgnoreCase)
                    .ToList();
                var hasImportedMessages = ordered.Any(item => item.AlreadyImported);
                var importedMessageCount = ordered.Count(item => item.AlreadyImported);
                var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2);
                var representative = ordered[0].Message;

                return new GmailJobMatchedThreadDto(
                    group.Key,
                    string.IsNullOrWhiteSpace(representative.Subject) ? "(no subject)" : representative.Subject,
                    threadScore,
                    ToConfidence(threadScore),
                    hasImportedMessages,
                    importedMessageCount,
                    ordered.Count,
                    latestDate,
                    matchedQueries,
                    combinedReasons,
                    ordered.Select(item => new GmailJobMatchedMessageDto(
                        item.Message.Id,
                        item.Message.ThreadId,
                        item.Message.Subject,
                        item.Message.From,
                        item.Message.To,
                        item.Message.Date,
                        item.Message.Snippet,
                        item.Score,
                        ToConfidence(item.Score),
                        item.AlreadyImported,
                        item.MatchedQueries,
                        item.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList())).ToList());
            })
            .OrderByDescending(thread => thread.Score)
            .ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
            .ToList();

        return Ok(new GmailJobMatchesResponseDto(
            job.Id,
            job.JobTitle,
            job.Company?.Name ?? string.Empty,
            job.Company?.RecruiterName,
            job.Company?.RecruiterEmail,
            queries,
            rankedMessages.Count,
            threads.Count,
            threads));
    }

    [HttpGet("review-candidates")]
    public async Task<ActionResult<GmailReviewQueueResponseDto>> ReviewCandidates(
        [FromQuery] string? queryOverride,
        [FromQuery] int maxResultsPerQuery = 6,
        CancellationToken cancellationToken = default)
    {
        var ownerUserId = GetRequiredOwnerUserId();
        if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null)
        {
            return GmailNotConnectedResult();
        }
        var jobs = await _db.JobApplications
            .AsNoTracking()
            .Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
            .Include(x => x.Company)
            .OrderByDescending(x => x.DateApplied)
            .Take(100)
            .ToListAsync(cancellationToken);
        if (jobs.Count == 0)
        {
            return Ok(new GmailReviewQueueResponseDto(Array.Empty<string>(), 0, 0, 0, 0, Array.Empty<GmailReviewThreadDto>()));
        }

        var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var job in jobs)
        {
            foreach (var query in _matching.BuildJobQueries(job, queryOverride))
            {
                querySet.Add(query);
            }
        }
        var queries = querySet.Take(18).ToList();
        var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);

        var allImportedMessageIds = await _db.Correspondences
            .AsNoTracking()
            .Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalMessageId))
            .Select(message => message.ExternalMessageId!)
            .ToListAsync(cancellationToken);
        var allImportedThreadIds = await _db.Correspondences
            .AsNoTracking()
            .Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalThreadId))
            .Select(message => message.ExternalThreadId!)
            .ToListAsync(cancellationToken);
        var allImportedMessageIdSet = allImportedMessageIds.ToHashSet(StringComparer.Ordinal);
        var allImportedThreadIdSet = allImportedThreadIds.ToHashSet(StringComparer.Ordinal);
        var reviewDecisions = await _db.GmailReviewDecisions
            .AsNoTracking()
            .Where(decision => decision.OwnerUserId == ownerUserId)
            .ToDictionaryAsync(decision => decision.ThreadId, StringComparer.Ordinal, cancellationToken);

        var groupedThreads = candidateMessages
            .GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
            .Select(group =>
            {
                var existingDecision = reviewDecisions.GetValueOrDefault(group.Key);
                var orderedMessages = group.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
                var latestDate = orderedMessages.Max(item => item.Message.Date ?? DateTimeOffset.MinValue);
                var subject = orderedMessages.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Message.Subject))?.Message.Subject ?? "(no subject)";
                var matchedQueries = orderedMessages.SelectMany(item => item.MatchedQueries).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
                var hasImportedMessages = orderedMessages.Any(item => allImportedMessageIdSet.Contains(item.Message.Id) || allImportedThreadIdSet.Contains(item.Message.ThreadId));

                var jobCandidates = jobs
                    .Select(job =>
                    {
                        var best = orderedMessages
                            .Select(item => _matching.ScoreMessage(job, item, allImportedMessageIdSet.Contains(item.Message.Id), allImportedThreadIdSet.Contains(item.Message.ThreadId)))
                            .OrderByDescending(score => score.Score)
                            .First();
                        return new GmailReviewJobCandidateDto(
                            job.Id,
                            job.JobTitle,
                            job.Company?.Name ?? string.Empty,
                            best.Score,
                            best.Confidence,
                            best.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList());
                    })
                    .Where(candidate => candidate.Score > 0)
                    .OrderByDescending(candidate => candidate.Score)
                    .Take(3)
                    .ToList();

                var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0;
                var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0;
                var routing = existingDecision?.Decision switch
                {
                    "linked" => "linked",
                    "rejected" => "rejected",
                    "suggested" => "suggested",
                    _ => topScore >= 30 && topScore - secondScore >= 8
                        ? "auto-link"
                        : topScore >= 16
                            ? "review"
                            : "unmatched"
                };

                var messages = orderedMessages
                    .Select(item => new GmailJobMatchedMessageDto(
                        item.Message.Id,
                        item.Message.ThreadId,
                        item.Message.Subject,
                        item.Message.From,
                        item.Message.To,
                        item.Message.Date,
                        item.Message.Snippet,
                        item.MatchedQueries.Count * 4,
                        item.MatchedQueries.Count >= 2 ? "medium" : "low",
                        allImportedMessageIdSet.Contains(item.Message.Id),
                        item.MatchedQueries,
                        Array.Empty<GmailJobMatchReasonDto>()))
                    .ToList();

                return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, existingDecision?.Note, matchedQueries, jobCandidates, messages);
            })
            .OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
            .Take(100)
            .ToList();

        return Ok(new GmailReviewQueueResponseDto(
            queries,
            groupedThreads.Count,
            groupedThreads.Count(thread => thread.Routing == "auto-link"),
            groupedThreads.Count(thread => thread.Routing == "review"),
            groupedThreads.Count(thread => thread.Routing == "unmatched"),
            groupedThreads));
    }

    [HttpPost("review-decision")]
    public async Task<IActionResult> SaveReviewDecision([FromBody] SaveGmailReviewDecisionRequest request, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
        var decision = (request.Decision ?? string.Empty).Trim().ToLowerInvariant();
        if (decision is not ("linked" or "rejected" or "review" or "suggested"))
        {
            return BadRequest("Decision must be linked, rejected, review, or suggested.");
        }

        var ownerUserId = GetRequiredOwnerUserId();
        JobApplication? job = null;
        if (decision == "linked")
        {
            if (request.JobApplicationId is null or <= 0) return BadRequest("jobApplicationId is required when linking a thread.");
            job = await _db.JobApplications
                .Where(x => x.OwnerUserId == ownerUserId)
                .Include(x => x.Company)
                .Include(x => x.Messages)
                .FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken);
            if (job is null) return NotFound("Job application not found.");

            var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
            var distinctMessageIds = threadMessages
                .Where(message => !string.IsNullOrWhiteSpace(message.Id))
                .Select(message => message.Id)
                .Distinct(StringComparer.Ordinal)
                .ToList();
            var existingMessageIds = await _db.Correspondences
                .Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
                .Select(message => message.ExternalMessageId!)
                .ToListAsync(cancellationToken);

            foreach (var messageId in distinctMessageIds)
            {
                if (existingMessageIds.Contains(messageId, StringComparer.Ordinal)) continue;
                await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
            }
        }

        var existing = await _db.GmailReviewDecisions.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
        if (existing is null)
        {
            existing = new GmailReviewDecision
            {
                OwnerUserId = ownerUserId,
                ThreadId = request.ThreadId.Trim(),
            };
            _db.GmailReviewDecisions.Add(existing);
        }

        existing.Decision = decision switch
        {
            "review" => "review",
            _ => decision,
        };
        existing.JobApplicationId = decision == "linked" ? request.JobApplicationId : null;
        existing.Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
        existing.UpdatedAt = DateTimeOffset.UtcNow;

        await _db.SaveChangesAsync(cancellationToken);
        return Ok(new
        {
            existing.ThreadId,
            existing.Decision,
            existing.JobApplicationId,
            existing.Note,
            existing.UpdatedAt,
        });
    }

    [HttpPost("manual-sync")]
    public async Task<ActionResult<GmailManualSyncResultDto>> ManualSync([FromBody] GmailManualSyncRequest? request, CancellationToken cancellationToken)
    {
        var ownerUserId = GetRequiredOwnerUserId();
        if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null)
        {
            return GmailNotConnectedResult();
        }
        var jobs = await _db.JobApplications
            .AsNoTracking()
            .Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
            .Include(x => x.Company)
            .OrderByDescending(x => x.DateApplied)
            .Take(100)
            .ToListAsync(cancellationToken);
        if (jobs.Count == 0)
        {
            return Ok(new GmailManualSyncResultDto(0, 0, 0, 0, 0, 0, 0, 0, 365, false, DateTimeOffset.UtcNow));
        }

        var lookbackDays = Math.Clamp(request?.LookbackDays ?? 365, 30, 365);
        var maxResultsPerQuery = Math.Clamp(request?.MaxResultsPerQuery ?? 8, 1, 25);
        var includeSpamTrash = request?.IncludeSpamTrash ?? false;
        var autoImportHighConfidence = request?.AutoImportHighConfidence ?? true;
        var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var jobItem in jobs)
        {
            foreach (var query in _matching.BuildJobQueries(jobItem, null))
            {
                var bounded = ApplySyncBoundary(query, lookbackDays, includeSpamTrash);
                querySet.Add(bounded);
            }
        }

        var queries = querySet.Take(24).ToList();
        var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
        var allImportedMessageIds = await _db.Correspondences
            .Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalMessageId))
            .Select(message => message.ExternalMessageId!)
            .ToListAsync(cancellationToken);
        var allImportedThreadIds = await _db.Correspondences
            .Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalThreadId))
            .Select(message => message.ExternalThreadId!)
            .ToListAsync(cancellationToken);
        var allImportedMessageIdSet = allImportedMessageIds.ToHashSet(StringComparer.Ordinal);
        var allImportedThreadIdSet = allImportedThreadIds.ToHashSet(StringComparer.Ordinal);
        var reviewDecisions = await _db.GmailReviewDecisions
            .Where(decision => decision.OwnerUserId == ownerUserId)
            .ToDictionaryAsync(decision => decision.ThreadId, StringComparer.Ordinal, cancellationToken);

        var groupedThreads = candidateMessages
            .GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
            .ToList();

        var autoLinked = 0;
        var reviewCount = 0;
        var unmatchedCount = 0;
        var importedMessages = 0;
        var importedThreads = 0;
        var skippedMessages = 0;

        foreach (var threadGroup in groupedThreads)
        {
            var threadId = threadGroup.Key;
            var existingDecision = reviewDecisions.GetValueOrDefault(threadId);
            if (string.Equals(existingDecision?.Decision, "rejected", StringComparison.OrdinalIgnoreCase))
            {
                unmatchedCount++;
                continue;
            }

            var orderedMessages = threadGroup.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
            var candidates = jobs
                .Select(jobItem =>
                {
                    var best = orderedMessages
                        .Select(item => _matching.ScoreMessage(jobItem, item, allImportedMessageIdSet.Contains(item.Message.Id), allImportedThreadIdSet.Contains(item.Message.ThreadId)))
                        .OrderByDescending(score => score.Score)
                        .First();
                    return new { Job = jobItem, Best = best };
                })
                .Where(x => x.Best.Score > 0)
                .OrderByDescending(x => x.Best.Score)
                .Take(3)
                .ToList();

            var top = candidates.FirstOrDefault();
            var secondScore = candidates.Skip(1).FirstOrDefault()?.Best.Score ?? 0;
            if (top is not null && autoImportHighConfidence && top.Best.Score >= 30 && top.Best.Score - secondScore >= 8)
            {
                var distinctMessageIds = orderedMessages.Select(item => item.Message.Id).Distinct(StringComparer.Ordinal).ToList();
                var existingIds = await _db.Correspondences
                    .Where(message => message.JobApplicationId == top.Job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
                    .Select(message => message.ExternalMessageId!)
                    .ToListAsync(cancellationToken);
                foreach (var messageId in distinctMessageIds)
                {
                    if (existingIds.Contains(messageId, StringComparer.Ordinal))
                    {
                        skippedMessages++;
                        continue;
                    }

                    await ImportSingleMessageAsync(ownerUserId, top.Job, messageId, cancellationToken);
                    allImportedMessageIdSet.Add(messageId);
                    importedMessages++;
                }

                importedThreads++;
                autoLinked++;
                UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", top.Job.Id, existingDecision?.Note);
                continue;
            }

            if (top is not null && top.Best.Score >= 16)
            {
                reviewCount++;
                UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, existingDecision?.Decision == "suggested" ? "suggested" : "review", null, existingDecision?.Note);
                continue;
            }

            unmatchedCount++;
            if (LooksLikeJobRelatedThread(orderedMessages))
            {
                UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "suggested", null, existingDecision?.Note);
            }
        }

        await _db.SaveChangesAsync(cancellationToken);
        return Ok(new GmailManualSyncResultDto(queries.Count, groupedThreads.Count, autoLinked, reviewCount, unmatchedCount, importedMessages, importedThreads, skippedMessages, lookbackDays, includeSpamTrash, DateTimeOffset.UtcNow));
    }

    [HttpGet("suggested-jobs")]
    public async Task<ActionResult<GmailSuggestedJobsResponseDto>> SuggestedJobs(CancellationToken cancellationToken)
    {
        var ownerUserId = GetRequiredOwnerUserId();
        if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null)
        {
            return GmailNotConnectedResult();
        }
        var reviewThreads = await ReviewCandidates(null, 6, cancellationToken);
        if (reviewThreads.Result is not OkObjectResult ok || ok.Value is not GmailReviewQueueResponseDto payload)
        {
            return BadRequest("Unable to compute Gmail suggested jobs.");
        }

        var items = payload.Threads
            .Where(thread => thread.Routing is "unmatched" or "suggested")
            .Select(thread => new GmailSuggestedJobCandidateDto(
                thread.ThreadId,
                thread.Subject,
                thread.LatestDate,
                ExtractCompanyName(thread.Messages.FirstOrDefault()?.From, thread.Subject),
                ExtractRecruiterName(thread.Messages.FirstOrDefault()?.From),
                ExtractFirstEmail(thread.Messages.FirstOrDefault()?.From),
                ExtractRoleFromSubject(thread.Subject),
                thread.Routing,
                thread.MatchedQueries,
                thread.Messages.FirstOrDefault()?.Snippet ?? string.Empty))
            .Where(item => !string.IsNullOrWhiteSpace(item.CompanyName) || !string.IsNullOrWhiteSpace(item.SuggestedJobTitle))
            .Take(50)
            .ToList();

        return Ok(new GmailSuggestedJobsResponseDto(items.Count, items));
    }

    [HttpPost("create-suggested-job")]
    public async Task<ActionResult<CreatedSuggestedGmailJobDto>> CreateSuggestedJob([FromBody] CreateSuggestedGmailJobRequest request, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
        if (string.IsNullOrWhiteSpace(request.CompanyName)) return BadRequest("CompanyName is required.");
        if (string.IsNullOrWhiteSpace(request.JobTitle)) return BadRequest("JobTitle is required.");

        var ownerUserId = GetRequiredOwnerUserId();
        var companyName = request.CompanyName.Trim();
        var jobTitle = request.JobTitle.Trim();
        var company = await _db.Companies.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.Name.ToLower() == companyName.ToLower(), cancellationToken);
        if (company is null)
        {
            company = new Company
            {
                OwnerUserId = ownerUserId,
                Name = companyName,
                RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim(),
                RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim(),
            };
            _db.Companies.Add(company);
            await _db.SaveChangesAsync(cancellationToken);
        }
        else
        {
            if (string.IsNullOrWhiteSpace(company.RecruiterName) && !string.IsNullOrWhiteSpace(request.RecruiterName)) company.RecruiterName = request.RecruiterName.Trim();
            if (string.IsNullOrWhiteSpace(company.RecruiterEmail) && !string.IsNullOrWhiteSpace(request.RecruiterEmail)) company.RecruiterEmail = request.RecruiterEmail.Trim();
        }

        var savedAt = DateTime.UtcNow;
        var job = new JobApplication
        {
            OwnerUserId = ownerUserId,
            CompanyId = company.Id,
            JobTitle = jobTitle,
            Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
            Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
            DateApplied = savedAt,
            SavedAt = savedAt,
        };
        job.Job = JobOpportunitySync.Create(job, "gmail");
        _db.JobApplications.Add(job);
        await _db.SaveChangesAsync(cancellationToken);

        var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
        var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
        // Batch the "already imported?" check with a single query instead of one
        // AnyAsync per message (N+1), mirroring RelinkThread below.
        var existingMessageIds = await _db.Correspondences
            .Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
            .Select(message => message.ExternalMessageId!)
            .ToListAsync(cancellationToken);
        var imported = 0;
        var skipped = 0;
        foreach (var messageId in distinctMessageIds)
        {
            if (existingMessageIds.Contains(messageId, StringComparer.Ordinal))
            {
                skipped++;
                continue;
            }

            await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
            imported++;
        }

        var suggestedJobReviewDecision = await _db.GmailReviewDecisions
            .FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
        UpsertReviewDecision(suggestedJobReviewDecision, ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
        await _db.SaveChangesAsync(cancellationToken);
        return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
    }

    [HttpPost("relink-thread")]
    public async Task<ActionResult<GmailRelinkResultDto>> RelinkThread([FromBody] RelinkGmailThreadRequest request, CancellationToken cancellationToken)
    {
        if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
        if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");

        var ownerUserId = GetRequiredOwnerUserId();
        var job = await _db.JobApplications
            .Where(x => x.OwnerUserId == ownerUserId)
            .Include(x => x.Company)
            .Include(x => x.Messages)
            .FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
        if (job is null) return NotFound("Job application not found.");

        var threadId = request.ThreadId.Trim();
        var unlinkedMessages = 0;
        if (request.RemoveFromOtherJobs)
        {
            var otherMessages = await _db.Correspondences
                .Include(message => message.JobApplication)
                .Where(message => message.ExternalThreadId == threadId && message.JobApplicationId != job.Id && message.JobApplication.OwnerUserId == ownerUserId)
                .ToListAsync(cancellationToken);
            if (otherMessages.Count > 0)
            {
                _db.Correspondences.RemoveRange(otherMessages);
                unlinkedMessages = otherMessages.Count;
            }
        }

        var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
        var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
        var existingMessageIds = await _db.Correspondences
            .Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
            .Select(message => message.ExternalMessageId!)
            .ToListAsync(cancellationToken);
        var imported = 0;
        var skipped = 0;
        foreach (var messageId in distinctMessageIds)
        {
            if (existingMessageIds.Contains(messageId, StringComparer.Ordinal))
            {
                skipped++;
                continue;
            }

            await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
            imported++;
        }

        var reviewDecision = await _db.GmailReviewDecisions
            .FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
        UpsertReviewDecision(reviewDecision, ownerUserId, threadId, "linked", job.Id, request.Note);
        await _db.SaveChangesAsync(cancellationToken);
        return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
    }

    [HttpPost("unlink-thread")]
    public async Task<ActionResult<GmailUnlinkResultDto>> UnlinkThread([FromBody] UnlinkGmailThreadRequest request, CancellationToken cancellationToken)
    {
        if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
        if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");

        var ownerUserId = GetRequiredOwnerUserId();
        var job = await _db.JobApplications
            .Where(x => x.OwnerUserId == ownerUserId)
            .FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
        if (job is null) return NotFound("Job application not found.");

        var threadId = request.ThreadId.Trim();
        var messages = await _db.Correspondences
            .Where(message => message.JobApplicationId == job.Id && message.ExternalThreadId == threadId)
            .ToListAsync(cancellationToken);
        if (messages.Count > 0)
        {
            _db.Correspondences.RemoveRange(messages);
        }

        var reviewDecision = await _db.GmailReviewDecisions
            .FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
        var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
        if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
        UpsertReviewDecision(reviewDecision, ownerUserId, threadId, nextDecision, null, request.Note);
        await _db.SaveChangesAsync(cancellationToken);
        return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
    }

    [AllowAnonymous]
    [HttpGet("oauth/callback")]
    public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
    {
        if (!string.IsNullOrWhiteSpace(error))
        {
            return Content(BuildPopupHtml(false, $"Google returned an error: {error}"), "text/html");
        }

        if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
        {
            return Content(BuildPopupHtml(false, "Missing Google OAuth code or state."), "text/html");
        }

        var ownerUserId = _gmail.ConsumeState(state);
        if (string.IsNullOrWhiteSpace(ownerUserId))
        {
            return Content(BuildPopupHtml(false, "This Gmail connection request is no longer valid. Start the connection again."), "text/html");
        }

        try
        {
            var result = await _gmail.ExchangeCodeAsync(ownerUserId, code, GetRedirectUri(), cancellationToken);
            return Content(BuildPopupHtml(true, $"Connected Gmail: {result.GmailAddress}"), "text/html");
        }
        catch (Exception ex)
        {
            return Content(BuildPopupHtml(false, ex.Message), "text/html");
        }
    }

    [HttpDelete("connection")]
    public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
    {
        var ownerUserId = GetRequiredOwnerUserId();
        await _gmail.DisconnectAsync(ownerUserId, cancellationToken);
        return NoContent();
    }

    [HttpGet("messages")]
    public async Task<IActionResult> Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default)
    {
        var ownerUserId = GetRequiredOwnerUserId();
        var items = await Email.SearchAsync(ownerUserId, query, maxResults, cancellationToken);
        return Ok(items);
    }

    [HttpPost("import")]
    public async Task<ActionResult<GmailImportMessageResultDto>> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
    {
        try
        {
            if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
            if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required.");

            var ownerUserId = GetRequiredOwnerUserId();
            var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
            if (job is null) return NotFound("Job application not found.");

            var existing = await _db.Correspondences.FirstOrDefaultAsync(
                x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId == request.MessageId,
                cancellationToken);
            if (existing is not null)
            {
                return Ok(new GmailImportMessageResultDto(0, 1, request.MessageId, existing.ExternalThreadId, existing));
            }

            var created = await ImportSingleMessageAsync(ownerUserId, job, request.MessageId, cancellationToken);
            await _db.SaveChangesAsync(cancellationToken);
            return Ok(new GmailImportMessageResultDto(1, 0, request.MessageId, created.ExternalThreadId, created));
        }
        catch (Exception ex)
        {
            return BadRequest(ex.Message);
        }
    }

    [HttpPost("import-thread")]
    public async Task<ActionResult<GmailImportResultDto>> ImportThread([FromBody] ImportGmailThreadRequest request, CancellationToken cancellationToken)
    {
        if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
        if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
        if (request.MessageIds is null || request.MessageIds.Length == 0) return BadRequest("At least one messageId is required.");

        var ownerUserId = GetRequiredOwnerUserId();
        var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
        if (job is null) return NotFound("Job application not found.");

        var distinctIds = request.MessageIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.Ordinal).ToList();
        var existingIds = await _db.Correspondences
            .Where(x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId != null && distinctIds.Contains(x.ExternalMessageId))
            .Select(x => x.ExternalMessageId!)
            .ToListAsync(cancellationToken);

        var skipped = existingIds.Count;
        var imported = 0;
        foreach (var messageId in distinctIds)
        {
            if (existingIds.Contains(messageId, StringComparer.Ordinal)) continue;
            await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
            imported++;
        }

        await _db.SaveChangesAsync(cancellationToken);
        return Ok(new GmailImportResultDto(imported, skipped, request.ThreadId));
    }

    [HttpPost("refresh-linked-threads")]
    public async Task<ActionResult<GmailThreadRefreshResultDto>> RefreshLinkedThreads([FromBody] RefreshLinkedThreadsRequest request, CancellationToken cancellationToken)
    {
        try
        {
            if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");

            var ownerUserId = GetRequiredOwnerUserId();
            var job = await _db.JobApplications
                .Where(x => x.OwnerUserId == ownerUserId)
                .Include(x => x.Company)
                .Include(x => x.Messages)
                .FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
            if (job is null) return NotFound("Job application not found.");

            var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
            if (connection is null) return Conflict("Connect Gmail before refreshing linked threads.");

            var linkedThreadIds = job.Messages
                .Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
                .Select(message => message.ExternalThreadId!.Trim())
                .Distinct(StringComparer.Ordinal)
                .ToList();

            if (linkedThreadIds.Count == 0)
            {
                return Ok(new GmailThreadRefreshResultDto(job.Id, 0, 0, 0, false, DateTimeOffset.UtcNow, Array.Empty<GmailThreadRefreshThreadDto>()));
            }

            var existingMessageIds = job.Messages
                .Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
                .Select(message => message.ExternalMessageId!)
                .ToHashSet(StringComparer.Ordinal);

            var refreshedThreads = new List<GmailThreadRefreshThreadDto>(linkedThreadIds.Count);
            var imported = 0;
            var skipped = 0;

            foreach (var threadId in linkedThreadIds)
            {
                var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
                var distinctThreadMessages = threadMessages
                    .Where(message => !string.IsNullOrWhiteSpace(message.Id))
                    .GroupBy(message => message.Id, StringComparer.Ordinal)
                    .Select(group => group.First())
                    .OrderBy(message => message.Date ?? DateTimeOffset.MinValue)
                    .ToList();

                var threadImported = 0;
                var threadSkipped = 0;
                foreach (var message in distinctThreadMessages)
                {
                    if (existingMessageIds.Contains(message.Id))
                    {
                        threadSkipped++;
                        skipped++;
                        continue;
                    }

                    var created = await ImportSingleMessageAsync(ownerUserId, job, message.Id, cancellationToken);
                    existingMessageIds.Add(created.ExternalMessageId ?? message.Id);
                    threadImported++;
                    imported++;
                }

                var latestMessageDate = distinctThreadMessages
                    .Select(message => message.Date)
                    .OrderByDescending(message => message ?? DateTimeOffset.MinValue)
                    .FirstOrDefault();
                var status = threadImported > 0 ? "imported-new-messages" : "already-current";
                refreshedThreads.Add(new GmailThreadRefreshThreadDto(threadId, threadImported, threadSkipped, distinctThreadMessages.Count, status, latestMessageDate));
            }

            await _db.SaveChangesAsync(cancellationToken);
            return Ok(new GmailThreadRefreshResultDto(job.Id, refreshedThreads.Count, imported, skipped, true, DateTimeOffset.UtcNow, refreshedThreads));
        }
        catch (Exception ex)
        {
            return BadRequest(ex.Message);
        }
    }

    private async Task<Correspondence> ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken)
    {
        var detail = await Email.GetMessageAsync(ownerUserId, messageId, cancellationToken);
        var me = await Email.GetConnectionAsync(ownerUserId, cancellationToken);
        var gmailAddress = me?.Address ?? string.Empty;
        var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
        var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;

        var message = new Correspondence
        {
            JobApplicationId = job.Id,
            From = isMe ? "Me" : "Company",
            Direction = isMe ? "outbound" : "inbound",
            Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
            Channel = "Email",
            ExternalMessageId = detail.Id,
            ExternalThreadId = string.IsNullOrWhiteSpace(detail.ThreadId) ? null : detail.ThreadId.Trim(),
            ExternalFrom = string.IsNullOrWhiteSpace(detail.From) ? null : detail.From.Trim(),
            ExternalTo = string.IsNullOrWhiteSpace(detail.To) ? null : detail.To.Trim(),
            ExternalLabelsJson = detail.Labels.Count == 0 ? null : JsonSerializer.Serialize(detail.Labels),
            AttachmentMetadataJson = detail.Attachments.Count == 0 ? null : JsonSerializer.Serialize(detail.Attachments.Select(attachment => new CorrespondenceAttachmentMetadata
            {
                FileName = attachment.FileName,
                MimeType = attachment.MimeType,
                SizeBytes = attachment.SizeBytes,
                GmailAttachmentId = attachment.ExternalAttachmentId,
                Inline = attachment.Inline,
            })),
            Provider = "gmail",
            Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
            Date = messageDate,
        };

        _db.Correspondences.Add(message);
        if (job.Company is not null)
        {
            job.Company.LastContactedAt = messageDate;
        }

        if (!isMe && (!job.ResponseReceived || job.ResponseDate is null || messageDate < job.ResponseDate.Value))
        {
            var oldResponse = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}";
            job.ResponseReceived = true;
            job.ResponseDate = messageDate;
            _db.JobEvents.Add(new JobEvent
            {
                JobApplicationId = job.Id,
                Type = "ReplyReceived",
                OldValue = oldResponse,
                NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}",
                Note = detail.Subject,
                At = messageDate
            });
        }

        return message;
    }

    private IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
    {
        return _matching.BuildJobQueries(job, queryOverride);
    }

    private void UpsertReviewDecision(IDictionary<string, GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
    {
        if (!decisions.TryGetValue(threadId, out var existing))
        {
            existing = new GmailReviewDecision
            {
                OwnerUserId = ownerUserId,
                ThreadId = threadId,
            };
            decisions[threadId] = existing;
            _db.GmailReviewDecisions.Add(existing);
        }

        existing.Decision = decision;
        existing.JobApplicationId = jobApplicationId;
        if (!string.IsNullOrWhiteSpace(note)) existing.Note = note.Trim();
        existing.UpdatedAt = DateTimeOffset.UtcNow;
    }

    // Single-thread upsert: callers acting on exactly one ThreadId should load just that row
    // (see the FirstOrDefaultAsync call sites below) rather than every review decision for the
    // owner just to scan for one match.
    private void UpsertReviewDecision(GmailReviewDecision? existing, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
    {
        if (existing is null)
        {
            existing = new GmailReviewDecision
            {
                OwnerUserId = ownerUserId,
                ThreadId = threadId,
            };
            _db.GmailReviewDecisions.Add(existing);
        }

        existing.Decision = decision;
        existing.JobApplicationId = jobApplicationId;
        if (!string.IsNullOrWhiteSpace(note)) existing.Note = note.Trim();
        existing.UpdatedAt = DateTimeOffset.UtcNow;
    }

    private string GetRequiredOwnerUserId()
    {
        return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
            ?? throw new InvalidOperationException("Authenticated user id is missing.");
    }

    private async Task<GmailConnection?> GetOwnerGmailConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
    {
        return await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
    }

    private ActionResult GmailNotConnectedResult()
    {
        return Conflict(new
        {
            code = "gmail_not_connected",
            message = "Connect Gmail before using the Gmail review queue.",
        });
    }

    private string GetRedirectUri()
    {
        return _externalOrigin.BuildPath("/api/gmail/oauth/callback");
    }

}
