using System.Net; using System.Text; using JobTrackerApi.Models; namespace JobTrackerApi.Services; public sealed record TailoredCvRenderResult(string TemplateId, string SuggestedFileName, string Html); public interface ICvTemplateRenderer { TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null); } public sealed class CvTemplateRenderer : ICvTemplateRenderer { public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null) { var normalized = TailoredCvDraftJson.Normalize(document); var effectiveTemplateId = NormalizeTemplateId(templateId ?? normalized.TemplateId); normalized.TemplateId = effectiveTemplateId; var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf"; var html = effectiveTemplateId switch { "harvard" => RenderHarvard(normalized, candidateName, jobTitle, companyName), "auckland" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Auckland", roundedPhoto: false, curvedHeader: false), "edinburgh" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Edinburgh", roundedPhoto: true, curvedHeader: true), "monarch" => RenderMonarch(normalized, candidateName, jobTitle, companyName, photoDataUrl), "fjord" => RenderFjord(normalized, candidateName, jobTitle, companyName, photoDataUrl), _ => RenderAtsMinimal(normalized, candidateName, jobTitle, companyName, photoDataUrl) }; return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html); } private static string NormalizeTemplateId(string? value) { var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); return normalized switch { "base" => "ats-minimal", "legacy-text" => "ats-minimal", "harvard" => "harvard", "auckland" => "auckland", "edinburgh" => "edinburgh", "monarch" => "monarch", "fjord" => "fjord", _ => "ats-minimal" }; } private static string RenderAtsMinimal(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl) { var accent = ResolveAccent(document.RenderOptions.AccentColor); var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); var body = RenderMainSections(document, accent, headingStyle: "caps-rule"); var companyFocusMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"Company focus: {Encode(companyName)}"; var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; return $@" {Encode(candidateName)} — ATS Minimal

{Encode(candidateName)}

{Encode(document.Headline ?? jobTitle)}
Target role: {Encode(jobTitle)}{companyFocusMarkup}Template: ATS Minimal
{photoMarkup}
{body}
"; } private static string RenderHarvard(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName) { var accent = ResolveAccent(document.RenderOptions.AccentColor); var body = RenderMainSections(document, accent, headingStyle: "harvard"); var contactLine = string.Join("  •  ", new[] { string.IsNullOrWhiteSpace(companyName) ? null : $"Targeting {Encode(companyName)}", Encode(jobTitle) }.Where(x => !string.IsNullOrWhiteSpace(x))); return $@" {Encode(candidateName)} — Harvard

{Encode(candidateName)}

{Encode(document.Headline ?? jobTitle)}
{contactLine}
{body}
"; } private static string RenderSidebar(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl, string templateLabel, bool roundedPhoto, bool curvedHeader) { var accent = ResolveAccent(document.RenderOptions.AccentColor); var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); var sidebarSections = new StringBuilder(); sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[] { $"Name\n{Encode(candidateName)}", $"Target role\n{Encode(jobTitle)}", string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{Encode(companyName)}" })); if (document.CustomSections.Count > 0) { foreach (var section in document.CustomSections.Take(2)) { sidebarSections.Append(RenderSidebarMetaSection(section.Title ?? "Additional", section.Items)); } } if (document.SelectedSkills.Count > 0) { sidebarSections.Append(RenderSidebarMetaSection("Skills", document.SelectedSkills.Take(8))); } var main = RenderMainSections(document, accent, headingStyle: "sidebar"); var photoClass = roundedPhoto ? "photo round" : "photo"; var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; var heroClass = curvedHeader ? "hero curved" : "hero"; return $@" {Encode(candidateName)} — {Encode(templateLabel)}
{main}
"; } private static string RenderMonarch(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl) { var accent = ResolveAccent(document.RenderOptions.AccentColor); var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; var body = RenderMainSections(document, accent, headingStyle: "sidebar"); var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"
Tailored toward {Encode(companyName)}
"; return $@" {Encode(candidateName)} — Monarch
Executive CV

{Encode(candidateName)}

{Encode(document.Headline ?? jobTitle)}
{companyMarkup}
{photoMarkup}
{(!string.IsNullOrWhiteSpace(jobTitle) ? $"
Primary role target: {Encode(jobTitle)}
" : string.Empty)} {body}
"; } private static string RenderFjord(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl) { var accent = ResolveAccent(document.RenderOptions.AccentColor); var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); var body = RenderMainSections(document, accent, headingStyle: "sidebar"); var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"{Encode(companyName)}"; return $@" {Encode(candidateName)} — Fjord
{body}
"; } private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle) { var sectionOrder = document.RenderOptions.SectionOrder.Count == 0 ? new List { "summary", "skills", "experience", "education", "custom" } : document.RenderOptions.SectionOrder; var sections = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["summary"] = RenderListSection("Profile", document.Summary, bulletList: true), ["skills"] = RenderSkillSection(document.SelectedSkills), ["experience"] = RenderExperienceSection(document.Experience), ["education"] = RenderEducationSection(document.Education), ["custom"] = string.Join(string.Empty, document.CustomSections.Select(RenderCustomSection)), }; return string.Join(string.Empty, sectionOrder .Select(key => sections.TryGetValue(key, out var section) ? section : string.Empty) .Where(section => !string.IsNullOrWhiteSpace(section))); } private static string BaseSectionCss(string accent, string headingStyle) { var headingCss = headingStyle switch { "harvard" => ".section-title{font-size:17pt;font-weight:700;border-bottom:1.5px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}", "sidebar" => ".section-title{font-size:14pt;font-weight:700;letter-spacing:.02em;margin-bottom:3mm;}", _ => ".section-title{font-size:9pt;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);border-bottom:1px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}" }; return $@" .section{{margin-top:6mm;}} {headingCss} .summary,.custom-list,.education-list,.experience-bullets{{margin:0;padding-left:4.5mm;}} .summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;}} .skills{{list-style:none;padding-left:0;display:flex;flex-wrap:wrap;gap:2mm;}} .skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;}} .entry{{margin-bottom:4.8mm;}} .entry-header{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;margin-bottom:1.2mm;}} .entry-title{{font-weight:700;font-size:11pt;}} .entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:nowrap;}} .entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;}}"; } private static string RenderSidebarMetaSection(string title, IEnumerable items) { var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"

{item}

")); if (string.IsNullOrWhiteSpace(content)) return string.Empty; return $"

{Encode(title)}

{content}
"; } private static string RenderListSection(string title, IReadOnlyCollection items, bool bulletList) { if (items.Count == 0) return string.Empty; var tag = bulletList ? "summary" : "custom-list"; return $"

{Encode(title)}

    {string.Join(string.Empty, items.Select(item => $"
  • {Encode(item)}
  • "))}
"; } private static string RenderSkillSection(IReadOnlyCollection skills) { if (skills.Count == 0) return string.Empty; return $"

Skills

    {string.Join(string.Empty, skills.Select(skill => $"
  • {Encode(skill)}
  • "))}
"; } private static string RenderExperienceSection(IReadOnlyCollection experience) { if (experience.Count == 0) return string.Empty; var items = new StringBuilder(); foreach (var entry in experience) { var subtitle = string.Join(" · ", new[] { entry.Company, entry.Location }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Encode)); var dateRange = FormatDateRange(entry.Start, entry.End, entry.IsCurrent); items.Append("
"); items.Append($"
{Encode(entry.Title)}
{Encode(dateRange)}
"); if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"
{subtitle}
"); if (entry.Bullets.Count > 0) items.Append($"
    {string.Join(string.Empty, entry.Bullets.Select(bullet => $"
  • {Encode(bullet)}
  • "))}
"); items.Append("
"); } return $"

Professional Experience

{items}
"; } private static string RenderEducationSection(IReadOnlyCollection education) { if (education.Count == 0) return string.Empty; var items = new StringBuilder(); foreach (var entry in education) { var subtitle = string.Join(" · ", new[] { entry.Institution, entry.Location, FormatDateRange(entry.Start, entry.End, false) } .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(Encode)); items.Append("
"); var title = string.IsNullOrWhiteSpace(entry.QualificationLevel) ? entry.Qualification : $"{entry.Qualification} ({entry.QualificationLevel})"; items.Append($"
{Encode(title)}
"); if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"
{subtitle}
"); if (entry.Details.Count > 0) items.Append($"
    {string.Join(string.Empty, entry.Details.Select(detail => $"
  • {Encode(detail)}
  • "))}
"); items.Append("
"); } return $"

Education

{items}
"; } private static string RenderCustomSection(TailoredCvCustomSection section) { if (section.Items.Count == 0) return string.Empty; return $"

{Encode(section.Title ?? "Additional Information")}

    {string.Join(string.Empty, section.Items.Select(item => $"
  • {Encode(item)}
  • "))}
"; } private static string FormatDateRange(string? start, string? end, bool isCurrent) { var normalizedStart = (start ?? string.Empty).Trim(); var normalizedEnd = (end ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(normalizedStart) && string.IsNullOrWhiteSpace(normalizedEnd)) return string.Empty; if (string.IsNullOrWhiteSpace(normalizedStart)) return normalizedEnd; return $"{normalizedStart} - {(isCurrent ? "Present" : string.IsNullOrWhiteSpace(normalizedEnd) ? "Present" : normalizedEnd)}"; } private static string ResolveAccent(string? accentColor) { var normalized = (accentColor ?? string.Empty).Trim().ToLowerInvariant(); return normalized switch { "slate" => "#334155", "blue" => "#1d4ed8", "emerald" => "#047857", "plum" => "#7c3aed", "brick" => "#b45309", _ when normalized.StartsWith("#") => normalized, _ => "#334155" }; } private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? string.Empty); private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "'", StringComparison.Ordinal); private static string Slugify(string value) { var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray()); while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal); return cleaned.Trim('-'); } }