using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;

namespace JobTrackerApi.Services
{
    /// <summary>
    /// Read-only analytics/statistics aggregation extracted from JobApplicationsController.
    /// Uses the tenant-scoped <see cref="JobTrackerContext"/>, so the global OwnerUserId
    /// query filters apply automatically. Behaviour is identical to the former inline
    /// controller methods (GetStats / GetAnalyticsOverview).
    /// </summary>
    public sealed class AnalyticsService
    {
        private readonly JobTrackerContext _db;

        public AnalyticsService(JobTrackerContext db)
        {
            _db = db;
        }

        public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
        {
            var now = DateTime.Now;
            var last30 = now.AddDays(-30);

            // Aggregate server-side (COUNT/GROUP BY) instead of pulling every row into memory.
            var total = await _db.JobApplications.AsNoTracking().CountAsync(cancellationToken);
            var active = await _db.JobApplications.AsNoTracking().CountAsync(j => !j.IsDeleted, cancellationToken);
            var appliedLast30Days = await _db.JobApplications.AsNoTracking()
                .CountAsync(j => !j.IsDeleted && j.DateApplied >= last30, cancellationToken);

            var byStatus = await _db.JobApplications
                .AsNoTracking()
                .Where(j => !j.IsDeleted)
                .GroupBy(j => j.Status)
                .Select(g => new { Status = g.Key, Count = g.Count() })
                .ToListAsync(cancellationToken);

            var byStatusDict = byStatus
                .GroupBy(x => string.IsNullOrWhiteSpace(x.Status) ? "Unknown" : x.Status)
                .OrderByDescending(g => g.Sum(x => x.Count))
                .ToDictionary(g => g.Key, g => g.Sum(x => x.Count));

            // ponytail: average age needs a per-row day-diff that doesn't translate identically
            // across the SQLite/MySQL providers this app runs on, so pull just the DateApplied
            // column (no wide blob columns) for active rows and average client-side.
            // DateApplied is null for pre-application stages; those have no "days since applied"
            // and are filtered out server-side so they can't drag the average toward zero.
            var activeDates = active == 0
                ? new List<DateTime>()
                : await _db.JobApplications.AsNoTracking()
                    .Where(j => !j.IsDeleted && j.DateApplied != null)
                    .Select(j => j.DateApplied!.Value)
                    .ToListAsync(cancellationToken);

            var avgDays = activeDates.Count == 0
                ? 0
                : activeDates.Average(d => Math.Max(0, (now - d).TotalDays));

            return new JobStats(
                Total: total,
                Active: active,
                Deleted: total - active,
                ByStatus: byStatusDict,
                AppliedLast30Days: appliedLast30Days,
                AverageDaysSinceApplied: Math.Round(avgDays, 1)
            );
        }

        public async Task<AnalyticsOverviewDto> GetAnalyticsOverviewAsync(CancellationToken cancellationToken)
        {
            // Project to only the fields the overview needs instead of Include-ing full
            // Company + JobApplication rows (avoids loading large description/CV blobs).
            var activeJobs = await _db.JobApplications
                .AsNoTracking()
                .Where(j => !j.IsDeleted)
                .Select(j => new
                {
                    j.Id,
                    j.Status,
                    j.ResponseReceived,
                    j.ResponseDate,
                    j.DateApplied,
                    j.SavedAt,
                    j.CompanyId,
                    CompanyName = j.Company.Name,
                    CompanySource = j.Company.Source,
                    j.SalaryMin,
                    j.SalaryMax,
                    j.SalaryCurrency,
                    j.SalaryPeriod
                })
                .ToListAsync(cancellationToken);

            // Funnel = distribution across canonical stages, driven by the pipeline (one source
            // of truth, so it includes every stage and normalizes legacy spellings).
            var normalizedByStage = activeJobs
                .GroupBy(j => JobPipeline.Normalize(j.Status))
                .ToDictionary(g => g.Key, g => g.Count());
            var funnel = JobPipeline.Stages
                .Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0))
                .ToList();

            var responseRateBySource = activeJobs
                .GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
                .Select(g => new ResponseRatePoint(
                    g.Key,
                    g.Count(),
                    g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
                    Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
                ))
                .OrderByDescending(x => x.Total)
                .ThenByDescending(x => x.Rate)
                .Take(6)
                .ToList();

            var topCompanies = activeJobs
                .GroupBy(j => new { j.CompanyId, Name = j.CompanyName })
                .Select(g => new CompanyActivityPoint(
                    g.Key.CompanyId,
                    g.Key.Name,
                    g.Count(),
                    g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
                    Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
                ))
                .OrderByDescending(x => x.Count)
                .ThenByDescending(x => x.ResponseRate)
                .Take(8)
                .ToList();

            // "Days to respond" is only meaningful once applied, so rows with no DateApplied
            // (pre-application stages) are excluded rather than measured from nothing.
            var responseDays = activeJobs
                .Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null && j.DateApplied is not null)
                .Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied!.Value).TotalDays))
                .OrderBy(x => x)
                .ToList();

            double? medianDays = null;
            if (responseDays.Count > 0)
            {
                var mid = responseDays.Count / 2;
                medianDays = responseDays.Count % 2 == 0
                    ? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
                    : Math.Round(responseDays[mid], 1);
            }

            // Time-in-stage: for each active job, when did it enter its current stage? Use the most
            // recent StatusChanged event into that stage, else its applied date.
            var activeIds = activeJobs.Select(j => j.Id).ToList();
            var statusChanges = await _db.JobEvents
                .AsNoTracking()
                .Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId))
                .Select(e => new { e.JobApplicationId, e.NewValue, e.At })
                .ToListAsync(cancellationToken);

            var lastEntryByJob = statusChanges
                .GroupBy(e => e.JobApplicationId)
                .ToDictionary(g => g.Key, g => g.ToList());

            var occupancy = activeJobs.Select(job =>
            {
                var current = JobPipeline.Normalize(job.Status);
                // Fall back to SavedAt when the job has not been applied to: every job has a
                // saved date, so a stage entry time always exists even before DateApplied does.
                DateTime enteredAt = job.DateApplied ?? job.SavedAt;
                if (lastEntryByJob.TryGetValue(job.Id, out var changes))
                {
                    var lastIntoCurrent = changes
                        .Where(e => JobPipeline.Normalize(e.NewValue) == current)
                        .OrderByDescending(e => e.At)
                        .FirstOrDefault();
                    if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At;
                }
                return new StageOccupancy(current, enteredAt.ToUniversalTime());
            });

            var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow)
                .Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
                .ToList();

            var salaryInsights = activeJobs
                .Where(j => (j.SalaryMin is not null || j.SalaryMax is not null)
                    && !string.IsNullOrWhiteSpace(j.SalaryCurrency)
                    && !string.IsNullOrWhiteSpace(j.SalaryPeriod))
                .GroupBy(j => new { Currency = j.SalaryCurrency!.ToUpperInvariant(), Period = j.SalaryPeriod!.ToLowerInvariant() })
                .Select(g => new SalaryInsightDto(
                    g.Key.Currency,
                    g.Key.Period,
                    g.Count(),
                    g.Min(j => j.SalaryMin ?? j.SalaryMax!.Value),
                    g.Max(j => j.SalaryMax ?? j.SalaryMin!.Value),
                    Math.Round(g.Average(j => ((j.SalaryMin ?? j.SalaryMax!.Value) + (j.SalaryMax ?? j.SalaryMin!.Value)) / 2m), 0)))
                .OrderByDescending(x => x.Count)
                .ThenBy(x => x.Currency)
                .ThenBy(x => x.Period)
                .ToList();

            return new AnalyticsOverviewDto(
                Funnel: funnel,
                ResponseRateBySource: responseRateBySource,
                TopCompanies: topCompanies,
                MedianDaysToFirstResponse: medianDays,
                TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
                TotalActive: activeJobs.Count,
                TimeInStage: timeInStage,
                SalaryInsights: salaryInsights
            );
        }
    }
}
