using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
namespace LaDOSE.Business.Service
{
///
/// Everything about a spreadsheet export that is not Google-specific: resolving the target,
/// validating the tables, and making the tab titles legal and unique. Kept out of the writers
/// so the rules are stated once and can be tested without a network, the same way
/// StatisticsService keeps its aggregation in a pure static method.
///
public class SheetsExportService : ISheetsExportService
{
private readonly SheetsSettings _settings;
private readonly ISheetsWriter _writer;
public SheetsExportService(SheetsSettings settings, ISheetsWriter writer)
{
_settings = settings ?? new SheetsSettings();
_writer = writer;
}
public SheetsConfig GetConfig()
{
return new SheetsConfig
{
Writer = _writer?.Name ?? "Disabled",
Configured = _writer != null
&& _writer.IsConfigured
&& !string.IsNullOrWhiteSpace(_settings.SpreadsheetId),
SpreadsheetId = _settings.SpreadsheetId ?? string.Empty,
MaxTabs = _settings.MaxTabs,
MaxRowsPerTab = _settings.MaxRowsPerTab
};
}
public async Task ExportAsync(SheetExportRequest request, CancellationToken ct = default)
{
if (_writer == null || !_writer.IsConfigured)
{
throw new SheetsExportException(503,
"Google Sheets export is not configured on the server.");
}
if (string.IsNullOrWhiteSpace(_settings.SpreadsheetId))
{
throw new SheetsExportException(400,
"No spreadsheet configured on the server. Set GoogleSheets:SpreadsheetId.");
}
var tabs = request?.Tabs?.Where(tab => tab != null).ToList() ?? new List();
if (tabs.Count == 0)
{
throw new SheetsExportException(400, "No table to write.");
}
Validate(tabs);
var warnings = new List();
NameTabs(tabs, warnings);
// The caller never names the spreadsheet; it is resolved here, from configuration.
var resolved = new SheetExportRequest
{
SpreadsheetId = _settings.SpreadsheetId.Trim(),
Tabs = tabs
};
var result = await _writer.WriteTablesAsync(resolved, ct);
result.Writer = _writer.Name;
result.Warnings = (result.Warnings ?? new List()).Concat(warnings).ToList();
return result;
}
#region Validation
private void Validate(List tabs)
{
if (tabs.Count > _settings.MaxTabs)
{
throw new SheetsExportException(400,
$"{tabs.Count} tabs requested, the limit is {_settings.MaxTabs}. Narrow the selection.");
}
foreach (var tab in tabs)
{
var header = tab.Header ?? new List();
// "Players", at least one game, "Total".
if (header.Count < 3)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' has no game column — nothing was scored for it.");
}
if (header.Count > _settings.MaxColumns)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' has {header.Count} columns, the limit is {_settings.MaxColumns}.");
}
var rows = tab.Rows ?? new List();
if (rows.Count > _settings.MaxRowsPerTab)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' has {rows.Count} rows, the limit is {_settings.MaxRowsPerTab}.");
}
var expected = header.Count - 2;
foreach (var row in rows)
{
var points = row?.Points?.Count ?? 0;
if (points != expected)
{
throw new SheetsExportException(400,
$"Tab '{tab.Name}' row '{row?.Player}' has {points} point columns, " +
$"header declares {expected}.");
}
}
}
}
#endregion
#region Tab naming
///
/// Google rejects these in a tab title. Replaced rather than stripped so "Ranking 13/14"
/// stays readable as "Ranking 13-14".
///
private static readonly Regex Forbidden = new Regex(@"[:\\/?*\[\]]", RegexOptions.Compiled);
private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled);
private const int MaxTitleLength = 100;
///
/// Makes every title legal and unique, in place, recording each change. Tab identity is
/// the title, so a rename means the next export writes somewhere else — which is exactly
/// why every rename is reported rather than applied quietly.
///
private static void NameTabs(List tabs, List warnings)
{
var taken = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var tab in tabs)
{
var requested = tab.Name ?? string.Empty;
var name = Sanitise(requested, tab.EventId);
if (!taken.Add(name))
{
var suffix = 2;
string candidate;
do
{
candidate = Truncate($"{name} ({suffix})");
suffix++;
} while (!taken.Add(candidate));
name = candidate;
}
if (!string.Equals(name, requested, StringComparison.Ordinal))
{
warnings.Add($"Tab renamed: '{requested}' -> '{name}'");
}
tab.Name = name;
}
}
///
/// Trim, replace what Google forbids, collapse runs of whitespace, drop leading and
/// trailing apostrophes (Sheets uses them to quote a title), cap the length, and fall
/// back to the event id when nothing usable survives.
///
public static string Sanitise(string requested, int eventId)
{
var name = (requested ?? string.Empty).Trim();
name = Forbidden.Replace(name, "-");
name = Whitespace.Replace(name, " ").Trim();
name = name.Trim('\'');
name = Truncate(name).Trim();
return string.IsNullOrWhiteSpace(name) ? $"Event {eventId}" : name;
}
private static string Truncate(string value)
{
return value.Length <= MaxTitleLength ? value : value.Substring(0, MaxTitleLength);
}
#endregion
}
}