using System; using System.Collections.Generic; using System.Data; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using GB5Shared.DTO.ECM; using GB5Shared.DTO.Framework.Login; using GB5Shared.QueryExecutor; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace GB5Shared.Attachment { // ── Token Registry ────────────────────────────────────────── public static class TemplateTokenRegistry { public static readonly IReadOnlySet SystemKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "_tenantCode", "_tenantName", "_year", "_month", "_yyyymm", "_yyyymmdd", "_moduleCode", "_moduleName", "_entityCode", "_entityName", "_docTypeCode", "_docTypeName", "_docSetCode", "_slNo", "_version", "_ext", "_objectId", "_rowGuid" }; public const string ObjectIdKey = "_objectId"; public const string RowGuidKey = "_rowGuid"; } // ── Token Resolvers ───────────────────────────────────────── public interface ITokenResolver { string? Resolve(string resolverKey); } /// Resolves system tokens from static context (date, login, template master data). public sealed class SystemTokenResolver : ITokenResolver { private readonly Dictionary _values; public SystemTokenResolver( AttachmentResolutionContext ctx, AttachmentTemplateData template, string entityCode, string entityName, string tenantCode, string tenantName) { var d = ctx.UploadedOn; var objectIdValue = ctx.ObjectId > 0 ? ctx.ObjectId.ToString() : ctx.RowGuid.ToString(); // Keyed without the legacy "_" prefix — real MDOCUMENTSETDETAIL templates use plain // {Year}, {Month}, {ObjectId} placeholders (see TemplateParser), not "_year" etc. _values = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["tenantCode"] = Sanitise(tenantCode), ["tenantName"] = Sanitise(tenantName), ["year"] = d.Year.ToString(), ["month"] = d.Month.ToString("D2"), ["yyyymm"] = d.ToString("yyyyMM"), ["yyyymmdd"] = d.ToString("yyyyMMdd"), ["moduleCode"] = Sanitise(template.ModuleCode), ["moduleName"] = Sanitise(template.ModuleName), ["entityCode"] = Sanitise(entityCode), ["entityName"] = Sanitise(entityName), ["docTypeCode"] = Sanitise(template.DocumentTypeCode), ["docTypeName"] = Sanitise(template.DocumentTypeName), ["docSetCode"] = Sanitise(template.DocumentSetCode), ["slNo"] = ctx.SlNo.ToString().PadLeft(3, '0'), ["version"] = ctx.Version.ToString(), ["ext"] = ctx.FileExtension.TrimStart('.').ToLowerInvariant(), ["objectId"] = objectIdValue, ["rowGuid"] = ctx.RowGuid.ToString(), ["fileName"] = Sanitise(ctx.OriginalFileName) }; } public string? Resolve(string key) => _values.TryGetValue(key.TrimStart('_'), out var val) ? val : null; public static string Sanitise(string? value) { if (string.IsNullOrWhiteSpace(value)) return "_"; return Regex.Replace(value.Trim(), @"[/\\:*?""<>|\s]+", "_").Trim('_'); } } /// /// Resolves dynamic tokens from the ContextDictionary. /// Supports dot-notation: vendor.vendorCode → dict["vendor"]["vendorCode"] /// public sealed class DictionaryTokenResolver : ITokenResolver { private readonly Dictionary _dict; public DictionaryTokenResolver(Dictionary contextDictionary) => _dict = contextDictionary; public string? Resolve(string resolverKey) { if (string.IsNullOrWhiteSpace(resolverKey)) return null; var parts = resolverKey.Split('.', 2); if (!_dict.TryGetValue(parts[0], out var topValue)) return null; return parts.Length == 1 ? Flatten(topValue) : Flatten(topValue, parts[1]); } private static string? Flatten(object? value, string? nestedKey = null) { if (value is null) return null; if (value is JsonElement el) { if (nestedKey is not null && el.ValueKind == JsonValueKind.Object && el.TryGetProperty(nestedKey, out var child)) return child.GetString(); return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString(); } if (value is Dictionary inner && nestedKey is not null) { inner.TryGetValue(nestedKey, out var v); return v?.ToString(); } return SystemTokenResolver.Sanitise(value.ToString() ?? string.Empty); } } // ── Template Parser ───────────────────────────────────────── /// /// Substitutes plain {TokenName} placeholders embedded in an otherwise-literal template /// string — e.g. MDOCUMENTSETDETAIL.LOCATIONTEMPLATE "/documents/invoice/{Year}/{Month}" /// or DOCUMENTNAMETEMPLATE "INV_{InvoiceNo}_{Date}". Literal text (including '/' /// separators) passes through untouched; only the {…} spans are resolved. /// Unresolved tokens produce "_UNKNOWN_" — visible to admins. /// public sealed class TemplateParser { private static readonly Regex _tokenPattern = new(@"\{([^{}]+)\}", RegexOptions.Compiled); private static readonly Regex _trailingExt = new(@"\.[A-Za-z0-9]{1,6}$", RegexOptions.Compiled); private readonly IReadOnlyList _resolvers; public TemplateParser(IReadOnlyList resolvers) => _resolvers = resolvers; public string Substitute(string template) => _tokenPattern.Replace(template, m => ResolveKey(m.Groups[1].Value.Trim())); public string BuildFolderPath(string? template, string fallback) { var active = string.IsNullOrWhiteSpace(template) ? fallback : template; if (string.IsNullOrWhiteSpace(active)) return string.Empty; var resolved = Substitute(active); var segments = resolved .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(SystemTokenResolver.Sanitise) .Where(s => !string.IsNullOrEmpty(s)) .ToList(); return segments.Count == 0 ? string.Empty : string.Join("/", segments) + "/"; } public string BuildFileName(string? template, string fallback, string fileExtension) { var ext = fileExtension.StartsWith('.') ? fileExtension : '.' + fileExtension; var active = string.IsNullOrWhiteSpace(template) ? fallback : template; if (string.IsNullOrWhiteSpace(active)) return $"attachment_{Guid.NewGuid():N}{ext}"; // A template may hardcode its own extension (e.g. "APPT_LET_{EMP}.pdf") — strip it // so the actual uploaded file's extension always wins instead of being appended twice. var resolved = _trailingExt.Replace(Substitute(active), string.Empty); resolved = SystemTokenResolver.Sanitise(resolved); return string.IsNullOrEmpty(resolved) ? $"attachment_{Guid.NewGuid():N}{ext}" : resolved + ext; } private string ResolveKey(string key) { foreach (var r in _resolvers) { var val = r.Resolve(key); if (val is not null) return SystemTokenResolver.Sanitise(val); } return "_UNKNOWN_"; } } // ── Template Resolution Engine ────────────────────────────── public interface ITemplateResolutionEngine { Task ResolveAsync( AttachmentResolutionContext context, CancellationToken ct = default); Task> ResolvePhase2Async( IEnumerable deferredAttachments, LoginDTO login, CancellationToken ct = default); } public sealed class TemplateResolutionEngine : ITemplateResolutionEngine { private readonly IQueryExecutor _qe; private readonly IOptions _settings; private readonly ILogger _log; private const string EntityContextSql = @" SELECT C.CLIENTCODE AS TenantCode, C.CLIENTNAME AS TenantName, E.ENTITYCODE AS EntityCode, E.ENTITYNAME AS EntityName FROM DBO.MCLIENT C JOIN DBO.MENTITY E ON E.ENTITYID = @EntityId WHERE C.CLIENTID = @ClientId"; public TemplateResolutionEngine( IQueryExecutor qe, IOptions settings, ILogger log) { _qe = qe; _settings = settings; _log = log; } public async Task ResolveAsync( AttachmentResolutionContext context, CancellationToken ct = default) { var s = _settings.Value; var template = await LoadTemplateAsync(context, ct).ConfigureAwait(false); var (tenantCode, tenantName, entityCode, entityName) = await LoadEntityContextAsync(context, ct).ConfigureAwait(false); var sysResolver = new SystemTokenResolver(context, template, entityCode, entityName, tenantCode, tenantName); var dictResolver = new DictionaryTokenResolver(context.ContextDictionary); var parser = new TemplateParser(new ITokenResolver[] { sysResolver, dictResolver }); var folderTemplate = !string.IsNullOrWhiteSpace(template.LocationTemplate) ? template.LocationTemplate : !string.IsNullOrWhiteSpace(template.TagFields) ? template.TagFields : s.DefaultFolderTemplate; var folderName = parser.BuildFolderPath(folderTemplate, s.DefaultFolderTemplate); var fileTemplate = !string.IsNullOrWhiteSpace(template.DocumentNameTemplate) ? template.DocumentNameTemplate : !string.IsNullOrWhiteSpace(template.PropertyFields) ? template.PropertyFields : s.DefaultFileTemplate; var attachedFileName = parser.BuildFileName(fileTemplate, s.DefaultFileTemplate, context.FileExtension); var displayFileName = !string.IsNullOrWhiteSpace(template.DisplayNameTemplate) ? BuildDisplayName(template.DisplayNameTemplate, parser) : $"{template.DocumentTypeName} - {context.UploadedOn:yyyyMMdd} - v{context.Version}"; var isDeferred = context.ObjectId <= 0; var storageKey = folderName + attachedFileName; _log.LogInformation( "Path resolved. ObjectTypeId={OT} ObjectId={OId} Folder={F} File={File} Deferred={D}", context.ObjectTypeId, context.ObjectId, folderName, attachedFileName, isDeferred); return new ResolvedAttachmentPaths { FolderName = folderName, AttachedFileName = attachedFileName, DisplayFileName = displayFileName, StorageKey = storageKey, IsDeferred = isDeferred }; } public Task> ResolvePhase2Async( IEnumerable deferredAttachments, LoginDTO login, CancellationToken ct = default) { var movements = new List(); foreach (var att in deferredAttachments) { var placeholder = att.RowGuidPlaceholder; var objectIdStr = att.NewObjectId.ToString(); var newFolder = att.OldFolderName.Replace(placeholder, objectIdStr, StringComparison.OrdinalIgnoreCase); var newFileName = att.OldAttachedFileName.Replace(placeholder, objectIdStr, StringComparison.OrdinalIgnoreCase); var newCmsId = newFolder + newFileName; var newDisplay = att.DisplayFileName.Replace(placeholder, objectIdStr, StringComparison.OrdinalIgnoreCase); _log.LogInformation( "Phase 2 path resolved. AttachmentId={Id} OldKey={Old} NewKey={New}", att.AttachmentId, att.OldCmsId, newCmsId); movements.Add(new PathMovement { AttachmentId = att.AttachmentId, OldCmsId = att.OldCmsId, NewFolderName = newFolder, NewAttachedFileName = newFileName, NewCmsId = newCmsId, NewDisplayFileName = newDisplay }); } return Task.FromResult>(movements); } private async Task LoadTemplateAsync( AttachmentResolutionContext ctx, CancellationToken ct) { var result = await _qe.QuerySingleAsync( ctx.Login, "EXEC DBO.SP_ATTACHMENT_LOAD_TEMPLATE @DocumentSetDetailId, @ClientId", new { ctx.DocumentSetDetailId, ctx.Login.ClientId }, cancellationToken: ct).ConfigureAwait(false); if (result is null) throw new InvalidOperationException( $"DocumentSetDetailId {ctx.DocumentSetDetailId} not found for ClientId {ctx.Login.ClientId}."); return result; } private async Task<(string tenantCode, string tenantName, string entityCode, string entityName)> LoadEntityContextAsync(AttachmentResolutionContext ctx, CancellationToken ct) { var row = await _qe.QuerySingleAsync( ctx.Login, EntityContextSql, new { EntityId = ctx.ObjectTypeId, ctx.Login.ClientId }, cancellationToken: ct).ConfigureAwait(false); return row is null ? ("TENANT", "Tenant", "ENTITY", "Entity") : ((string)row.TenantCode, (string)row.TenantName, (string)row.EntityCode, (string)row.EntityName); } private static string BuildDisplayName(string template, TemplateParser parser) { var resolved = parser.Substitute(template).Trim(); return string.IsNullOrWhiteSpace(resolved) ? "Attachment" : resolved; } } }