using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;

namespace JobTrackerApi.Tests;

// Phase 5.3 — Application Intelligence. These tests pin the two properties that matter most: the
// results are OWNERSHIP-SCOPED, and nothing here writes to the user's data. Career matching in
// particular reads the master CareerProfile and must leave it byte-identical.
public sealed class ApplicationIntelligenceTests
{
    private const string Advert = """
        We are hiring a Senior Backend Developer (full-time) to join our platform team in Oslo.

        You will:
        - Build and operate REST APIs used by every product surface
        - Own services end to end, from design through production support
        - Work closely with product and design on new features

        We expect:
        - Strong experience with C# and .NET
        - Solid SQL and Docker knowledge
        - Experience with unit tests and CI/CD
        """;

    private static (JobTrackerContext db, ApplicationIntelligenceService intelligence, ApplicationTimelineService timeline) New(string userId)
    {
        var options = new DbContextOptionsBuilder<JobTrackerContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
        var currentUser = new Mock<ICurrentUserService>();
        currentUser.SetupGet(s => s.UserId).Returns(userId);
        var db = new JobTrackerContext(options, currentUser.Object);
        return (db, new ApplicationIntelligenceService(db, new JobCvMatchService()), new ApplicationTimelineService(db));
    }

    private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db, string owner, Action<JobApplication>? tweak = null)
    {
        var company = new Company { OwnerUserId = owner, Name = "Acme" };
        db.Companies.Add(company);
        await db.SaveChangesAsync();
        var job = new JobApplication
        {
            OwnerUserId = owner,
            CompanyId = company.Id,
            JobTitle = "Senior Backend Developer",
            Status = "Applied",
            Location = "Oslo",
            Description = Advert,
        };
        tweak?.Invoke(job);
        db.JobApplications.Add(job);
        await db.SaveChangesAsync();
        return job;
    }

    private static async Task<CareerProfile> SeedProfileAsync(JobTrackerContext db, string owner)
    {
        var profile = new CareerProfile
        {
            OwnerUserId = owner,
            Experiences =
            {
                new CareerExperience
                {
                    OwnerUserId = owner, Title = "Backend Developer", Company = "Initech", Start = "2021", IsCurrent = true,
                    BulletsJson = """["Built REST APIs in C# and .NET","Ran the SQL migration programme"]""",
                },
                new CareerExperience
                {
                    OwnerUserId = owner, Title = "Barista", Company = "Coffee Co", Start = "2018", End = "2020",
                    BulletsJson = """["Served customers"]""",
                },
            },
            Projects =
            {
                new CareerProject
                {
                    OwnerUserId = owner, Name = "Deploy pipeline", Role = "Author",
                    BulletsJson = """["Docker based CI/CD for six services"]""",
                },
            },
            Skills = { new CareerSkill { OwnerUserId = owner, Name = "C#", Category = "Languages" } },
        };
        db.CareerProfiles.Add(profile);
        await db.SaveChangesAsync();
        return profile;
    }

    private static async Task<CvVariant> AttachCvAsync(JobTrackerContext db, string owner, int jobId, CvVariantSettings? settings = null)
    {
        var variant = new CvVariant
        {
            OwnerUserId = owner,
            JobApplicationId = jobId,
            Name = "Backend CV",
            PublicSlug = Guid.NewGuid().ToString("N"),
            SettingsJson = CvVariantSettingsJson.Serialize(settings),
            Version = 1,
            CreatedAtUtc = DateTimeOffset.UtcNow,
            UpdatedAtUtc = DateTimeOffset.UtcNow,
        };
        db.CvVariants.Add(variant);
        await db.SaveChangesAsync();
        return variant;
    }

    // ---------- Milestone 1: timeline ----------

    [Fact]
    public async Task Timeline_turns_raw_events_into_readable_summaries()
    {
        var (db, _, timeline) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-5) });
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", OldValue = "Applied", NewValue = "Interview", At = DateTime.Now.AddDays(-1) });
        await db.SaveChangesAsync();

        var result = await timeline.GetAsync("user-1", job.Id, null, false, default);

        Assert.NotNull(result);
        var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList();
        Assert.Contains("Moved from Applied to Interview", summaries);
        Assert.Contains("Application created", summaries);
    }

    [Fact]
    public async Task Timeline_groups_by_day_newest_first()
    {
        var (db, _, timeline) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");
        // Anchor the two older events to a fixed time-of-day so they always land on the same
        // calendar day. Using DateTime.Now.AddDays(-3).AddHours(2) straddled midnight whenever the
        // wall clock was within two hours of it, splitting one day into two and failing the test.
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Today.AddDays(-3).AddHours(9) });
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Today.AddDays(-3).AddHours(11) });
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "ReplyReceived", At = DateTime.Now });
        await db.SaveChangesAsync();

        var result = await timeline.GetAsync("user-1", job.Id, null, false, default);

        Assert.Equal(2, result!.Days.Count);
        Assert.True(result.Days[0].Date > result.Days[1].Date);
        Assert.Equal(2, result.Days[1].Events.Count);
        Assert.Equal("Today", result.Days[0].Label);
    }

    [Fact]
    public async Task Timeline_detects_milestones_and_ignores_routine_events()
    {
        var (db, _, timeline) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Interview", At = DateTime.Now });
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Waiting", At = DateTime.Now.AddDays(-1) });
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-2) });
        await db.SaveChangesAsync();

        var result = await timeline.GetAsync("user-1", job.Id, null, false, default);

        Assert.Single(result!.Milestones);
        Assert.Equal("Moved to Interview", result.Milestones[0].Summary);
    }

    [Fact]
    public async Task Timeline_filters_by_category_but_keeps_the_milestone_spine()
    {
        var (db, _, timeline) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Offer", At = DateTime.Now });
        db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now });
        await db.SaveChangesAsync();

        var result = await timeline.GetAsync("user-1", job.Id, "ai", false, default);

        Assert.Single(result!.Days.SelectMany(d => d.Events));
        Assert.Equal("ai", result.Days[0].Events[0].Category);
        // Filtering the detail must not hide what actually happened.
        Assert.Single(result.Milestones);
        Assert.Equal(2, result.TotalEvents);
    }

    [Fact]
    public async Task Timeline_is_not_readable_for_another_users_application()
    {
        var (db, _, timeline) = New("user-1");
        await using var _d = db;
        var other = await SeedJobAsync(db, "user-2");

        Assert.Null(await timeline.GetAsync("user-1", other.Id, null, false, default));
    }

    // ---------- Milestone 2: job analysis ----------

    [Fact]
    public async Task Analysis_extracts_structure_from_the_advert()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");

        var a = await intelligence.AnalyzeAsync("user-1", job.Id, default);

        Assert.NotNull(a);
        Assert.Equal("Senior Backend Developer", a!.Role);
        Assert.Equal("Acme", a.Company);
        Assert.Equal("Oslo", a.Location);
        Assert.Equal("Full-time", a.EmploymentType);
        Assert.Equal("Senior", a.Seniority);
        Assert.Contains("C#", a.Technologies);
        Assert.Contains(".NET", a.Technologies);
        Assert.Contains("Docker", a.Technologies);
        Assert.NotEmpty(a.ImportantRequirements);
        Assert.NotEmpty(a.Responsibilities);
        Assert.True(a.HasJobDescription);
    }

    [Fact]
    public async Task Analysis_reports_what_the_advert_does_not_say()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1", j => { j.Salary = null; j.JobUrl = null; });

        var a = await intelligence.AnalyzeAsync("user-1", job.Id, default);

        Assert.Contains(a!.MissingInformation, m => m.Contains("Salary", StringComparison.OrdinalIgnoreCase));
        Assert.Contains(a.MissingInformation, m => m.Contains("link", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public async Task Analysis_degrades_gracefully_without_an_advert()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1", j => j.Description = null);

        var a = await intelligence.AnalyzeAsync("user-1", job.Id, default);

        Assert.False(a!.HasJobDescription);
        Assert.Empty(a.Responsibilities);
        Assert.Contains("advert", a.Summary, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Analysis_is_deterministic()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");

        var first = await intelligence.AnalyzeAsync("user-1", job.Id, default);
        var second = await intelligence.AnalyzeAsync("user-1", job.Id, default);

        Assert.Equal(first!.Keywords, second!.Keywords);
        Assert.Equal(first.Summary, second.Summary);
    }

    [Fact]
    public async Task Analysis_is_not_readable_for_another_users_application()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var other = await SeedJobAsync(db, "user-2");

        Assert.Null(await intelligence.AnalyzeAsync("user-1", other.Id, default));
    }

    // ---------- Milestone 3: career matching ----------

    [Fact]
    public async Task Match_scores_the_profile_against_the_advert_with_evidence()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");
        await SeedProfileAsync(db, "user-1");
        await AttachCvAsync(db, "user-1", job.Id);

        var m = await intelligence.MatchAsync("user-1", job.Id, default);

        Assert.NotNull(m);
        Assert.True(m!.HasSelectedCv);
        Assert.Equal("Backend CV", m.SelectedCvName);
        Assert.True(m.HasCareerProfile);
        Assert.True(m.Score > 0);
        Assert.Contains("C#", m.MatchedSkills);
        // The relevant-experience list is evidence drawn FROM the profile, not a copy of it.
        Assert.Contains(m.RelevantExperience, e => e.Title == "Backend Developer");
        Assert.DoesNotContain(m.RelevantExperience, e => e.Title == "Barista");
        Assert.NotEmpty(m.Suggestions);
    }

    [Fact]
    public async Task Match_never_writes_to_the_career_profile()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");
        var profile = await SeedProfileAsync(db, "user-1");
        await AttachCvAsync(db, "user-1", job.Id);
        var bulletsBefore = profile.Experiences[0].BulletsJson;
        var experienceCountBefore = profile.Experiences.Count;
        var versionBefore = profile.Version;

        await intelligence.MatchAsync("user-1", job.Id, default);

        var after = await db.CareerProfiles.Include(p => p.Experiences).FirstAsync(p => p.OwnerUserId == "user-1");
        Assert.Equal(experienceCountBefore, after.Experiences.Count);
        Assert.Equal(bulletsBefore, after.Experiences.First(e => e.Title == "Backend Developer").BulletsJson);
        Assert.Equal(versionBefore, after.Version);
    }

    [Fact]
    public async Task Match_requires_an_explicitly_linked_cv_before_scoring_anything()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");

        var m = await intelligence.MatchAsync("user-1", job.Id, default);

        Assert.False(m!.HasCareerProfile);
        Assert.False(m.HasSelectedCv);
        Assert.Equal(0, m.Score);
        Assert.Contains(m.Suggestions, s => s.Contains("Select the CV", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public async Task Match_only_reads_the_requesting_users_profile()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");
        await SeedProfileAsync(db, "user-2");   // someone else's profile must not be scored
        await AttachCvAsync(db, "user-1", job.Id);

        var m = await intelligence.MatchAsync("user-1", job.Id, default);

        Assert.False(m!.HasCareerProfile);
        Assert.True(m.HasSelectedCv);
    }

    [Fact]
    public async Task Match_respects_sections_hidden_in_the_linked_cv()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");
        await SeedProfileAsync(db, "user-1");
        await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings
        {
            Sections = { new CvSectionSetting { Key = "experience", Hidden = true } },
        });

        var match = await intelligence.MatchAsync("user-1", job.Id, default);

        Assert.True(match!.HasSelectedCv);
        Assert.Empty(match.RelevantExperience);
    }

    [Fact]
    public async Task Match_is_not_readable_for_another_users_application()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var other = await SeedJobAsync(db, "user-2");

        Assert.Null(await intelligence.MatchAsync("user-1", other.Id, default));
    }

    [Fact]
    public async Task Match_counts_ai_suggestions_without_generating_any()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1");
        await SeedProfileAsync(db, "user-1");
        db.AiInteractions.Add(new AiInteraction
        {
            OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "career-match",
            Title = "Career match", Provider = "p", ResultJson = "{}", CreatedAtUtc = DateTimeOffset.UtcNow,
        });
        await db.SaveChangesAsync();
        var interactionsBefore = await db.AiInteractions.CountAsync();

        var m = await intelligence.MatchAsync("user-1", job.Id, default);

        Assert.Equal(1, m!.AiSuggestionCount);
        // Reading the match must not itself call the AI or append history.
        Assert.Equal(interactionsBefore, await db.AiInteractions.CountAsync());
    }

    [Fact]
    public async Task Match_uses_saved_English_translation_when_linked_cv_is_English()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1", j =>
        {
            j.Description = "Vi ser etter en utvikler med erfaring fra C# og skalerbare systemer.";
            j.DescriptionLanguage = "nb-NO";
            j.TranslatedDescription = "We need a developer with C# and scalable systems experience.";
        });
        await SeedProfileAsync(db, "user-1");
        await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings { Language = "en" });

        var match = await intelligence.MatchAsync("user-1", job.Id, default);

        Assert.NotNull(match);
        Assert.Equal("nb", match!.JobLanguage);
        Assert.Equal("en", match.CvLanguage);
        Assert.True(match.LanguageMismatch);
        Assert.True(match.UsedTranslatedJobDescription);
        Assert.Contains("C#", match.MatchedSkills);
        Assert.DoesNotContain(match.MissingSkills, value => value.Equals("utvikler", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public async Task Match_reports_language_mismatch_when_no_translation_is_available()
    {
        var (db, intelligence, _) = New("user-1");
        await using var _d = db;
        var job = await SeedJobAsync(db, "user-1", j =>
        {
            j.Description = "Vi søker en utvikler som kan bygge robuste systemer og gode tjenester.";
            j.DescriptionLanguage = null; // Exercise detector fallback for manually created jobs.
            j.TranslatedDescription = null;
        });
        await SeedProfileAsync(db, "user-1");
        await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings { Language = "en" });

        var match = await intelligence.MatchAsync("user-1", job.Id, default);

        Assert.NotNull(match);
        Assert.Equal("nb", match!.JobLanguage);
        Assert.Equal("en", match.CvLanguage);
        Assert.True(match.LanguageMismatch);
        Assert.False(match.UsedTranslatedJobDescription);
    }
}
