// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0

using System.Text;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
using OfficeCli.Core;
using C = DocumentFormat.OpenXml.Drawing.Charts;
using Drawing = DocumentFormat.OpenXml.Drawing;
using XDR = DocumentFormat.OpenXml.Drawing.Spreadsheet;

namespace OfficeCli.Handlers;

public partial class ExcelHandler
{
    /// <summary>
    /// Render all charts in a worksheet as SVG elements, respecting anchor positions.
    /// Charts with overlapping row ranges are placed side-by-side using flex layout.
    /// </summary>
    private void RenderSheetCharts(StringBuilder sb, WorksheetPart worksheetPart)
    {
        var charts = CollectSheetCharts(worksheetPart);
        foreach (var (_, _, _, _, _, html) in charts)
            sb.Append(html);
    }

    /// <summary>
    /// Pre-render all charts and return them with their anchor row/col positions.
    /// Charts with overlapping row ranges are grouped into flex rows.
    /// </summary>
    private List<(int fromRow, int toRow, int fromCol, int toCol, double colOffsetPt, string html)> CollectSheetCharts(WorksheetPart worksheetPart, string sheetName = "")
    {
        var result = new List<(int fromRow, int toRow, int fromCol, int toCol, double colOffsetPt, string html)>();
        var drawingsPart = worksheetPart.DrawingsPart;
        if (drawingsPart?.WorksheetDrawing == null) return result;

        var chartFrames = drawingsPart.WorksheetDrawing
            .Descendants<XDR.GraphicFrame>()
            .Where(gf => gf.Descendants<C.ChartReference>().Any() || IsExtendedChartFrame(gf))
            .ToList();

        if (chartFrames.Count == 0) return result;

        // Build GF → 1-based chart index map (document order, same as GetExcelCharts)
        var gfIndexMap = new Dictionary<XDR.GraphicFrame, int>();
        for (int i = 0; i < chartFrames.Count; i++) gfIndexMap[chartFrames[i]] = i + 1;

        var chartAnchors = chartFrames.Select(gf =>
        {
            var anchor = gf.Parent as XDR.TwoCellAnchor;
            int fromRow = 0, toRow = 0, fromCol = 0, toCol = 0;
            if (anchor?.FromMarker != null && anchor?.ToMarker != null)
            {
                int.TryParse(anchor.FromMarker.RowId?.Text, out fromRow);
                int.TryParse(anchor.ToMarker.RowId?.Text, out toRow);
                int.TryParse(anchor.FromMarker.ColumnId?.Text, out fromCol);
                int.TryParse(anchor.ToMarker.ColumnId?.Text, out toCol);
            }
            return (gf, fromRow, toRow, fromCol, toCol);
        }).OrderBy(x => x.fromRow).ThenBy(x => x.fromCol).ToList();

        // Each chart gets its own overlay (no flex grouping) so drag-to-move works independently
        foreach (var (gf, fromRow, toRow, fromCol, toCol) in chartAnchors)
        {
            var chartSb = new StringBuilder();
            RenderExcelChart(chartSb, gf, drawingsPart, worksheetPart, sheetName, gfIndexMap.GetValueOrDefault(gf));
            // Thread the anchor's partial-column EMU offset to the overlay loop. Its
            // own column-sum (which is hidden-column- and sheet-default-width-aware)
            // drops this sub-column remainder, leaving the card a fraction of a
            // column narrow vs Excel; the loop adds it back.
            result.Add((fromRow, toRow, fromCol, toCol, ChartColOffsetPt(gf), chartSb.ToString()));
        }

        return result;
    }

    /// <summary>
    /// Partial-column EMU offset of a chart's TwoCellAnchor, in points:
    /// (toColumnOffset − fromColumnOffset) — the fraction of the from/to columns
    /// the card starts/ends inside. The overlay loop adds this to its whole-column
    /// sum so the card width matches Excel's sub-column anchor. Returns 0 when the
    /// frame is not a TwoCellAnchor (no sub-column geometry to recover).
    /// </summary>
    private static double ChartColOffsetPt(XDR.GraphicFrame gf)
    {
        var anchor = gf.Parent as XDR.TwoCellAnchor;
        var from = anchor?.FromMarker;
        var to = anchor?.ToMarker;
        if (from == null || to == null) return 0;
        var fromOff = long.TryParse(from.ColumnOffset?.Text, out var fco) ? fco : 0;
        var toOff = long.TryParse(to.ColumnOffset?.Text, out var tco) ? tco : 0;
        return (toOff - fromOff) / EmuConverter.EmuPerPointF;
    }

    private void RenderExcelChart(StringBuilder sb, XDR.GraphicFrame gf,
        DrawingsPart drawingsPart, WorksheetPart worksheetPart,
        string sheetName = "", int chartIdx = 0)
    {
        // cx:chart (extended) path — histogram / funnel / treemap / sunburst /
        // boxWhisker. Delegate to the cx-aware extractor and shared renderer.
        if (IsExtendedChartFrame(gf))
        {
            RenderExcelCxChart(sb, gf, drawingsPart, worksheetPart, sheetName, chartIdx);
            return;
        }

        // 1. Get chart reference and load ChartPart
        var chartRef = gf.Descendants<C.ChartReference>().FirstOrDefault();
        if (chartRef?.Id?.Value == null) return;

        C.Chart? chart;
        C.PlotArea? plotArea;
        try
        {
            var chartPart = (ChartPart)drawingsPart.GetPartById(chartRef.Id.Value);
            chart = chartPart.ChartSpace?.GetFirstChild<C.Chart>();
            plotArea = chart?.GetFirstChild<C.PlotArea>();
            if (plotArea == null) return;
        }
        catch { return; }

        // 2. Read chart data using shared ChartHelper
        var chartType = ChartHelper.DetectChartType(plotArea) ?? "bar";
        var categories = ChartHelper.ReadCategories(plotArea) ?? [];
        var seriesList = ChartHelper.ReadAllSeries(plotArea);
        if (seriesList.Count == 0) return;

        // 2b. Resolve series names from cell references when strCache is missing
        if (seriesList.Any(s => s.name == "?"))
        {
            var nameSerEls = plotArea.Descendants<OpenXmlCompositeElement>()
                .Where(e => e.LocalName == "ser" && e.Parent != null &&
                    (e.Parent.LocalName.Contains("Chart") || e.Parent.LocalName.Contains("chart")))
                .ToList();
            for (int i = 0; i < seriesList.Count && i < nameSerEls.Count; i++)
            {
                if (seriesList[i].name != "?") continue;
                var strRef = nameSerEls[i].GetFirstChild<C.SeriesText>()
                    ?.Descendants<C.StringReference>().FirstOrDefault();
                var formula = strRef?.GetFirstChild<C.Formula>()?.Text;
                if (!string.IsNullOrEmpty(formula))
                {
                    var resolved = ReadCellRangeAsStrings(formula);
                    if (resolved != null && resolved.Length > 0)
                        seriesList[i] = (resolved[0], seriesList[i].values);
                }
            }
        }

        // 2c. Resolve cell references when cache is missing (chart references other sheets)
        var needsCatResolve = categories.Length == 0;
        var needsValResolve = seriesList.All(s => s.values.Length == 0);
        if (needsCatResolve || needsValResolve)
        {
            ResolveChartDataFromCells(plotArea, ref categories, ref seriesList, needsCatResolve, needsValResolve);
            if (seriesList.All(s => s.values.Length == 0)) return;
        }

        // 3. Extract all chart metadata via shared helper
        var info = ChartSvgRenderer.ExtractChartInfo(plotArea, chart);
        // Override with locally-resolved data (Excel cell resolution may have updated categories/series).
        // NOTE: seriesList here comes from Excel-specific extraction that may still include
        // reference-line overlay series — re-apply the shared filter so they are not drawn
        // as an extra bar/column segment on top of the real data.
        info.ChartType = chartType;
        info.Categories = categories;
        info.Series = ChartSvgRenderer.FilterReferenceLineSeries(plotArea, seriesList);
        if (info.Series.Count == 0) return;
        // Ensure colors match series count (ExtractChartInfo may have extracted for a different count)
        while (info.Colors.Count < info.Series.Count)
            info.Colors.Add(ChartSvgRenderer.FallbackColors[info.Colors.Count % ChartSvgRenderer.FallbackColors.Length]);
        if (info.Colors.Count > info.Series.Count && !info.ChartType.Contains("pie") && !info.ChartType.Contains("doughnut"))
            info.Colors = info.Colors.Take(info.Series.Count).ToList();

        // 4. Estimate chart dimensions from TwoCellAnchor using actual column widths
        var colWidths = GetColumnWidths(GetSheet(worksheetPart));
        var (widthPt, heightPt) = EstimateChartSize(gf, colWidths);

        // 5. Create renderer — colors from OOXML with Excel-appropriate fallbacks
        var renderer = new ChartSvgRenderer
        {
            ThemeAccentColors = ChartSvgRenderer.BuildThemeAccentColors(GetExcelThemeColors()),
            ValueColor = info.ValFontColor != null ? ChartSvgRenderer.CssHexColor(info.ValFontColor) : "#333",
            CatColor = info.CatFontColor != null ? ChartSvgRenderer.CssHexColor(info.CatFontColor) : "#555",
            AxisColor = info.ValFontColor != null ? ChartSvgRenderer.CssHexColor(info.ValFontColor) : "#666",
            GridColor = info.GridlineColor != null ? ChartSvgRenderer.CssHexColor(info.GridlineColor) : "#ddd",
            AxisLineColor = info.AxisLineColor != null ? ChartSvgRenderer.CssHexColor(info.AxisLineColor) : "#999",
            ValFontPx = info.ValFontPx,
            CatFontPx = info.CatFontPx
        };

        // 6. Build SVG
        var svgW = Math.Max(widthPt, 225);
        var svgH = Math.Max(heightPt, 150);
        // Title/legend height from actual font sizes
        var titleFontPt = 10.0;
        if (!string.IsNullOrEmpty(info.TitleFontSize) && double.TryParse(info.TitleFontSize.Replace("pt", ""), out var tfp))
            titleFontPt = tfp;
        var titleH = string.IsNullOrEmpty(info.Title) ? 0 : (int)(titleFontPt * 1.6 + 8);
        var legendFontPt = 8.0;
        if (!string.IsNullOrEmpty(info.LegendFontSize) && double.TryParse(info.LegendFontSize.Replace("pt", ""), out var lfp))
            legendFontPt = lfp;
        // Legend position drives layout. A left/right legend sits BESIDE the plot
        // (flex row) — it takes width, not height; top/bottom legends take height.
        var legendSide = info.HasLegend && info.LegendPos is "r" or "l";
        var legendTop  = info.HasLegend && info.LegendPos is "t" or "tr";
        var legendH = (info.HasLegend && !legendSide) ? (int)(legendFontPt * 1.6 + 12) : 0;
        var chartSvgH = svgH - titleH - legendH;
        if (chartSvgH < 80) return;

        // For a side legend, shrink the plot's viewBox to the actual plot area
        // (full width minus an estimated legend width) so the meet-fit fills the
        // box without letterboxing.
        var plotW = svgW;
        if (legendSide)
        {
            var labels = info.ChartType.Contains("pie") || info.ChartType.Contains("doughnut")
                ? info.Categories : info.Series.Select(s => s.name).ToArray();
            var maxChars = labels.Length > 0 ? labels.Max(l => (l ?? "").Length) : 6;
            var legendWpt = (int)((28 + maxChars * 0.5 * legendFontPt * 1.333) * 0.75);
            plotW = Math.Max(svgW - legendWpt, 160);
        }

        var bgStyle = info.ChartFillColor != null ? $"background:#{info.ChartFillColor};" : "";
        var chartDataPath = chartIdx > 0 && !string.IsNullOrEmpty(sheetName) ? $" data-path=\"/{HtmlEncode(sheetName)}/chart[{chartIdx}]\"" : "";
        // height:100% + flex column makes the chart FILL its anchor box: the plot
        // grows into the height left after the title/legend instead of being sized
        // from its width alone (which left a gap below the chart).
        sb.AppendLine($"<div class=\"chart-container\"{chartDataPath} style=\"max-width:max({svgW}pt,100%);flex:1;min-width:200pt;height:100%;display:flex;flex-direction:column;{bgStyle}\">");

        var titleColor = info.TitleFontColor != null ? ChartSvgRenderer.CssHexColor(info.TitleFontColor) : "#333";
        if (!string.IsNullOrEmpty(info.Title))
            sb.AppendLine($"  <div style=\"flex-shrink:0;text-align:center;font-size:{info.TitleFontSize};font-weight:bold;padding:6px 0;color:{titleColor}\">{ChartSvgRenderer.BuildTitleInnerHtml(info, titleColor, info.TitleBold, titleFontPt)}</div>");

        var legendColor = info.LegendFontColor != null ? ChartSvgRenderer.CssHexColor(info.LegendFontColor) : "#555";

        if (legendTop)
            renderer.RenderLegendHtml(sb, info, legendColor);

        // The plot area takes the remaining height (flex:1). Side legends wrap it
        // in a row [plot | legend]; otherwise the plot fills directly.
        if (legendSide)
        {
            var flexDir = info.LegendPos == "l" ? "row-reverse" : "row";
            sb.AppendLine($"  <div style=\"flex:1;min-height:0;display:flex;flex-direction:{flexDir};align-items:center;gap:8px\">");
        }

        var svgFill = legendSide ? "flex:1;min-width:0;height:100%" : "flex:1;min-height:0;width:100%";
        sb.AppendLine($"  <svg viewBox=\"0 0 {plotW} {chartSvgH}\" style=\"{svgFill}\" preserveAspectRatio=\"xMidYMid meet\">");

        renderer.RenderChartSvgContent(sb, info, plotW, chartSvgH);

        sb.AppendLine("  </svg>");

        if (legendSide)
        {
            renderer.RenderLegendHtml(sb, info, legendColor);
            sb.AppendLine("  </div>");
        }
        else if (!legendTop)
        {
            renderer.RenderLegendHtml(sb, info, legendColor);
        }

        renderer.RenderDataTableHtml(sb, info);

        sb.AppendLine("</div>");
    }

    /// <summary>
    /// Estimate chart size from the TwoCellAnchor parent, using actual column widths when available.
    /// </summary>
    private static (int widthPt, int heightPt) EstimateChartSize(XDR.GraphicFrame gf,
        Dictionary<int, double>? colWidths = null)
    {
        var anchor = gf.Parent as XDR.TwoCellAnchor;
        if (anchor == null) return (450, 263);

        var from = anchor.FromMarker;
        var to = anchor.ToMarker;
        if (from == null || to == null) return (450, 263);

        var fromCol = int.TryParse(from.ColumnId?.Text, out var fc) ? fc : 0;
        var toCol = int.TryParse(to.ColumnId?.Text, out var tc) ? tc : 0;
        var fromRow = int.TryParse(from.RowId?.Text, out var fr) ? fr : 0;
        var toRow = int.TryParse(to.RowId?.Text, out var tr) ? tr : 0;

        var fromColOff = long.TryParse(from.ColumnOffset?.Text, out var fco) ? fco : 0;
        var toColOff = long.TryParse(to.ColumnOffset?.Text, out var tco) ? tco : 0;
        var fromRowOff = long.TryParse(from.RowOffset?.Text, out var fro) ? fro : 0;
        var toRowOff = long.TryParse(to.RowOffset?.Text, out var tro) ? tro : 0;

        // Sum actual column widths; fall back to the grid's default column width
        // (≈ 44.27pt / 59px) for columns without an explicit width — matching how the
        // grid renders them. Used only for the inner SVG's max-width/aspect ratio;
        // the overlay box itself is sized by the grid-aware loop in RenderSheetTable.
        double totalWidth = 0;
        for (int c = fromCol + 1; c <= toCol; c++)
            totalWidth += (colWidths != null && colWidths.TryGetValue(c, out var w)) ? w : ExcelDefaultColWidthPt;
        totalWidth += (toColOff - fromColOff) / EmuConverter.EmuPerPointF;

        // Default row height ~15pt; offsets in EMU (1pt = 12700 EMU)
        double totalHeight = (toRow - fromRow) * 15.0 + (toRowOff - fromRowOff) / EmuConverter.EmuPerPointF;

        return ((int)Math.Max(totalWidth, 225), (int)Math.Max(totalHeight, 150));
    }

    /// <summary>
    /// Resolve chart data from actual cells when the chart XML has no cache.
    /// Parses formula references like "'Income Statement'!$B$10:$D$10" and reads cell values.
    /// </summary>
    private void ResolveChartDataFromCells(C.PlotArea plotArea,
        ref string[] categories, ref List<(string name, double[] values)> seriesList,
        bool resolveCats, bool resolveVals)
    {
        if (resolveCats)
        {
            var catRef = ChartHelper.ReadCategoriesRef(plotArea);
            if (catRef != null)
            {
                var resolved = ReadCellRangeAsStrings(catRef);
                if (resolved != null) categories = resolved;
            }
        }

        if (resolveVals)
        {
            var newSeries = new List<(string name, double[] values)>();
            foreach (var ser in plotArea.Descendants<OpenXmlCompositeElement>()
                .Where(e => e.LocalName == "ser" && e.Parent != null &&
                    (e.Parent.LocalName.Contains("Chart") || e.Parent.LocalName.Contains("chart"))))
            {
                var serText = ser.GetFirstChild<C.SeriesText>();
                var name = serText?.Descendants<C.NumericValue>().FirstOrDefault()?.Text ?? "?";

                var valRef = ChartHelper.ReadFormulaRef(ser.GetFirstChild<C.Values>())
                    ?? ChartHelper.ReadFormulaRef(ser.Elements<OpenXmlCompositeElement>()
                        .FirstOrDefault(e => e.LocalName == "yVal"));

                double[] values = [];
                if (valRef != null)
                {
                    var resolved = ReadCellRangeAsDoubles(valRef);
                    if (resolved != null) values = resolved;
                }

                newSeries.Add((name, values));
            }
            if (newSeries.Count > 0) seriesList = newSeries;
        }
    }

    /// <summary>
    /// Parse a cell range reference like "'Sheet Name'!$B$1:$D$1" and return cell values as strings.
    /// </summary>
    private string[]? ReadCellRangeAsStrings(string formula)
    {
        var (sheetData, startCol, startRow, endCol, endRow) = ParseCellRangeFormula(formula);
        if (sheetData == null) return null;

        var results = new List<string>();
        for (int r = startRow; r <= endRow; r++)
        {
            for (int c = startCol; c <= endCol; c++)
            {
                var cellRef = GetColumnLetter(c) + r;
                var cell = sheetData.Descendants<Cell>()
                    .FirstOrDefault(cl => cl.CellReference?.Value == cellRef);
                results.Add(cell != null ? GetCellDisplayValue(cell) : "");
            }
        }
        return results.ToArray();
    }

    /// <summary>
    /// Parse a cell range reference and return cell values as doubles.
    /// Uses FormulaEvaluator with cross-sheet support.
    /// </summary>
    private double[]? ReadCellRangeAsDoubles(string formula)
    {
        var (sheetData, startCol, startRow, endCol, endRow) = ParseCellRangeFormula(formula);
        if (sheetData == null) return null;

        var evaluator = new Core.FormulaEvaluator(sheetData, _doc.WorkbookPart);
        var results = new List<double>();
        for (int r = startRow; r <= endRow; r++)
        {
            for (int c = startCol; c <= endCol; c++)
            {
                var cellRef = GetColumnLetter(c) + r;
                var cell = sheetData.Descendants<Cell>()
                    .FirstOrDefault(cl => cl.CellReference?.Value == cellRef);

                double val = 0;
                if (cell != null)
                {
                    // If the cell has a formula, always evaluate — cached values may be stale
                    // (e.g. generator tools often write formulas with cachedValue=0 and expect
                    // Excel to recompute on open). Matches GetFormattedCellValue's policy.
                    if (cell.CellFormula?.Text != null)
                    {
                        val = evaluator.TryEvaluate(cell.CellFormula.Text) ?? 0;
                    }
                    else
                    {
                        var raw = cell.CellValue?.Text;
                        if (!string.IsNullOrEmpty(raw) && double.TryParse(raw,
                            System.Globalization.NumberStyles.Any,
                            System.Globalization.CultureInfo.InvariantCulture, out var v))
                        {
                            val = v;
                        }
                    }
                }
                results.Add(val);
            }
        }
        return results.ToArray();
    }

    /// <summary>
    /// Parse "'Sheet Name'!$B$1:$D$1" into (SheetData, startCol, startRow, endCol, endRow).
    /// </summary>
    private (SheetData? sheetData, int startCol, int startRow, int endCol, int endRow) ParseCellRangeFormula(string formula)
    {
        // Pattern: optional 'SheetName'! or SheetName! prefix, then cell range like $B$1:$D$1 or B1:D1
        var match = Regex.Match(formula, @"^(?:'([^']+)'|([^!]+))!\$?([A-Z]+)\$?(\d+)(?::\$?([A-Z]+)\$?(\d+))?$");
        if (!match.Success) return (null, 0, 0, 0, 0);

        var sheetName = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value;
        var startColStr = match.Groups[3].Value;
        var startRow = int.Parse(match.Groups[4].Value);
        var endColStr = match.Groups[5].Success ? match.Groups[5].Value : startColStr;
        var endRow = match.Groups[6].Success ? int.Parse(match.Groups[6].Value) : startRow;

        var startCol = ColumnLetterToIndex(startColStr);
        var endCol = ColumnLetterToIndex(endColStr);

        // Find the worksheet by name
        var workbookPart = _doc.WorkbookPart;
        if (workbookPart == null) return (null, 0, 0, 0, 0);

        var sheet = workbookPart.Workbook?.Descendants<Sheet>()
            .FirstOrDefault(s => s.Name?.Value == sheetName);
        if (sheet?.Id?.Value == null) return (null, 0, 0, 0, 0);

        try
        {
            var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id.Value);
            var sheetData = worksheetPart.Worksheet?.GetFirstChild<SheetData>();
            return (sheetData, startCol, startRow, endCol, endRow);
        }
        catch { return (null, 0, 0, 0, 0); }
    }

    private static int ColumnLetterToIndex(string col)
    {
        int result = 0;
        foreach (var c in col)
            result = result * 26 + (c - 'A' + 1);
        return result;
    }

    private static string GetColumnLetter(int colIndex)
    {
        var result = "";
        while (colIndex > 0)
        {
            colIndex--;
            result = (char)('A' + colIndex % 26) + result;
            colIndex /= 26;
        }
        return result;
    }

    /// <summary>
    /// Render a cx:chart (Office 2016 extended chart) inside a GraphicFrame.
    /// Mirrors the regular <see cref="RenderExcelChart"/> flow: extract
    /// ChartInfo from the cx:chart element, instantiate the shared renderer
    /// with theme colors, and emit the SVG + legend inside a chart-container div.
    /// </summary>
    private void RenderExcelCxChart(StringBuilder sb, XDR.GraphicFrame gf,
        DrawingsPart drawingsPart, WorksheetPart worksheetPart,
        string sheetName = "", int chartIdx = 0)
    {
        var relId = GetExtendedChartRelId(gf);
        if (relId == null) return;

        DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing.Chart? chart;
        try
        {
            var extPart = (ExtendedChartPart)drawingsPart.GetPartById(relId);
            chart = extPart.ChartSpace?
                .GetFirstChild<DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing.Chart>();
            if (chart == null) return;
        }
        catch { return; }

        var info = ChartSvgRenderer.ExtractCxChartInfo(chart);
        if (info.Series.Count == 0) return;

        // Dimensions from the TwoCellAnchor, same as regular charts.
        var colWidths = GetColumnWidths(GetSheet(worksheetPart));
        var (widthPt, heightPt) = EstimateChartSize(gf, colWidths);

        var renderer = new ChartSvgRenderer
        {
            ThemeAccentColors = ChartSvgRenderer.BuildThemeAccentColors(GetExcelThemeColors()),
            ValueColor = info.ValFontColor != null ? ChartSvgRenderer.CssHexColor(info.ValFontColor) : "#333",
            CatColor = info.CatFontColor != null ? ChartSvgRenderer.CssHexColor(info.CatFontColor) : "#555",
            AxisColor = info.ValFontColor != null ? ChartSvgRenderer.CssHexColor(info.ValFontColor) : "#666",
            GridColor = info.GridlineColor != null ? ChartSvgRenderer.CssHexColor(info.GridlineColor) : "#ddd",
            AxisLineColor = info.AxisLineColor != null ? ChartSvgRenderer.CssHexColor(info.AxisLineColor) : "#999",
            ValFontPx = info.ValFontPx,
            CatFontPx = info.CatFontPx,
        };

        var svgW = Math.Max(widthPt, 225);
        var svgH = Math.Max(heightPt, 150);
        var titleFontPt = 10.0;
        if (!string.IsNullOrEmpty(info.TitleFontSize) && double.TryParse(info.TitleFontSize.Replace("pt", ""), out var tfp))
            titleFontPt = tfp;
        var titleH = string.IsNullOrEmpty(info.Title) ? 0 : (int)(titleFontPt * 1.6 + 8);
        var chartSvgH = svgH - titleH;
        if (chartSvgH < 80) return;

        var cxChartDataPath = chartIdx > 0 && !string.IsNullOrEmpty(sheetName) ? $" data-path=\"/{HtmlEncode(sheetName)}/chart[{chartIdx}]\"" : "";
        sb.AppendLine($"<div class=\"chart-container\"{cxChartDataPath} style=\"max-width:max({svgW}pt,100%);flex:1;min-width:200pt\">");

        var titleColor = info.TitleFontColor != null ? ChartSvgRenderer.CssHexColor(info.TitleFontColor) : "#333";
        if (!string.IsNullOrEmpty(info.Title))
            sb.AppendLine($"  <div style=\"text-align:center;font-size:{info.TitleFontSize};font-weight:bold;padding:6px 0;color:{titleColor}\">{ChartSvgRenderer.BuildTitleInnerHtml(info, titleColor, info.TitleBold, titleFontPt)}</div>");

        sb.AppendLine($"  <svg viewBox=\"0 0 {svgW} {chartSvgH}\" style=\"width:100%;height:auto\" preserveAspectRatio=\"xMidYMin meet\">");
        renderer.RenderChartSvgContent(sb, info, svgW, chartSvgH);
        sb.AppendLine("  </svg>");
        sb.AppendLine("</div>");
    }
}
