using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Http; namespace line_gestao_api.Services; public sealed class MveCsvParserService { private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true); private static readonly Encoding Latin1 = Encoding.GetEncoding("ISO-8859-1"); private static readonly Encoding Windows1252 = Encoding.GetEncoding(1252); private static readonly string[] RequiredHeaders = ["DDD", "NUMERO", "STATUS_LINHA"]; static MveCsvParserService() { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } internal async Task ParseAsync(IFormFile file, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(file); await using var stream = file.OpenReadStream(); using var memory = new MemoryStream(); await stream.CopyToAsync(memory, cancellationToken); return Parse(memory.ToArray(), file.FileName); } internal MveParsedFileResult Parse(byte[] bytes, string? fileName) { ArgumentNullException.ThrowIfNull(bytes); var decoded = Decode(bytes); var rows = ParseCsvRows(decoded.Content); if (rows.Count == 0) { throw new InvalidOperationException("O arquivo CSV do MVE está vazio."); } var headerRow = rows[0]; var headerMap = BuildHeaderMap(headerRow); var missingHeaders = RequiredHeaders .Where(header => !headerMap.ContainsKey(MveAuditNormalization.NormalizeHeader(header))) .ToList(); if (missingHeaders.Count > 0) { throw new InvalidOperationException( $"O relatório MVE não contém as colunas obrigatórias: {string.Join(", ", missingHeaders)}."); } var serviceColumns = headerMap .Where(entry => entry.Key.StartsWith("SERVICO_ATIVOS", StringComparison.Ordinal)) .OrderBy(entry => entry.Value) .Select(entry => entry.Value) .ToList(); var result = new MveParsedFileResult { FileName = string.IsNullOrWhiteSpace(fileName) ? null : fileName.Trim(), FileEncoding = decoded.Encoding.WebName, FileHashSha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), SourceRowCount = Math.Max(rows.Count - 1, 0) }; for (var rowIndex = 1; rowIndex < rows.Count; rowIndex++) { var row = rows[rowIndex]; if (IsEmptyRow(row)) { continue; } var sourceRowNumber = rowIndex + 1; var ddd = GetValue(row, headerMap, "DDD"); var numero = GetValue(row, headerMap, "NUMERO"); var numeroNormalizado = MveAuditNormalization.OnlyDigits($"{ddd}{numero}"); if (string.IsNullOrWhiteSpace(ddd) || string.IsNullOrWhiteSpace(numero) || string.IsNullOrWhiteSpace(numeroNormalizado)) { result.Issues.Add(new MveParsedIssue( sourceRowNumber, string.Empty, "INVALID_ROW", "Linha sem DDD e/ou número válido no relatório MVE.")); continue; } var status = MveAuditNormalization.NormalizeReportStatus(GetValue(row, headerMap, "STATUS_LINHA")); var line = new MveParsedLine { SourceRowNumber = sourceRowNumber, Ddd = ddd, Numero = numero, NumeroNormalizado = numeroNormalizado, StatusLinha = status.DisplayValue, StatusLinhaKey = status.Key, StatusLinhaRecognized = status.Recognized, StatusConta = GetValue(row, headerMap, "STATUS_CONTA"), PlanoLinha = GetValue(row, headerMap, "PLANO_LINHA"), DataAtivacao = MveAuditNormalization.ParseDateValue(GetValue(row, headerMap, "DATA_ATIVACAO")), TerminoContrato = MveAuditNormalization.ParseDateValue(GetValue(row, headerMap, "TERMINO_CONTRATO")), Chip = MveAuditNormalization.NullIfEmptyDigits(GetValue(row, headerMap, "CHIP")), Conta = NullIfEmpty(MveAuditNormalization.CleanTextValue(GetValue(row, headerMap, "CONTA"))), Cnpj = MveAuditNormalization.NullIfEmptyDigits(GetValue(row, headerMap, "CNPJ")), ModeloAparelho = NullIfEmpty(MveAuditNormalization.CleanTextValue(GetValue(row, headerMap, "MODELO_APARELHO"))), Fabricante = NullIfEmpty(MveAuditNormalization.CleanTextValue(GetValue(row, headerMap, "FABRICANTE"))) }; foreach (var columnIndex in serviceColumns) { if (columnIndex < 0 || columnIndex >= row.Count) { continue; } var serviceValue = NullIfEmpty(MveAuditNormalization.CleanTextValue(row[columnIndex])); if (!string.IsNullOrWhiteSpace(serviceValue)) { line.ServicosAtivos.Add(serviceValue); } } result.Lines.Add(line); } return result; } private static DecodedContent Decode(byte[] bytes) { foreach (var encoding in new[] { StrictUtf8, Windows1252, Latin1 }) { try { var content = encoding.GetString(bytes); if (!string.IsNullOrWhiteSpace(content)) { return new DecodedContent(encoding, content.TrimStart('\uFEFF')); } } catch { // tenta o próximo encoding } } return new DecodedContent(Latin1, Latin1.GetString(bytes).TrimStart('\uFEFF')); } private static List> ParseCsvRows(string content) { var rows = new List>(); var currentRow = new List(); var currentField = new StringBuilder(); var inQuotes = false; for (var i = 0; i < content.Length; i++) { var ch = content[i]; if (inQuotes) { if (ch == '"') { if (i + 1 < content.Length && content[i + 1] == '"') { currentField.Append('"'); i++; } else { inQuotes = false; } } else { currentField.Append(ch); } continue; } switch (ch) { case '"': inQuotes = true; break; case ';': currentRow.Add(currentField.ToString()); currentField.Clear(); break; case '\r': if (i + 1 < content.Length && content[i + 1] == '\n') { i++; } currentRow.Add(currentField.ToString()); currentField.Clear(); rows.Add(currentRow); currentRow = new List(); break; case '\n': currentRow.Add(currentField.ToString()); currentField.Clear(); rows.Add(currentRow); currentRow = new List(); break; default: currentField.Append(ch); break; } } currentRow.Add(currentField.ToString()); if (currentRow.Count > 1 || !string.IsNullOrWhiteSpace(currentRow[0])) { rows.Add(currentRow); } return rows; } private static Dictionary BuildHeaderMap(IReadOnlyList headerRow) { var map = new Dictionary(StringComparer.Ordinal); for (var i = 0; i < headerRow.Count; i++) { var key = MveAuditNormalization.NormalizeHeader(headerRow[i]); if (string.IsNullOrWhiteSpace(key) || key.StartsWith("UNNAMED", StringComparison.Ordinal)) { continue; } if (!map.ContainsKey(key)) { map[key] = i; } } return map; } private static string GetValue(IReadOnlyList row, IReadOnlyDictionary headerMap, string header) { var normalizedHeader = MveAuditNormalization.NormalizeHeader(header); if (!headerMap.TryGetValue(normalizedHeader, out var index)) { return string.Empty; } if (index < 0 || index >= row.Count) { return string.Empty; } return MveAuditNormalization.CleanTextValue(row[index]); } private static bool IsEmptyRow(IReadOnlyList row) { return row.Count == 0 || row.All(cell => string.IsNullOrWhiteSpace(MveAuditNormalization.CleanTextValue(cell))); } private static string? NullIfEmpty(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private sealed record DecodedContent(Encoding Encoding, string Content); } internal sealed class MveParsedFileResult { public string? FileName { get; init; } public string FileEncoding { get; init; } = string.Empty; public string FileHashSha256 { get; init; } = string.Empty; public int SourceRowCount { get; init; } public List Lines { get; } = new(); public List Issues { get; } = new(); } internal sealed class MveParsedLine { public int SourceRowNumber { get; init; } public string Ddd { get; init; } = string.Empty; public string Numero { get; init; } = string.Empty; public string NumeroNormalizado { get; init; } = string.Empty; public string StatusLinha { get; init; } = string.Empty; public string StatusLinhaKey { get; init; } = string.Empty; public bool StatusLinhaRecognized { get; init; } public string? StatusConta { get; init; } public string? PlanoLinha { get; init; } public DateTime? DataAtivacao { get; init; } public DateTime? TerminoContrato { get; init; } public string? Chip { get; init; } public string? Conta { get; init; } public string? Cnpj { get; init; } public string? ModeloAparelho { get; init; } public string? Fabricante { get; init; } public List ServicosAtivos { get; } = new(); } internal sealed record MveParsedIssue( int SourceRowNumber, string NumeroLinha, string IssueType, string Message);