using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace BulkArchiveMailer.Core
{
/// Everything ArchiveScanner.Scan found in one folder, split by shape so the caller can render them however it likes.
public sealed class ScanResult
{
/// Ready / AlreadySent / Error rows - one per login that had at least one recognized *.7z file.
public List Jobs { get; } = new List();
/// One Skipped row per file that did not match the expected naming pattern.
public List UnrecognizedFiles { get; } = new List();
/// Convenience: both lists concatenated, sorted the same way PreviewForm wants to bind a single grid.
public IEnumerable AllRows => Jobs.Concat(UnrecognizedFiles);
}
///
/// Walks a folder of ssh-access-tui output archives (plain System.IO -
/// no Outlook/COM involved) and builds the list of preview rows.
///
public static class ArchiveScanner
{
/// Folder to scan (non-recursive - ssh-access-tui writes archives flat into one output directory).
/// Domain appended to the derived local part, e.g. "redlab.lo".
/// Configured templates, matched against each parsed file's suffix (case-insensitively) to render that job's Subject/BodyHtml/KindDisplay. May be null or empty (every recognized file then goes to UnrecognizedFiles, since nothing can match), useful for pure filename-parsing tests.
/// When provided, each single-archive login is checked against the log (by SHA-256 of the archive's current bytes) and marked AlreadySent if a prior Sent row matches. When null, dedup is skipped entirely and every recognized single-archive login comes back Ready.
public static ScanResult Scan(string folderPath, string emailDomain, List templates, SendLogStore sendLog = null)
{
var result = new ScanResult();
if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath))
{
// Caller (SettingsForm/PreviewForm) is expected to validate
// the configured folder up front and show its own error;
// scanning a missing folder just yields an empty result
// rather than throwing.
return result;
}
// login -> every (path, suffix) found for it, case-insensitive
// because the file system this runs on (Windows) is too.
var byLogin = new Dictionary>(StringComparer.OrdinalIgnoreCase);
foreach (var filePath in Directory.EnumerateFiles(folderPath, "*.7z", SearchOption.TopDirectoryOnly).OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
{
var fileName = Path.GetFileName(filePath);
if (!FilenameParser.TryParse(fileName, out var parsed))
{
result.UnrecognizedFiles.Add(new SendJob
{
ArchivePath = filePath,
Status = SendStatus.Skipped,
ErrorMessage = $"Имя файла не соответствует шаблону \"{{login}}_{{суффикс}}.7z\": {fileName}",
});
continue;
}
var matchedTemplate = templates?.FirstOrDefault(t => string.Equals(t.FileSuffix, parsed.Suffix, StringComparison.OrdinalIgnoreCase));
if (matchedTemplate == null)
{
result.UnrecognizedFiles.Add(new SendJob
{
ArchivePath = filePath,
Status = SendStatus.Skipped,
ErrorMessage = $"Суффикс \"{parsed.Suffix}\" не соответствует ни одному настроенному шаблону: {fileName}",
});
continue;
}
if (!string.IsNullOrEmpty(matchedTemplate.LoginPattern) && !LoginMatchesPattern(parsed.Login, matchedTemplate.LoginPattern))
{
// Suffix matched, but the operator scoped this template
// to a subset of logins (e.g. a per-team/per-suffix
// custom template) and this file's login falls outside
// it - surfaced next to the other per-file
// "recognized but does not qualify" case (suffix
// mismatch above) rather than silently picking a
// different template or falling through to
// byLogin/Ready.
result.UnrecognizedFiles.Add(new SendJob
{
ArchivePath = filePath,
Status = SendStatus.Error,
ErrorMessage = $"Логин \"{parsed.Login}\" не соответствует фильтру шаблона \"{matchedTemplate.DisplayName}\" ({matchedTemplate.LoginPattern}): {fileName}",
});
continue;
}
if (!byLogin.TryGetValue(parsed.Login, out var list))
{
list = new List<(string, string)>();
byLogin[parsed.Login] = list;
}
list.Add((filePath, parsed.Suffix));
}
foreach (var kv in byLogin.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
{
var login = kv.Key;
var files = kv.Value;
var email = EmailAddressBuilder.BuildEmail(login, emailDomain);
if (files.Count > 1)
{
// Both archive kinds present for one login (or, less
// likely, a same-kind duplicate left behind from a
// prior export) - this is a data error upstream. Never
// auto-pick one; surface it as a blocking row instead.
var names = string.Join(", ", files.Select(f => Path.GetFileName(f.Path)));
result.Jobs.Add(new SendJob
{
Login = login,
Email = email,
Kind = null,
ArchivePath = null,
ConflictingArchivePaths = files.Select(f => f.Path).ToList(),
Status = SendStatus.Error,
ErrorMessage = $"Для логина \"{login}\" одновременно найдено {files.Count} архива(ов): {names}. Это ошибка данных (должен существовать только один архив на логин за раз) — требуется ручная проверка на jump-хосте, автоматический выбор не выполняется.",
});
continue;
}
var (path, suffix) = files[0];
// Guaranteed non-null: a suffix only ever makes it into
// byLogin after already matching a template above.
var matchedTemplate = templates.First(t => string.Equals(t.FileSuffix, suffix, StringComparison.OrdinalIgnoreCase));
var values = TemplateEngine.BuildValues(login, email);
var job = new SendJob
{
Login = login,
Email = email,
Kind = suffix,
ArchivePath = path,
Subject = TemplateEngine.Render(matchedTemplate.Subject, values),
BodyHtml = TemplateEngine.Render(matchedTemplate.BodyHtml, values),
KindDisplay = matchedTemplate.DisplayName,
Status = SendStatus.Ready,
};
if (sendLog != null)
{
try
{
job.FileSha256 = SendLogStore.ComputeSha256(path);
if (sendLog.TryFindSent(email, suffix, job.FileSha256, out var priorEntry))
{
job.Status = SendStatus.AlreadySent;
job.ErrorMessage = $"Уже отправлено {priorEntry.TimestampUtc:yyyy-MM-dd HH:mm} UTC (тот же файл, без изменений с тех пор).";
}
}
catch (IOException ex)
{
// File locked/unreadable right now - don't block
// the whole scan over one bad file, surface it as
// an error row instead.
job.Status = SendStatus.Error;
job.ErrorMessage = $"Не удалось прочитать файл архива для проверки дубликатов: {ex.Message}";
}
}
result.Jobs.Add(job);
}
return result;
}
///
/// Whether login satisfies the template's LoginPattern regex
/// (case-insensitive). An invalid pattern (e.g. hand-edited
/// config.json with broken regex syntax) is treated as "does not
/// match" rather than throwing and aborting the whole folder scan -
/// SettingsForm.btnSave_Click is where a bad pattern is meant to be
/// caught, before it ever reaches disk.
///
private static bool LoginMatchesPattern(string login, string pattern)
{
try
{
return Regex.IsMatch(login, pattern, RegexOptions.IgnoreCase);
}
catch (ArgumentException)
{
return false;
}
}
}
}