using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;

namespace JobTrackerApi.Services;

// Career Workspace foundation (see docs/career-workspace-implementation-roadmap.md, Phase F1).
// Bounded to the profile/CV domain -- job tracking is untouched. Every existing read path still
// goes through ApplicationUser.ProfileCvStructureJson (dual-write window); this service is the
// single place stable item IDs and normalized dates get assigned, and where profile history is
// captured so it's never silently overwritten on the next rebuild/improve/upload.
public interface ICareerProfileService
{
    // Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
    // it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, syncs
    // the relational children (Experience/Education/Skill/Project/Certification/Language), and
    // returns the same profile so the caller can go on to serialize it into the legacy column.
    Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);

    // Reads the master profile from the relational children (the source of truth). If the relational
    // rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), it is
    // backfilled from the blob first. Returns an empty profile if the user has none.
    Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);

    // The append-only version history, newest first.
    Task<IReadOnlyList<CareerProfileVersionInfo>> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken);

    // Restores a past version by re-saving its snapshot as a NEW version (non-destructive: history
    // and the current state are both preserved; the restore is itself reversible). Returns the
    // restored profile, or null if the version does not exist.
    Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken);

    // Read-only structured load for a specific owner, bypassing the tenant query filter. For public
    // CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never
    // backfills — falls back to the ProfileJson blob if relational rows are absent.
    Task<StructuredCvProfile> LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken);
}

public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);

public sealed class CareerProfileService : ICareerProfileService
{
    private readonly JobTrackerContext _db;

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

    public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
    {
        AssignStableIds(profile);
        NormalizeDates(profile);

        // Publish the profile row, relational children, and history revision atomically. Without
        // this boundary a concurrent outline/read can observe the new profile before its children,
        // enter the legacy backfill path, and insert a second copy of every child row.
        await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
            ? await _db.Database.BeginTransactionAsync(cancellationToken)
            : null;

        var json = StructuredCvProfileJson.SerializePersisted(profile);

        var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
        if (existing is null)
        {
            existing = new CareerProfile
            {
                OwnerUserId = ownerUserId,
                ProfileJson = json,
                Version = 1,
                CreatedAtUtc = DateTimeOffset.UtcNow,
                UpdatedAtUtc = DateTimeOffset.UtcNow,
            };
            _db.CareerProfiles.Add(existing);
        }
        else
        {
            existing.ProfileJson = json;
            existing.Version += 1;
            existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
        }

        existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
        await _db.SaveChangesAsync(cancellationToken);

        await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);

        _db.CareerProfileVersions.Add(new CareerProfileVersion
        {
            OwnerUserId = ownerUserId,
            CareerProfileId = existing.Id,
            Version = existing.Version,
            ProfileJson = json,
            Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
            CreatedAtUtc = DateTimeOffset.UtcNow,
        });
        await _db.SaveChangesAsync(cancellationToken);
        if (transaction is not null) await transaction.CommitAsync(cancellationToken);

        return profile;
    }

    public async Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken)
    {
        var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
        if (profile is null) return new StructuredCvProfile();

        var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
            || await _db.CareerEducations.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
            || await _db.CareerSkills.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
            || await _db.CareerProjects.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
            || await _db.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
            || await _db.CareerLanguages.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken);

        // Backfill a pre-Phase-3 profile from its blob, once, before reading relationally.
        if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
        {
            var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
            AssignStableIds(fromBlob);
            NormalizeDates(fromBlob);
            await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
        }

        var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));

        return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
    }

    public async Task<StructuredCvProfile> LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken)
    {
        var profile = await _db.CareerProfiles.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
        if (profile is null) return new StructuredCvProfile();

        var experiences = UniqueChildren(await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var education = UniqueChildren(await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var skills = UniqueChildren(await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var projects = UniqueChildren(await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var certifications = UniqueChildren(await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
        var languages = UniqueChildren(await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));

        // No relational rows yet (a pre-Phase-3 profile that has never been re-saved): fall back to
        // the blob so a public CV still renders. Read-only, so we don't backfill here.
        if (experiences.Count == 0 && education.Count == 0 && skills.Count == 0 && projects.Count == 0
            && certifications.Count == 0 && languages.Count == 0 && !string.IsNullOrWhiteSpace(profile.ProfileJson))
        {
            return StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
        }

        return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
    }

    public async Task<IReadOnlyList<CareerProfileVersionInfo>> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken)
    {
        var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
        if (profile is null) return Array.Empty<CareerProfileVersionInfo>();

        var versions = await _db.CareerProfileVersions
            .Where(x => x.CareerProfileId == profile.Id)
            .OrderByDescending(x => x.Version)
            .Select(x => new { x.Version, x.Source, x.CreatedAtUtc })
            .ToListAsync(cancellationToken);

        return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList();
    }

    public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken)
    {
        var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
        if (profile is null) return null;

        var target = await _db.CareerProfileVersions.FirstOrDefaultAsync(x => x.CareerProfileId == profile.Id && x.Version == version, cancellationToken);
        if (target is null) return null;

        // Re-save the old snapshot as a new version. Non-destructive: the current state stays in
        // history, so a restore can itself be undone by restoring the version before it.
        var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson);
        return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken);
    }

    // Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the
    // children is simpler and safer than diffing. ItemKeys are preserved from the StructuredCvProfile
    // item ids so references survive; SortOrder is the array position.
    private async Task SyncRelationalChildrenAsync(int careerProfileId, string ownerUserId, StructuredCvProfile source, CancellationToken cancellationToken)
    {
        _db.CareerExperiences.RemoveRange(_db.CareerExperiences.Where(x => x.CareerProfileId == careerProfileId));
        _db.CareerEducations.RemoveRange(_db.CareerEducations.Where(x => x.CareerProfileId == careerProfileId));
        _db.CareerSkills.RemoveRange(_db.CareerSkills.Where(x => x.CareerProfileId == careerProfileId));
        _db.CareerProjects.RemoveRange(_db.CareerProjects.Where(x => x.CareerProfileId == careerProfileId));
        _db.CareerCertifications.RemoveRange(_db.CareerCertifications.Where(x => x.CareerProfileId == careerProfileId));
        _db.CareerLanguages.RemoveRange(_db.CareerLanguages.Where(x => x.CareerProfileId == careerProfileId));
        await _db.SaveChangesAsync(cancellationToken);

        CareerProfileMapper.AddChildren(_db, careerProfileId, ownerUserId, source);
        await _db.SaveChangesAsync(cancellationToken);
    }

    private static void AssignStableIds(StructuredCvProfile profile)
    {
        var used = new HashSet<string>(StringComparer.Ordinal);
        foreach (var job in profile.Jobs)
        {
            job.Id = UniqueItemId(job.Id, used);
        }
        foreach (var education in profile.Education)
        {
            education.Id = UniqueItemId(education.Id, used);
        }
        foreach (var certification in profile.Certifications)
        {
            certification.Id = UniqueItemId(certification.Id, used);
        }
        foreach (var project in profile.Projects)
        {
            project.Id = UniqueItemId(project.Id, used);
        }
    }

    private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];

    private static string UniqueItemId(string? candidate, HashSet<string> used)
    {
        var normalized = candidate?.Trim();
        if (!string.IsNullOrWhiteSpace(normalized) && used.Add(normalized)) return normalized;
        string generated;
        do generated = NewItemId(); while (!used.Add(generated));
        return generated;
    }

    // Old race-affected databases can contain exact duplicate rows with one stable ItemKey. Keep
    // reads deterministic and avoid duplicate CV output while preserving the stored rows for an
    // explicit repair migration/backup workflow.
    private static List<T> UniqueChildren<T>(List<T> items) where T : CareerChildEntity =>
        items.GroupBy(item => item.ItemKey, StringComparer.Ordinal)
            .Select(group => group.OrderBy(item => item.SortOrder).ThenBy(item => item.Id).First())
            .OrderBy(item => item.SortOrder).ThenBy(item => item.Id)
            .ToList();

    private static void NormalizeDates(StructuredCvProfile profile)
    {
        foreach (var job in profile.Jobs)
        {
            job.StartDate = CvDateNormalizer.TryParseYearMonth(job.Start);
            job.EndDate = job.IsCurrent ? null : CvDateNormalizer.TryParseYearMonth(job.End);
        }
        foreach (var education in profile.Education)
        {
            education.StartDate = CvDateNormalizer.TryParseYearMonth(education.Start);
            education.EndDate = CvDateNormalizer.TryParseYearMonth(education.End);
        }
        foreach (var certification in profile.Certifications)
        {
            certification.DateNormalized = CvDateNormalizer.TryParseYearMonth(certification.Date);
        }
        foreach (var project in profile.Projects)
        {
            project.StartDate = CvDateNormalizer.TryParseYearMonth(project.Start);
            project.EndDate = CvDateNormalizer.TryParseYearMonth(project.End);
        }
    }
}

// Best-effort free-string -> "YYYY-MM" parser. Never throws, never loses data: the original
// free-string field is always kept alongside whatever this returns (null on anything it can't
// confidently parse -- callers must not treat null as "no date", only as "unparsed").
public static class CvDateNormalizer
{
    private static readonly Dictionary<string, int> MonthNames = new(StringComparer.OrdinalIgnoreCase)
    {
        ["jan"] = 1, ["january"] = 1,
        ["feb"] = 2, ["february"] = 2,
        ["mar"] = 3, ["march"] = 3,
        ["apr"] = 4, ["april"] = 4,
        ["may"] = 5,
        ["jun"] = 6, ["june"] = 6,
        ["jul"] = 7, ["july"] = 7,
        ["aug"] = 8, ["august"] = 8,
        ["sep"] = 9, ["sept"] = 9, ["september"] = 9,
        ["oct"] = 10, ["october"] = 10,
        ["nov"] = 11, ["november"] = 11,
        ["dec"] = 12, ["december"] = 12,
    };

    public static string? TryParseYearMonth(string? raw)
    {
        var value = (raw ?? string.Empty).Trim();
        if (value.Length == 0) return null;
        if (value.Equals("present", StringComparison.OrdinalIgnoreCase) || value.Equals("current", StringComparison.OrdinalIgnoreCase)) return null;

        // "2020" -> January is an assumption we don't want to make silently; year-only stays unparsed.
        var monthYear = Regex.Match(value, @"^(?<month>[A-Za-z]+)\.?\s+(?<year>\d{4})$");
        if (monthYear.Success && MonthNames.TryGetValue(monthYear.Groups["month"].Value, out var month))
        {
            return $"{monthYear.Groups["year"].Value}-{month:D2}";
        }

        var slash = Regex.Match(value, @"^(?<month>\d{1,2})/(?<year>\d{4})$");
        if (slash.Success)
        {
            var m = int.Parse(slash.Groups["month"].Value);
            if (m is >= 1 and <= 12) return $"{slash.Groups["year"].Value}-{m:D2}";
        }

        var isoLike = Regex.Match(value, @"^(?<year>\d{4})-(?<month>\d{1,2})$");
        if (isoLike.Success)
        {
            var m = int.Parse(isoLike.Groups["month"].Value);
            if (m is >= 1 and <= 12) return $"{isoLike.Groups["year"].Value}-{m:D2}";
        }

        return null;
    }
}
