using System.Text.RegularExpressions;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Fast.Shared.Logic.FileService;
using Serilog;
using static Fast.Shared.Models.Enums;

namespace Fast.Shared.Logic.Package;

/// Base class for generating Word documents from templates using OpenXML.
/// Uses FileService for storage-agnostic file operations (local or SharePoint).
public abstract partial class DocBaseOpenXml : IDisposable
{
    protected readonly Dictionary<string, List<Run>> docRunsByKey = new(StringComparer.OrdinalIgnoreCase);
    protected readonly Document doc;
    protected readonly WordprocessingDocument wordprocessingDocument;
    protected readonly MainDocumentPart mainDocumentPart;
    protected readonly IFileService fileService;
    protected readonly string filesFolderName;
    protected readonly string templatesFolderName;
    protected readonly string signaturesFolderName;
    protected readonly string logosFolderName;
    private readonly MemoryStream documentMemoryStream;
    private bool disposed = false;

    /// Creates a new DocBaseOpenXml instance using FileService for all file operations.
    public DocBaseOpenXml(
        IFileService fileService,
        string filesFolderName,
        string templatesFolderName,
        string signaturesFolderName,
        string logosFolderName,
        bool ignoreMissingKeys)
    {
        this.fileService = fileService ?? throw new ArgumentNullException(nameof(fileService));
        this.filesFolderName = filesFolderName;
        this.templatesFolderName = templatesFolderName;
        this.signaturesFolderName = signaturesFolderName;
        this.logosFolderName = logosFolderName;

        documentMemoryStream = new MemoryStream();

        // Load template via FileService (supports both local and SharePoint)
        var templateBytes = fileService.ReadAllBytes(templatesFolderName, TemplateFileName, useSharding: false);
        documentMemoryStream.Write(templateBytes, 0, templateBytes.Length);
        documentMemoryStream.Position = 0;

        wordprocessingDocument = WordprocessingDocument.Open(documentMemoryStream, true);
        mainDocumentPart = wordprocessingDocument.MainDocumentPart!;
        doc = mainDocumentPart.Document!;

        FixTableCellParagraphSpacing(mainDocumentPart);

        var docKeys = GetDocKeys();
        FillDocKeyRuns(docKeys, ignoreMissingKeys);
    }

    protected abstract HashSet<string> GetDocKeys();

    protected abstract string TemplateFileName { get; }

    public void SetText(string key, string textToSet, bool removeRowIfEmptyText = false)
    {
        textToSet ??= string.Empty;

        if (!docRunsByKey.TryGetValue(key, out List<Run>? runsForKey))
            return;

        var tagToReplace = $"«{key}»";
        foreach (var run in runsForKey)
        {
            var textElementsInRun = run.Elements<Text>().ToList();
            if (textElementsInRun.Count != 0)
            {
                foreach (var textElement in textElementsInRun)
                {
                    if (textElement.Text != null && textElement.Text.Contains(tagToReplace, StringComparison.OrdinalIgnoreCase))
                        textElement.Text = textElement.Text.Replace(tagToReplace, textToSet, StringComparison.OrdinalIgnoreCase);
                }
            }

            if (removeRowIfEmptyText && string.IsNullOrWhiteSpace(textToSet))
                RemoveParentRowOfRun(run);
        }
    }

    protected static void SetTableCellText(
        TableCell tableCell,
        string text,
        bool bold = false,
        JustificationValues? alignment = null)
    {
        // find or create the first paragraph in the cell
        var paragraph = tableCell.Elements<Paragraph>().FirstOrDefault();
        if (paragraph == null)
        {
            paragraph = new Paragraph();
            tableCell.AppendChild(paragraph);
        }

        // find or create the first run in the paragraph
        var run = paragraph.Elements<Run>().FirstOrDefault();
        if (run == null)
        {
            run = new Run();
            paragraph.AppendChild(run);
        }

        // set paragraph alignment if specified
        if (alignment != null)
        {
            var paragraphProperties = paragraph.Elements<ParagraphProperties>().FirstOrDefault();
            if (paragraphProperties == null)
            {
                paragraphProperties = new ParagraphProperties();
                paragraph.PrependChild(paragraphProperties);
            }

            var justification = paragraphProperties.Elements<Justification>().FirstOrDefault();
            if (justification == null)
            {
                justification = new Justification();
                paragraphProperties.AppendChild(justification);
            }
            justification.Val = alignment.Value;
        }

        // set bold formatting if specified
        if (bold)
        {
            var runProperties = run.Elements<RunProperties>().FirstOrDefault();
            if (runProperties == null)
            {
                runProperties = new RunProperties();
                run.PrependChild(runProperties);
            }

            var boldElement = runProperties.Elements<Bold>().FirstOrDefault();
            if (boldElement == null)
            {
                boldElement = new Bold();
                runProperties.AppendChild(boldElement);
            }
        }

        // set text content
        text ??= "";
        var textElement = run.Elements<Text>().FirstOrDefault();
        if (textElement == null)
        {
            textElement = new Text();
            run.AppendChild(textElement);
        }
        textElement.Text = text;
    }

    private static void RemoveParentRowOfRun(Run run)
    {
        var parentRow = GetParentRowOfRun(run);
        parentRow?.Remove();
    }

    private static TableRow? GetParentRowOfRun(Run run)
    {
        var parentNode = run.Parent;
        while (parentNode != null)
        {
            if (parentNode is TableRow tableRow)
            {
                return tableRow;
            }
            parentNode = parentNode.Parent;
        }

        return null;
    }

    /// Sets an image in the document by replacing the placeholder image with the specified file.
    protected void SetImage(string folderName, string fileName, string imageNameOrTitle, string uniqueId)
    {
        if (string.IsNullOrEmpty(folderName) || string.IsNullOrEmpty(fileName) || string.IsNullOrEmpty(imageNameOrTitle) || mainDocumentPart?.Document == null)
            return;

        // Remove trailing period from the end of file names since SharePoint doesn't allow files to end with a period
        // E.G. `John Doe Jr..jpg` is not allowed
        var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
        fileName = fileNameWithoutExtension.Trim().TrimEnd('.') + Path.GetExtension(fileName);

        var matchingDrawing = (
            from drawing in mainDocumentPart.Document.Descendants<Drawing>()
            let docProps = drawing.Descendants<DocumentFormat.OpenXml.Drawing.Wordprocessing.DocProperties>().FirstOrDefault()
            where docProps?.Name?.Value == imageNameOrTitle || docProps?.Title?.Value == imageNameOrTitle
            select drawing
        ).FirstOrDefault();

        if (matchingDrawing == null)
            return;

        try
        {
            var imageBytes = fileService.ReadAllBytes(folderName, fileName, useSharding: false);
            ReplaceImageInDrawing(matchingDrawing, imageBytes);
        }
        catch (Exception)
        {
            Log.Error($"Failed to set image `{folderName}/{fileName}` for {uniqueId}");
            // Try sample signature as fallback for signatures folder
            if (folderName.Contains("Signature", StringComparison.OrdinalIgnoreCase))
            {
                try
                {
                    var sampleBytes = fileService.ReadAllBytes(folderName, "Sample.jpg", useSharding: false);
                    ReplaceImageInDrawing(matchingDrawing, sampleBytes);
                }
                catch
                {
                    // Ignore if sample also not found
                }
            }
        }
    }

    protected void SetSignatureImage(string fileName, string imageNameOrTitle, string uniqueId)
    {
        SetImage(signaturesFolderName, fileName, imageNameOrTitle, uniqueId);
    }

    protected void SetLogoImage(string fileName, string imageNameOrTitle, string uniqueId)
    {
        SetImage(logosFolderName, fileName, imageNameOrTitle, uniqueId);
    }

    private void ReplaceImageInDrawing(Drawing drawing, byte[] imageBytes)
    {
        var blip = drawing.Descendants<DocumentFormat.OpenXml.Drawing.Blip>().FirstOrDefault();
        var imagePartId = blip?.Embed?.Value;
        if (string.IsNullOrEmpty(imagePartId))
            return;

        OpenXmlPart? part = null;
        try
        {
            part = mainDocumentPart.GetPartById(imagePartId);
        }
        catch
        {
            // GetPartById() will throw if it can't find the part ID.
            // do nothing if it can't find it.
        }

        if (part == null)
            return;

        if (part is ImagePart imagePart)
        {
            // replace the content of the existing ImagePart
            using var imageStream = new MemoryStream(imageBytes);
            imagePart.FeedData(imageStream);
        }
    }

    protected static string FormatNum(decimal? num, NumFormat format)
    {
        if (!num.HasValue)
            return "";

        //manually specifying the formats
        //otherwise NumberFormatInfo may vary with OS and OS settings which result in different formats
        string result = format switch
        {
            NumFormat.n0 => num.Value.ToString("#,##0;(#,##0);0"),
            NumFormat.n1 => num.Value.ToString("#,##0.0;(#,##0.0);0.0"),
            NumFormat.n2 => num.Value.ToString("#,##0.00;(#,##0.00);0.00"),
            NumFormat.n3 => num.Value.ToString("#,##0.000;(#,##0.000);0.000"),
            NumFormat.n4 => num.Value.ToString("#,##0.0000;(#,##0.0000);0.0000"),
            NumFormat.n5 => num.Value.ToString("#,##0.00000;(#,##0.00000);0.00000"),
            NumFormat.c2 => num.Value.ToString("$ #,##0.00;$ (#,##0.00);$ 0.00"),
            NumFormat.c3 => num.Value.ToString("$ #,##0.000;$ (#,##0.000);$ 0.000"),
            NumFormat.c4 => num.Value.ToString("$ #,##0.0000;$ (#,##0.0000);$ 0.0000"),
            NumFormat.c5 => num.Value.ToString("$ #,##0.00000;$ (#,##0.00000);$ 0.00000"),
            NumFormat.p2 => num.Value.ToString("#,##0.00%;(#,##0.00%);0.00%"),
            NumFormat.p3 => num.Value.ToString("#,##0.000%;(#,##0.000%);0.000%"),
            NumFormat.p4 => num.Value.ToString("#,##0.0000%;(#,##0.0000%);0.0000%"),
            NumFormat.p5 => num.Value.ToString("#,##0.00000%;(#,##0.00000%);0.00000%"),
            NumFormat.p6 => num.Value.ToString("#,##0.000000%;(#,##0.000000%);0.000000%"),
            _ => throw new Exception($"Undefined format code: {format}")
        };

        return result;
    }
    [GeneratedRegex("«.*?»")]
    private static partial Regex TagRegex();
    private void FillDocKeyRuns(HashSet<string> keysToFind, bool ignoreMissingKeys)
    {
        if (mainDocumentPart?.Document?.Body == null)
            return;

        // process elements in the main document body
        var bodyParagraphs = mainDocumentPart.Document.Body.Descendants<Paragraph>();
        if (bodyParagraphs != null)
            ProcessParagraphsForKeys(bodyParagraphs, keysToFind);

        // process elements in headers
        foreach (var headerPart in mainDocumentPart.HeaderParts)
        {
            var paragraphs = headerPart.Header?.Descendants<Paragraph>();
            if (paragraphs != null)
                ProcessParagraphsForKeys(paragraphs, keysToFind);
        }

        // process elements in footers
        foreach (var footerPart in mainDocumentPart.FooterParts)
        {
            var paragraphs = footerPart.Footer?.Descendants<Paragraph>();
            if (paragraphs != null)
                ProcessParagraphsForKeys(paragraphs, keysToFind);
        }

        if (!ignoreMissingKeys)
        {
            foreach (string key in keysToFind)
            {
                if (!docRunsByKey.ContainsKey(key))
                    throw new Exception($"Could not find key {{{key}}} in template file.");
            }
        }
    }

    private void ProcessParagraphsForKeys(IEnumerable<Paragraph> paragraphs, HashSet<string> keysToFind)
    {
        // combine tags that might be split across multiple runs
        var allRuns = paragraphs.SelectMany(x => x.Elements<Run>()).ToList();
        CombineRunTags(allRuns);

        foreach (var paragraph in paragraphs)
        {
            var runsInParagraph = paragraph.Elements<Run>().ToList();
            if (runsInParagraph.Count == 0)
                continue;

            foreach (var run in runsInParagraph)
            {
                var runText = GetRunTextContent(run);
                if (string.IsNullOrEmpty(runText))
                    continue;

                var runKeyMatches = TagRegex().Matches(runText);
                foreach (var match in runKeyMatches.Cast<Match>())
                {
                    var runKey = match.Value.Replace("«", string.Empty).Replace("»", string.Empty);
                    if (keysToFind.Contains(runKey))
                    {
                        if (!docRunsByKey.TryGetValue(runKey, out _))
                            docRunsByKey.Add(runKey, new List<Run>());
                        docRunsByKey[runKey].Add(run);
                    }
                }
            }
        }
    }

    //sometimes tags are split between runs
    //this combines runs that were split into a single run
    //this makes it easier to replace a tag with new text
    //the old runs are removed
    protected static void CombineRunTags(List<Run> runs)
    {
        Run? replacementRun = null;
        var runsToRemove = new List<Run>();

        foreach (var run in runs)
        {
            var runTextElement = GetFirstTextElement(run);
            var runText = runTextElement.Text ?? string.Empty;

            //if begin tag but no end tag after it
            var lastIndexOfBeginTag = runText.LastIndexOf('«');
            var lastIndexOfEndTag = runText.LastIndexOf('»');
            if (lastIndexOfBeginTag > lastIndexOfEndTag)
            {
                replacementRun = run; //set this run as the replacement run
            }
            //if we are mid-tag
            else if (replacementRun != null && !runText.Contains('»'))
            {
                var replacementRunTextElement = GetFirstTextElement(replacementRun);
                replacementRunTextElement.Text += runText; //combine the text of this run with the previous run
                runsToRemove.Add(run);
            }
            //if end tag without begin tag
            else if (replacementRun != null && runText.Contains('»'))
            {
                var replacementRunTextElement = GetFirstTextElement(replacementRun);
                replacementRunTextElement.Text += runText; //combine the text of this run with the previous run
                runsToRemove.Add(run);
                replacementRun = null; //reset the replacement run since we found an end tag
            }
        }

        foreach (var runToRemove in runsToRemove)
        {
            runToRemove.Remove();
        }
    }

    private static Text GetFirstTextElement(Run run)
    {
        var textElement = run.Elements<Text>().FirstOrDefault();
        if (textElement == null)
        {
            textElement = new Text();
            run.AppendChild(textElement);
        }
        return textElement;
    }

    private static string GetRunTextContent(Run run)
    {
        if (run == null)
            return string.Empty;

        var sb = new StringBuilder();
        var textElements = run.Elements<Text>();
        foreach (var textElement in textElements)
        {
            if (textElement?.Text != null)
                sb.Append(textElement.Text);
        }
        return sb.ToString();
    }

    /// Writes an exception text file via FileService.
    protected string WriteExceptionToFile(Exception exception, string dealNum)
    {
        var fileName = Util.String.GetNewGuid() + ".txt";
        var errorText = GetErrorText(exception, dealNum);
        var errorBytes = Encoding.UTF8.GetBytes(errorText);

        fileService.WriteAllBytes(filesFolderName, fileName, errorBytes, useSharding: false);
        return fileName;
    }

    /// Writes an exception text file via FileService asynchronously.
    protected async Task<string> WriteExceptionToFileAsync(Exception exception, string dealNum)
    {
        var fileName = Util.String.GetNewGuid() + ".txt";
        var errorText = GetErrorText(exception, dealNum);
        var errorBytes = Encoding.UTF8.GetBytes(errorText);

        await fileService.WriteAllBytesAsync(filesFolderName, fileName, errorBytes, useSharding: false);
        return fileName;
    }

    private static string GetErrorText(Exception exception, string dealNum)
    {
        var messagePrefix = $"Deal #: {dealNum}{Environment.NewLine}";
        var errorMsg = Util.String.GetExceptionMessage(exception);
        return messagePrefix + errorMsg;
    }

    /// Saves the modified document via FileService.
    public string Save(string outputFileName, bool useSharding = true)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        if (wordprocessingDocument == null)
            throw new InvalidOperationException("document is not loaded or has been disposed.");

        var docStream = SaveToMemoryStream();
        var docBytes = docStream.ToArray();

        fileService.WriteAllBytes(filesFolderName, outputFileName, docBytes, useSharding);
        return outputFileName;
    }

    /// Saves the modified document via FileService asynchronously.
    public async Task<string> SaveAsync(string outputFileName, bool useSharding = true)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        if (wordprocessingDocument == null)
            throw new InvalidOperationException("document is not loaded or has been disposed.");

        var docStream = SaveToMemoryStream();
        var docBytes = docStream.ToArray();

        await fileService.WriteAllBytesAsync(filesFolderName, outputFileName, docBytes, useSharding);
        return outputFileName;
    }

    public MemoryStream SaveToMemoryStream()
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        if (wordprocessingDocument == null)
            throw new InvalidOperationException("document is not loaded or has been disposed.");

        // flush changes to memory stream
        doc.Save();
        wordprocessingDocument.Save();

        // clone to a new MemoryStream
        var outputStream = new MemoryStream();
        using (var clonedDocument = wordprocessingDocument.Clone(outputStream))
            clonedDocument.Save();

        outputStream.Position = 0;
        return outputStream;
    }

    /// Fixes paragraph spacing within table cells to prevent LibreOffice from rendering double-tall lines.
    /// LibreOffice handles w:spacing (before/after) differently than Word in table cells. It often adds the space whereas
    /// Word may collapse/ignore it. This is essential since Gotenberg PDF conversion uses LibreOffice for rendering.
    private static void FixTableCellParagraphSpacing(MainDocumentPart mainPart)
    {
        if (mainPart.Document?.Body != null)
            FixElementTableCellSpacing(mainPart.Document.Body);

        foreach (var headerPart in mainPart.HeaderParts)
        {
            if (headerPart.Header != null)
                FixElementTableCellSpacing(headerPart.Header);
        }

        foreach (var footerPart in mainPart.FooterParts)
        {
            if (footerPart.Footer != null)
                FixElementTableCellSpacing(footerPart.Footer);
        }
    }

    private static void FixElementTableCellSpacing(OpenXmlElement rootElement)
    {
        // Find all table cells in this element (Body, Header, or Footer)
        var tableCells = rootElement.Descendants<TableCell>();

        foreach (var cell in tableCells)
        {
            var paragraphs = cell.Elements<Paragraph>();
            foreach (var para in paragraphs)
            {
                // Get or create paragraph properties
                var pPr = para.GetFirstChild<ParagraphProperties>();
                if (pPr == null)
                {
                    pPr = new ParagraphProperties();
                    para.InsertAt(pPr, 0);
                }

                // Get or create spacing element
                var spacing = pPr.GetFirstChild<SpacingBetweenLines>();
                if (spacing == null)
                {
                    spacing = new SpacingBetweenLines();
                    pPr.AppendChild(spacing);
                }

                spacing.Before = "0";
                spacing.After = "0";
            }
        }
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                documentMemoryStream?.Dispose();
                wordprocessingDocument?.Dispose();
            }
            disposed = true;
        }
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
}
