using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services.JobImport;
using Microsoft.EntityFrameworkCore;

namespace JobTrackerApi.Services;

// Phase 5.3 Milestones 2 and 3 — job analysis and career matching.
//
// DETERMINISTIC and READ-ONLY. Both endpoints derive their answer from data the user already owns
// (the advert on the JobApplication, the explicitly linked CV lens over CareerProfile) using the existing SkillTagger and
// JobCvMatchService. The same input always gives the same number, so the score is something a user
// can trust and re-check.
//
// The AI narrative is deliberately NOT here: it stays in AiWorkspaceService's "job-analysis" and
// "career-match" modules, which are suggestion-only, append-only (AiInteraction is the version
// history), and require the user to act on them. Nothing in this file writes to the CareerProfile,
// a CvVariant, or the JobApplication. docs/architecture/application-workspace.md.
public sealed record JobAnalysisDto(
    string? Role,
    string? Company,
    string? Location,
    string? EmploymentType,
    string? Seniority,
    string? Salary,
    IReadOnlyList<string> Technologies,
    IReadOnlyList<string> Skills,
    IReadOnlyList<string> Responsibilities,
    IReadOnlyList<string> Keywords,
    string Summary,
    IReadOnlyList<string> ImportantRequirements,
    IReadOnlyList<string> InterviewTopics,
    IReadOnlyList<string> MissingInformation,
    bool HasJobDescription,
    int AiSuggestionCount);

public sealed record MatchEvidenceDto(string Title, string? Subtitle, IReadOnlyList<string> Matched);

public sealed record CareerMatchDto(
    int Score,
    string Band,
    bool HasEnoughSignal,
    bool HasCareerProfile,
    IReadOnlyList<string> MatchedSkills,
    IReadOnlyList<string> MissingSkills,
    IReadOnlyList<MatchEvidenceDto> RelevantExperience,
    IReadOnlyList<MatchEvidenceDto> RelevantProjects,
    IReadOnlyList<string> Suggestions,
    int AiSuggestionCount,
    bool HasSelectedCv = false,
    string? SelectedCvName = null,
    string JobLanguage = "en",
    string? CvLanguage = null,
    bool LanguageMismatch = false,
    bool UsedTranslatedJobDescription = false);

public interface IApplicationIntelligenceService
{
    Task<JobAnalysisDto?> AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
    Task<CareerMatchDto?> MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
}

public sealed class ApplicationIntelligenceService : IApplicationIntelligenceService
{
    private const int MaxEvidence = 5;

    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);

    // Bullet lines in an advert: "-", "*", "•", or "1." at the start of a line.
    private static readonly Regex BulletRx = new(@"^\s*(?:[-*•·–]|\d+[.)])\s+(?<text>.+)$",
        RegexOptions.Multiline | RegexOptions.Compiled);

    private static readonly (string Label, Regex Pattern)[] EmploymentTypes =
    {
        ("Full-time", new Regex(@"\bfull[-\s]?time\b|\bfast stilling\b|\bheltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
        ("Part-time", new Regex(@"\bpart[-\s]?time\b|\bdeltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
        ("Contract", new Regex(@"\bcontract\b|\bfreelance\b|\bconsultan(t|cy)\b|\bengasjement\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
        ("Internship", new Regex(@"\bintern(ship)?\b|\btrainee\b|\bpraktikant\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
        ("Temporary", new Regex(@"\btemporary\b|\bfixed[-\s]?term\b|\bvikariat\b|\bmidlertidig\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
    };

    private static readonly (string Label, Regex Pattern)[] Seniorities =
    {
        ("Lead / Principal", new Regex(@"\b(lead|principal|staff|head of|director)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
        ("Senior", new Regex(@"\bsenior\b|\bsr\.?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
        ("Junior / Entry", new Regex(@"\bjunior\b|\bjr\.?\b|\bentry[-\s]?level\b|\bgraduate\b|\bnyutdannet\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
        ("Mid-level", new Regex(@"\bmid[-\s]?level\b|\bintermediate\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
    };

    // A salary line: a currency figure, or an explicit salary/lønn mention with numbers nearby.
    private static readonly Regex SalaryRx = new(
        @"(?:(?:[£$€]|\bNOK\b|\bkr\b|\bUSD\b|\bGBP\b|\bEUR\b)\s?[\d][\d\s.,]{2,}(?:\s?[-–]\s?[\d][\d\s.,]{2,})?(?:\s?(?:k|per\s+(?:year|annum|month|hour)|p\.?a\.?))?)|(?:\b(?:salary|lønn|compensation)\b[^.\n]{0,60}?[\d][\d\s.,]{2,}[^.\n]{0,20})",
        RegexOptions.IgnoreCase | RegexOptions.Compiled);

    private static readonly Regex ResponsibilityRx = new(
        @"\b(you will|you'll|responsib|the role|day[-\s]to[-\s]day|arbeidsoppgaver|du vil)\b",
        RegexOptions.IgnoreCase | RegexOptions.Compiled);

    private static readonly Regex RequirementRx = new(
        @"\b(require|must have|essential|we expect|you have|experience (?:with|in)|proficien|kvalifikasjon|vi ser etter)\b",
        RegexOptions.IgnoreCase | RegexOptions.Compiled);

    private readonly JobTrackerContext _db;
    private readonly IJobCvMatchService _match;

    public ApplicationIntelligenceService(JobTrackerContext db, IJobCvMatchService match)
    {
        _db = db;
        _match = match;
    }

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

    public async Task<JobAnalysisDto?> AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
    {
        var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
            .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
        if (job is null) return null;

        var description = job.Description ?? string.Empty;
        var hasDescription = !string.IsNullOrWhiteSpace(description);
        var haystack = $"{job.JobTitle}\n{description}";

        // Same tagger the job importer and the CV match use, so the vocabulary is consistent everywhere.
        var tags = SkillTagger.Detect(haystack);
        var technologies = tags.Where(IsTechnology).ToList();
        var skills = tags.Where(t => !IsTechnology(t)).ToList();

        var bullets = BulletRx.Matches(description)
            .Select(m => Tidy(m.Groups["text"].Value))
            .Where(b => b.Length > 12)
            .ToList();

        var responsibilities = Pick(bullets, description, ResponsibilityRx);
        var requirements = Pick(bullets, description, RequirementRx);

        var aiCount = await _db.AiInteractions.AsNoTracking()
            .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "job-analysis", ct);

        return new JobAnalysisDto(
            Role: Blank(job.JobTitle),
            Company: Blank(job.Company?.Name),
            Location: Blank(job.Location),
            EmploymentType: FirstMatch(EmploymentTypes, haystack),
            Seniority: FirstMatch(Seniorities, haystack),
            Salary: Blank(job.Salary) ?? (hasDescription ? Tidy(SalaryRx.Match(description).Value) is { Length: > 0 } s ? s : null : null),
            Technologies: technologies,
            Skills: skills,
            Responsibilities: responsibilities,
            Keywords: tags.ToList(),
            Summary: BuildSummary(job, technologies, hasDescription),
            ImportantRequirements: requirements,
            InterviewTopics: technologies.Concat(skills).Take(8).ToList(),
            MissingInformation: MissingInformation(job, hasDescription),
            HasJobDescription: hasDescription,
            AiSuggestionCount: aiCount);
    }

    private static string BuildSummary(JobApplication job, IReadOnlyList<string> technologies, bool hasDescription)
    {
        if (!hasDescription)
        {
            return "No advert text saved yet, so this analysis is limited to the fields on the application. Paste the advert to get requirements, technologies and interview topics.";
        }

        var sb = new StringBuilder();
        sb.Append(job.JobTitle);
        if (!string.IsNullOrWhiteSpace(job.Company?.Name)) sb.Append(" at ").Append(job.Company!.Name);
        if (!string.IsNullOrWhiteSpace(job.Location)) sb.Append(" · ").Append(job.Location);
        sb.Append('.');

        if (technologies.Count > 0)
        {
            sb.Append(" The advert leans on ")
              .Append(string.Join(", ", technologies.Take(5)))
              .Append('.');
        }

        return sb.ToString();
    }

    private static List<string> MissingInformation(JobApplication job, bool hasDescription)
    {
        var missing = new List<string>();
        if (!hasDescription) missing.Add("The advert text itself");
        if (string.IsNullOrWhiteSpace(job.Salary)) missing.Add("Salary or compensation range");
        if (string.IsNullOrWhiteSpace(job.Location)) missing.Add("Location or remote policy");
        if (string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) missing.Add("A named contact to follow up with");
        if (string.IsNullOrWhiteSpace(job.JobUrl)) missing.Add("A link back to the original posting");
        return missing;
    }

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

    public async Task<CareerMatchDto?> MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
    {
        var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
            .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
        if (job is null) return null;

        // A job-specific match must never silently pick an arbitrary CV. The attached variant is a
        // lens over the master profile, so matching it remains read-only while respecting what the
        // user actually plans to send (hidden sections/items and CV-specific wording).
        var attachedQuery = _db.CvVariants.AsNoTracking()
            .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId);
        var attached = _db.Database.IsSqlite()
            ? (await attachedQuery.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc)
            : await attachedQuery.OrderByDescending(v => v.UpdatedAtUtc).FirstOrDefaultAsync(ct);

        var aiCount = await _db.AiInteractions.AsNoTracking()
            .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "career-match", ct);

        if (attached is null)
        {
            return new CareerMatchDto(0, "Select a CV", false, false,
                Array.Empty<string>(), Array.Empty<string>(),
                Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
                new[] { "Select the CV you plan to send before running a job-specific match." },
                aiCount, HasSelectedCv: false);
        }

        var profile = await _db.CareerProfiles.AsNoTracking()
            .Include(p => p.Experiences)
            .Include(p => p.Projects)
            .Include(p => p.Skills)
            .FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct);

        if (profile is null)
        {
            return new CareerMatchDto(0, "No profile", false, false,
                Array.Empty<string>(), Array.Empty<string>(),
                Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
                new[] { "Build your career profile first — matching compares the advert against it." },
                aiCount, HasSelectedCv: true, SelectedCvName: attached.Name);
        }

        var settings = CvVariantSettingsJson.Deserialize(attached.SettingsJson);
        var experienceVisible = SectionVisible(settings, "experience");
        var projectsVisible = SectionVisible(settings, "projects");
        var skillsVisible = SectionVisible(settings, "skills");
        var experiences = experienceVisible
            ? profile.Experiences.Where(entry => !Override(settings, entry.ItemKey).Hidden).ToList()
            : new List<CareerExperience>();
        var projects = projectsVisible
            ? profile.Projects.Where(entry => !Override(settings, entry.ItemKey).Hidden).ToList()
            : new List<CareerProject>();
        var skillItems = settings.Sections.FirstOrDefault(section => string.Equals(section.Key, "skills", StringComparison.OrdinalIgnoreCase))?.Items;
        var skillsText = !skillsVisible
            ? string.Empty
            : skillItems is not null
                ? string.Join("\n", skillItems)
                : string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}"));

        // Feed the profile to the SAME deterministic matcher the CV builder uses, so one job scores
        // identically whichever surface asks.
        var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["Experience"] = string.Join("\n", experiences.Select(entry => ExperienceText(entry, Override(settings, entry.ItemKey)))),
            ["Projects"] = string.Join("\n", projects.Select(entry => ProjectText(entry, Override(settings, entry.ItemKey)))),
            ["Skills"] = skillsText,
        };

        var cvCorpus = string.Join("\n", sections.Values);
        var jobLanguage = NormalizeMatchLanguage(job.DescriptionLanguage, job.Description);
        var cvLanguage = NormalizeMatchLanguage(settings.Language, cvCorpus);
        var languageMismatch = !string.Equals(jobLanguage, cvLanguage, StringComparison.OrdinalIgnoreCase);

        // Imported Norwegian adverts may already carry a trusted English translation. When the
        // explicitly linked CV is English, compare against that saved translation instead of
        // penalising equivalent wording in different languages. This endpoint remains read-only:
        // it never starts a translation request or mutates either source document.
        var useTranslatedJobDescription = languageMismatch
            && jobLanguage == "nb"
            && cvLanguage == "en"
            && !string.IsNullOrWhiteSpace(job.TranslatedDescription);
        var matchDescription = useTranslatedJobDescription
            ? job.TranslatedDescription!
            : job.Description ?? string.Empty;

        var result = _match.Evaluate(job.JobTitle, matchDescription, sections);

        var relevantExperience = experiences
            .Select(e => new { Entry = e, Override = Override(settings, e.ItemKey) })
            .Select(x => new { x.Entry, x.Override, Hits = HitsFor(ExperienceText(x.Entry, x.Override), result.MatchedKeywords) })
            .Where(x => x.Hits.Count > 0)
            .OrderByDescending(x => x.Hits.Count)
            .Take(MaxEvidence)
            .Select(x => new MatchEvidenceDto(
                x.Override.Title ?? x.Entry.Title ?? "Untitled role",
                string.Join(" · ", new[] { x.Override.Subtitle ?? x.Entry.Company, Period(x.Entry.Start, x.Entry.End, x.Entry.IsCurrent) }.Where(v => !string.IsNullOrWhiteSpace(v))),
                x.Hits))
            .ToList();

        var relevantProjects = projects
            .Select(p => new { Entry = p, Override = Override(settings, p.ItemKey) })
            .Select(x => new { x.Entry, x.Override, Hits = HitsFor(ProjectText(x.Entry, x.Override), result.MatchedKeywords) })
            .Where(x => x.Hits.Count > 0)
            .OrderByDescending(x => x.Hits.Count)
            .Take(MaxEvidence)
            .Select(x => new MatchEvidenceDto(
                x.Override.Title ?? x.Entry.Name ?? "Untitled project",
                Blank(x.Override.Subtitle ?? x.Entry.Role),
                x.Hits))
            .ToList();

        return new CareerMatchDto(
            result.Score,
            result.Band,
            result.HasEnoughSignal,
            HasCareerProfile: true,
            MatchedSkills: result.MatchedKeywords,
            MissingSkills: result.MissingKeywords,
            RelevantExperience: relevantExperience,
            RelevantProjects: relevantProjects,
            Suggestions: Suggestions(result, relevantExperience.Count),
            AiSuggestionCount: aiCount,
            HasSelectedCv: true,
            SelectedCvName: attached.Name,
            JobLanguage: jobLanguage,
            CvLanguage: cvLanguage,
            LanguageMismatch: languageMismatch,
            UsedTranslatedJobDescription: useTranslatedJobDescription);
    }

    // Suggestions describe what the USER could change. They never edit anything themselves.
    private static List<string> Suggestions(JobCvMatchResult result, int experienceHits)
    {
        var suggestions = new List<string>();

        if (!result.HasEnoughSignal)
        {
            suggestions.Add("The advert is too short to score reliably — paste the full text for a real match.");
            return suggestions;
        }

        if (result.MissingKeywords.Count > 0)
        {
            suggestions.Add($"The advert asks for {string.Join(", ", result.MissingKeywords.Take(4))} — add it to your profile if you have it.");
        }

        if (experienceHits == 0)
        {
            suggestions.Add("No experience entry matched the advert. Rewrite your bullets in the advert's vocabulary where it is honest to do so.");
        }

        if (result.Score < 50)
        {
            suggestions.Add("A CV variant tailored to this advert would lift the match — the builder starts from your master profile.");
        }
        else if (result.Score < 80)
        {
            suggestions.Add("Solid match. Lead with the matched skills in your cover letter's opening paragraph.");
        }
        else
        {
            suggestions.Add("Strong match. Focus your effort on the cover letter and interview prep rather than the CV.");
        }

        return suggestions;
    }

    // ---------- shared ----------

    private static string ExperienceText(CareerExperience e, CvItemOverride? itemOverride = null) =>
        $"{itemOverride?.Title ?? e.Title} {itemOverride?.Subtitle ?? e.Company} {e.Location} {OverrideBullets(itemOverride, e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}";

    private static string ProjectText(CareerProject p, CvItemOverride? itemOverride = null) =>
        $"{itemOverride?.Title ?? p.Name} {itemOverride?.Subtitle ?? p.Role} {OverrideBullets(itemOverride, p.BulletsJson)} {ReadJsonArray(p.SkillsJson)}";

    private static string OverrideBullets(CvItemOverride? itemOverride, string fallbackJson) =>
        itemOverride?.Bullets is null ? ReadJsonArray(fallbackJson) : string.Join(" ", itemOverride.Bullets);

    private static CvItemOverride Override(CvVariantSettings settings, string itemKey) =>
        settings.Overrides.TryGetValue(itemKey, out var itemOverride) ? itemOverride : new CvItemOverride();

    private static bool SectionVisible(CvVariantSettings settings, string key) =>
        settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase))?.Hidden != true;

    private static string ReadJsonArray(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) return string.Empty;
        try
        {
            var items = JsonSerializer.Deserialize<List<string>>(json, Json);
            return items is null ? string.Empty : string.Join(" ", items);
        }
        catch (JsonException)
        {
            // A malformed blob must not break the whole match — treat it as no text.
            return string.Empty;
        }
    }

    // Which of the job's matched keywords this specific entry is the evidence for.
    private static List<string> HitsFor(string text, IReadOnlyList<string> matchedKeywords) =>
        matchedKeywords.Where(k => SkillTagger.MatchesTag(k, text)).ToList();

    private static string? Period(string? start, string? end, bool isCurrent)
    {
        if (string.IsNullOrWhiteSpace(start)) return isCurrent ? "Current" : null;
        return isCurrent ? $"{start} – present" : string.IsNullOrWhiteSpace(end) ? start : $"{start} – {end}";
    }

    // Pull the bullets nearest the paragraph that introduces requirements/responsibilities. Falls back
    // to "all bullets" when the advert has no such heading, which is common enough.
    private static List<string> Pick(List<string> bullets, string description, Regex cue)
    {
        if (bullets.Count == 0) return new List<string>();
        if (!cue.IsMatch(description)) return bullets.Take(MaxEvidence).ToList();

        var cued = bullets.Where(b => cue.IsMatch(b)).ToList();
        return (cued.Count > 0 ? cued : bullets).Take(MaxEvidence).ToList();
    }

    private static bool IsTechnology(string tag) => tag switch
    {
        "Communication" or "Collaboration" or "Problem Solving" or "Leadership" or "Ownership"
            or "Adaptability" or "Attention to Detail" or "Agile" => false,
        _ => true,
    };

    private static string? FirstMatch((string Label, Regex Pattern)[] table, string text)
    {
        foreach (var (label, pattern) in table)
        {
            if (pattern.IsMatch(text)) return label;
        }
        return null;
    }

    private static string Tidy(string? value) =>
        string.IsNullOrWhiteSpace(value) ? string.Empty : Regex.Replace(value.Trim(), @"\s+", " ");

    private static string? Blank(string? value) =>
        string.IsNullOrWhiteSpace(value) ? null : value.Trim();

    private static string NormalizeMatchLanguage(string? declaredLanguage, string? text)
    {
        var language = string.IsNullOrWhiteSpace(declaredLanguage)
            ? LanguageDetector.Detect(text)
            : declaredLanguage.Trim().ToLowerInvariant();
        return language.StartsWith("nb", StringComparison.Ordinal)
            || language.StartsWith("no", StringComparison.Ordinal)
            || language.StartsWith("nn", StringComparison.Ordinal)
            ? "nb"
            : "en";
    }
}
