using BMA.EHR.Application.Responses; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using System.Diagnostics; using System.Reflection; using System.Runtime.InteropServices; namespace BMA.EHR.Retirement.Service.Services { public class RetirementReportService { private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; private readonly IConfiguration _configuration; /// /// Initializes a new instance of the RetirementReportService class. /// public RetirementReportService( IWebHostEnvironment environment, ILogger logger, IConfiguration configuration) { _environment = environment; _logger = logger; _configuration = configuration; } #region Public Methods /// /// สร้างรายงานจาก Template (.docx) /// public async Task GenerateReportAsync(string templateName, dynamic data, string exportType) { try { var templatePath = GetTemplatePath(templateName); var docxBytes = await ProcessTemplateAsync(templatePath, data); return exportType.ToLower() == "pdf" ? await ConvertToPdfAsync(docxBytes) : docxBytes; } catch (Exception ex) { _logger.LogError(ex, "Error generating report"); throw; } } #endregion #region Template Processing private string GetTemplatePath(string templateName) { var path = Path.Combine(_environment.ContentRootPath, "Templates", $"{templateName}.docx"); if (!File.Exists(path)) throw new FileNotFoundException($"Template not found: {templateName}"); return path; } private async Task ProcessTemplateAsync(string templatePath, dynamic data) { using var templateStream = File.OpenRead(templatePath); using var outputStream = new MemoryStream(); await templateStream.CopyToAsync(outputStream); outputStream.Position = 0; using (var wordDoc = WordprocessingDocument.Open(outputStream, true)) { var mainPart = wordDoc.MainDocumentPart; if (mainPart == null) return Array.Empty(); ReplacePlaceholders(mainPart, data); wordDoc.Save(); } return outputStream.ToArray(); } private void ReplacePlaceholders(MainDocumentPart mainPart, dynamic data) { var document = mainPart.Document; if (document == null) return; var processor = CreateDataProcessor(data); processor.Process(document, new Action(FillTableRows)); } #endregion #region Data Processing Strategy private IDataProcessor CreateDataProcessor(dynamic data) { var dataType = data.GetType(); var isDictionary = dataType.IsGenericType && dataType.GetGenericTypeDefinition() == typeof(Dictionary<,>); return isDictionary ? new DictionaryDataProcessor(data) : new ObjectDataProcessor(data); } #endregion #region Table Processing private void FillTableRows(Document document, System.Collections.IEnumerable profiles) { var table = document.Descendants().FirstOrDefault(); if (table == null) return; var rows = table.Elements().ToList(); if (rows.Count == 0) return; var strategy = CreateTableStrategy(rows); strategy.Process(table, rows, profiles); } private static ITableStrategy CreateTableStrategy(List rows) { // retire-1 format: 1 row, 1 cell, 1 paragraph if (IsSingleParagraphFormat(rows)) return new SingleParagraphTableStrategy(); // retire-3 format: 2+ rows (header + template) return new MultiRowTableStrategy(); } private static bool IsSingleParagraphFormat(List rows) => rows.Count == 1 && rows[0].Elements().Count() == 1 && rows[0].Elements().First().Elements().Count() == 1; #endregion #region PDF Conversion private async Task ConvertToPdfAsync(byte[] docxBytes) { var tempDocx = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.docx"); var tempPdf = Path.ChangeExtension(tempDocx, ".pdf"); try { await File.WriteAllBytesAsync(tempDocx, docxBytes); await ConvertToPdfInternalAsync(tempDocx, tempPdf); return await File.ReadAllBytesAsync(tempPdf); } finally { if (File.Exists(tempDocx)) File.Delete(tempDocx); if (File.Exists(tempPdf)) File.Delete(tempPdf); } } private async Task ConvertToPdfInternalAsync(string docxPath, string pdfPath) { try { var useDocker = _configuration.GetValue("LibreOffice:UseDocker", false); var timeout = _configuration.GetValue("LibreOffice:Timeout", 180000); if (useDocker) { await ConvertToPdfViaDockerAsync(docxPath, pdfPath, timeout); } else { // // PROD: Disabled local LibreOffice conversion // await ConvertToPdfLocallyAsync(docxPath, pdfPath, timeout); throw new NotSupportedException("LibreOffice conversion is disabled."); } } catch (Exception ex) { _logger.LogError(ex, "Error converting to PDF"); throw; } } private async Task ConvertToPdfViaDockerAsync(string docxPath, string pdfPath, int timeout) { var inputDir = _configuration["LibreOffice:InputDirectory"] ?? "/app/libreoffice/input"; var outputDir = _configuration["LibreOffice:OutputDirectory"] ?? "/app/libreoffice/output"; var fileName = Path.GetFileName(docxPath); var pdfName = Path.ChangeExtension(fileName, ".pdf"); // Ensure directories exist Directory.CreateDirectory(inputDir); Directory.CreateDirectory(outputDir); // Copy file to input folder (LibreOffice watcher will pick it up) var inputPath = Path.Combine(inputDir, fileName).Replace('\\', '/'); var outputPath = Path.Combine(outputDir, pdfName).Replace('\\', '/'); _logger.LogInformation("📤 Sending file to LibreOffice: {FileName}", fileName); await File.WriteAllBytesAsync(inputPath, await File.ReadAllBytesAsync(docxPath)); // Wait for LibreOffice to convert (file watcher handles it) var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var pollInterval = TimeSpan.FromMilliseconds(500); while (stopwatch.ElapsedMilliseconds < timeout) { if (File.Exists(outputPath)) { _logger.LogInformation("✅ PDF received: {PdfName} (took {ElapsedMs}ms)", pdfName, stopwatch.ElapsedMilliseconds); await File.WriteAllBytesAsync(pdfPath, await File.ReadAllBytesAsync(outputPath)); // Cleanup try { if (File.Exists(outputPath)) File.Delete(outputPath); _logger.LogDebug("🗑️ Cleaned up output file: {PdfName}", pdfName); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to cleanup output file"); } return; } await Task.Delay(pollInterval); } throw new TimeoutException($"LibreOffice conversion timed out after {timeout}ms. File not found: {outputPath}"); } // // PROD: Disabled local LibreOffice conversion // private async Task ConvertToPdfLocallyAsync(string docxPath, string pdfPath, int timeout) // { // var libreOfficePath = _configuration["LibreOffice:Path"] ?? GetDefaultLibreOfficePath(); // var arguments = _configuration["LibreOffice:Arguments"] ?? "--headless --convert-to pdf --nologo --norestore"; // var outputDir = Path.GetDirectoryName(pdfPath); // if (string.IsNullOrEmpty(outputDir)) // { // throw new DirectoryNotFoundException("Output directory cannot be determined"); // } // var psi = new ProcessStartInfo // { // FileName = libreOfficePath, // Arguments = $"{arguments} --outdir \"{outputDir}\" \"{docxPath}\"", // UseShellExecute = false, // RedirectStandardOutput = true, // RedirectStandardError = true, // CreateNoWindow = true // }; // using var process = Process.Start(psi); // var exited = process.WaitForExit(timeout); // if (!exited) // { // process.Kill(entireProcessTree: true); // throw new TimeoutException($"LibreOffice conversion timed out after {timeout}ms"); // } // if (process.ExitCode != 0) // { // var error = await process.StandardError.ReadToEndAsync(); // throw new Exception($"LibreOffice conversion failed: {error}"); // } // } // // PROD: Disabled local LibreOffice path detection // private static string GetDefaultLibreOfficePath() // { // if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) // { // var possiblePaths = new[] // { // @"C:\Program Files\LibreOffice\program\soffice.exe", // @"C:\Program Files (x86)\LibreOffice\program\soffice.exe", // @"C:\Program Files\LibreOffice\program\soffice.com" // }; // return possiblePaths.FirstOrDefault(File.Exists) // ?? throw new FileNotFoundException("LibreOffice not found. Please install LibreOffice or configure the path in appsettings.json"); // } // // Linux/Docker: use default path // return "libreoffice"; // } #endregion } #region Data Processor Interfaces & Implementations internal interface IDataProcessor { void Process(Document document, Action tableFiller); } internal class DictionaryDataProcessor : IDataProcessor { private readonly dynamic _data; public DictionaryDataProcessor(dynamic data) { _data = data; } public void Process(Document document, Action tableFiller) { var keys = _data.Keys as System.Collections.ICollection; if (keys == null) return; System.Collections.IEnumerable? profiles = null; foreach (string key in keys) { if (key.Equals("profiles", StringComparison.OrdinalIgnoreCase)) { profiles = _data[key] as System.Collections.IEnumerable; continue; } var valueObj = _data[key]; if (valueObj != null && typeof(System.Collections.IEnumerable).IsAssignableFrom(valueObj.GetType()) && valueObj.GetType() != typeof(string)) { continue; } var value = valueObj?.ToString() ?? string.Empty; var placeholder = $"{{{{{key}}}}}"; TextReplacer.ReplaceAll(document, placeholder, value); } if (profiles != null) { tableFiller(document, profiles); } } } internal class ObjectDataProcessor : IDataProcessor { private readonly dynamic _data; public ObjectDataProcessor(dynamic data) { _data = data; } public void Process(Document document, Action tableFiller) { var dataType = _data.GetType(); var allProps = dataType.GetProperties(); var validProps = new List(); foreach (var p in allProps) { if (p.GetIndexParameters().Length == 0) { validProps.Add(p); } } System.Collections.IEnumerable? profiles = null; foreach (var prop in validProps) { var propType = prop.PropertyType; bool isEnumerable = typeof(System.Collections.IEnumerable).IsAssignableFrom(propType); bool isString = propType == typeof(string); if (isEnumerable && !isString) { if (prop.Name.Equals("profiles", StringComparison.OrdinalIgnoreCase)) { profiles = prop.GetValue(_data) as System.Collections.IEnumerable; } continue; } var value = prop.GetValue(_data)?.ToString() ?? string.Empty; var placeholder = $"{{{{{prop.Name}}}}}"; TextReplacer.ReplaceAll(document, placeholder, value); } if (profiles != null) { tableFiller(document, profiles); } } } #endregion #region Text Replacer internal static class TextReplacer { public static void ReplaceAll(Document document, string oldValue, string newValue) { bool found = false; // Method 1: Check within single Run foreach (var run in document.Descendants()) { var textElements = run.Elements().ToList(); if (textElements.Count == 0) continue; var combinedText = string.Concat(textElements.Select(t => t.Text)); if (combinedText.Contains(oldValue)) { found = true; var replacedText = combinedText.Replace(oldValue, newValue); textElements[0].Text = replacedText; for (int i = 1; i < textElements.Count; i++) { textElements[i].Text = string.Empty; } } } // Method 2: Check across all Runs in Paragraph foreach (var para in document.Descendants()) { var allRuns = para.Elements().ToList(); if (allRuns.Count == 0) continue; var combinedParaText = string.Concat(allRuns.SelectMany(r => r.Elements().Select(t => t.Text))); if (combinedParaText.Contains(oldValue)) { found = true; var replacedText = combinedParaText.Replace(oldValue, newValue); var firstRunTexts = allRuns[0].Elements().ToList(); if (firstRunTexts.Count > 0) { firstRunTexts[0].Text = replacedText; for (int i = 1; i < firstRunTexts.Count; i++) { firstRunTexts[i].Text = string.Empty; } } for (int i = 1; i < allRuns.Count; i++) { foreach (var t in allRuns[i].Elements()) { t.Text = string.Empty; } } } } // Fallback: Check individual Text elements if (!found) { foreach (var text in document.Descendants()) { if (!string.IsNullOrEmpty(text.Text) && text.Text.Contains(oldValue)) { found = true; text.Text = text.Text.Replace(oldValue, newValue); } } } } public static void ReplaceInRow(TableRow row, string oldValue, string newValue) { bool found = false; foreach (var cell in row.Descendants()) { foreach (var para in cell.Elements()) { found = ReplaceInParagraph(para, oldValue, newValue) || found; } } // Fallback: Check individual Text elements if (!found) { foreach (var text in row.Descendants()) { if (!string.IsNullOrEmpty(text.Text) && text.Text.Contains(oldValue)) { found = true; text.Text = text.Text.Replace(oldValue, newValue); } } } } public static bool ReplaceInParagraph(Paragraph paragraph, string oldValue, string newValue) { bool found = false; var allTexts = paragraph.Descendants().ToList(); if (allTexts.Count == 0) return false; var combinedParaText = string.Concat(allTexts.Select(t => t.Text)); if (combinedParaText.Contains(oldValue)) { found = true; var replacedText = combinedParaText.Replace(oldValue, newValue); allTexts[0].Text = replacedText; for (int i = 1; i < allTexts.Count; i++) { allTexts[i].Text = string.Empty; } } // Fallback: Check individual Text elements if (!found) { foreach (var text in allTexts) { if (!string.IsNullOrEmpty(text.Text) && text.Text.Contains(oldValue)) { found = true; text.Text = text.Text.Replace(oldValue, newValue); } } } return found; } } #endregion #region Table Strategy Interfaces & Implementations internal interface ITableStrategy { void Process(Table table, List rows, System.Collections.IEnumerable profiles); } internal class SingleParagraphTableStrategy : ITableStrategy { public void Process(Table table, List rows, System.Collections.IEnumerable profiles) { var cell = rows[0].Elements().First(); var templatePara = cell.Elements().First(); var profileList = profiles.Cast().ToList(); foreach (var profile in profileList) { var props = profile.GetType() .GetProperties() .Where(p => p.GetIndexParameters().Length == 0) .ToList(); var newPara = (Paragraph)templatePara.CloneNode(true); foreach (var prop in props) { var value = prop.GetValue(profile)?.ToString() ?? string.Empty; var placeholder = $"{{{{{prop.Name}}}}}"; TextReplacer.ReplaceInParagraph(newPara, placeholder, value); } cell.Append(newPara); } templatePara.Remove(); } } internal class MultiRowTableStrategy : ITableStrategy { public void Process(Table table, List rows, System.Collections.IEnumerable profiles) { var templateRowIndex = rows.Count >= 2 ? 1 : 0; var templateRow = rows[templateRowIndex]; templateRow.Remove(); var profileList = profiles.Cast().ToList(); // Process header row if exists if (rows.Count >= 2) { ProcessHeaderRow(rows[0], profileList); } // Process template rows foreach (var profile in profileList) { var newRow = (TableRow)templateRow.CloneNode(true); var props = profile.GetType() .GetProperties() .Where(p => p.GetIndexParameters().Length == 0) .ToList(); foreach (var prop in props) { var value = prop.GetValue(profile)?.ToString() ?? string.Empty; var placeholder = $"{{{{{prop.Name}}}}}"; TextReplacer.ReplaceInRow(newRow, placeholder, value); } table.AppendChild(newRow); } } private static void ProcessHeaderRow(TableRow headerRow, List profileList) { var firstProfile = profileList.FirstOrDefault(); if (firstProfile == null) return; var props = firstProfile.GetType() .GetProperties() .Where(p => p.GetIndexParameters().Length == 0) .ToList(); foreach (var prop in props) { var value = prop.GetValue(firstProfile)?.ToString() ?? string.Empty; var placeholder = $"{{{{{prop.Name}}}}}"; if (!string.IsNullOrWhiteSpace(value)) { TextReplacer.ReplaceInRow(headerRow, placeholder, value); } } } } #endregion }