using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Launcher;

namespace MiGu.Server.Controllers;

/// <summary>
/// 平台「日志管理」后端：把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// （<c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>，俗称 DLog）暴露给
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
/// 的两区设计（诊断条目表 + 落盘文件表），并在 Web 端额外提供「按标签合订」视图。
///
/// 数据来源：日志是 SimpleLite 进程在其工作目录写出的历史文件，<b>不依赖 SimpleLite 是否在运行</b>，
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
///
/// 落盘行格式（见 Diagnosis.Log）：<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>，
/// 其中 tag 为 <c>/</c> 表示无标签（滚动记录）。不匹配该模式的行视为上一条的续行（多行内容）。
///
/// 鉴权：class 级 <c>[Authorize(Policy = "PlatformScope")]</c> —— 日志为内核落盘文件（可能含文件路径 /
/// 内部运行状态等敏感信息），仅平台后台用户（scope=Platform）可读 / 下载，排除 RCSMonitor 监控大屏 token。
/// </summary>
[ApiController]
[Authorize(Policy = "PlatformScope")]
[Route("api/logs")]
public sealed class LogsController : ControllerBase
{
    /// <summary>文件列表默认上限（与 LogViewer.cs 的 MaxFilesShown=500 对齐）。</summary>
    private const int DefaultFileLimit = 500;

    /// <summary>单次解析的条目硬上限，防超大日志（实测单文件可达 46MB）撑爆内存。</summary>
    private const int MaxEntries = 200_000;

    /// <summary>单次扫描字节上限（超过则截断并标记 truncated）。</summary>
    private const long MaxScanBytes = 96L * 1024 * 1024;

    /// <summary>合订/某天聚合时最多遍历的文件数，避免一次扫描整月日志。</summary>
    private const int MaxDigestFiles = 64;

    /// <summary>跨文件聚合（analyze/digest 的 day 模式）的总条目上限，防某天多个大文件 entries 累加撑爆内存。</summary>
    private const int MaxAggregateEntries = 300_000;

    /// <summary>Diagnosis 落盘行：<c>[head] &gt;tag: content</c>；head 内含可选 prefix + 时间戳。</summary>
    private static readonly Regex LineRegex =
        new(@"^\[(?<head>[^\]]*)\]\s*>(?<tag>.*?):\s?(?<content>.*)$", RegexOptions.Compiled);

    /// <summary>从 head 里抠出时间戳（prefix = 时间戳之前的部分）。</summary>
    private static readonly Regex TimeRegex =
        new(@"\d{4}/\d{2}/\d{2}-\d{2}:\d{2}:\d{2}\.\d{3}", RegexOptions.Compiled);

    private const string TimeFormat = "yyyy/MM/dd-HH:mm:ss.fff";

    /// <summary>
    /// 内容里的「数值字段」：<c>key=value</c> 或 <c>key: value</c>，value 为数字（可带小数/负号）。
    /// key 必须以字母/下划线/中文开头，避免把 <c>12:30</c> 这类时间误判为字段。供「日志分析器」识别可绘图字段。
    /// </summary>
    private static readonly Regex NumericFieldRegex =
        new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);

    private readonly SimpleLiteLauncher _launcher;
    private readonly IConfiguration _config;
    private readonly ILogger<LogsController> _log;

    public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
    {
        _launcher = launcher;
        _config = config;
        _log = log;
    }

    // ─────────────────────────────────────────────────────────── 概览 ──

    /// <summary>日志根概览：工作目录、根路径、是否存在、文件/字节总量、按天分组统计。</summary>
    [HttpGet("overview")]
    public IActionResult Overview()
    {
        var (root, workdir, error) = ResolveLogRoot();
        if (root == null)
            return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error });

        if (!Directory.Exists(root))
            return Ok(new
            {
                exists = false, root, workingDirectory = workdir,
                totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
                message = "日志目录尚未生成（SimpleLite 产生落盘日志后会自动创建 log 目录）。"
            });

        var files = EnumerateLogFiles(root);
        var days = files
            .GroupBy(f => f.Day)
            .Select(g => new { day = g.Key, files = g.Count(), bytes = g.Sum(x => x.Bytes) })
            .OrderByDescending(d => d.day, StringComparer.Ordinal)
            .ToList();

        return Ok(new
        {
            exists = true, root, workingDirectory = workdir,
            totalFiles = files.Count,
            totalBytes = files.Sum(f => f.Bytes),
            latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
            days
        });
    }

    // ────────────────────────────────────────────────────── 文件列表 ──

    /// <summary>列出落盘日志文件（递归 log/），可按天 / 文件名关键字过滤，按修改时间降序。</summary>
    [HttpGet("files")]
    public IActionResult Files([FromQuery] string? day, [FromQuery] string? keyword, [FromQuery] int limit = DefaultFileLimit)
    {
        var (root, _, error) = ResolveLogRoot();
        if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
        if (!Directory.Exists(root)) return Ok(new { root, total = 0, returned = 0, files = Array.Empty<object>() });

        var clamp = Math.Clamp(limit, 1, 5000);
        IEnumerable<FileMeta> q = EnumerateLogFiles(root);
        if (!string.IsNullOrWhiteSpace(day))
            q = q.Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase));
        if (!string.IsNullOrWhiteSpace(keyword))
            q = q.Where(f => f.Rel.Contains(keyword, StringComparison.OrdinalIgnoreCase));

        var all = q.OrderByDescending(f => f.Mtime).ToList();
        var page = all.Take(clamp).Select(f => new
        {
            rel = f.Rel, name = f.Name, day = f.Day, dir = f.Dir, bytes = f.Bytes, mtime = f.Mtime
        }).ToList();

        return Ok(new { root, total = all.Count, returned = page.Count, files = page });
    }

    // ──────────────────────────────────────────────────── 目录浏览 ──

    /// <summary>
    /// 文件夹 / 文件浏览器：列出 <c>log/</c> 下指定相对目录的<b>直接</b>子项（子文件夹 + 文件），可逐层进入。
    /// <c>path</c> 为空 = 日志根。对齐用户诉求「直接显示 log 下所有文件夹和文件，可进入文件夹、打开某个日志文件」。
    /// 子文件夹附带其下一层的子目录/文件计数，文件附带大小、修改时间与是否 .log。
    /// </summary>
    [HttpGet("browse")]
    public IActionResult Browse([FromQuery] string? path)
    {
        var (root, _, error) = ResolveLogRoot();
        if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);

        var normRoot = Path.GetFullPath(root);
        if (!Directory.Exists(normRoot))
            return Ok(new
            {
                root = normRoot, exists = false, path = "", parent = (string?)null,
                dirCount = 0, fileCount = 0,
                dirs = Array.Empty<object>(), files = Array.Empty<object>(),
                message = "日志目录尚未生成（SimpleLite 产生落盘日志后会自动创建 log 目录）。"
            });

        var target = SafeResolveDir(normRoot, path);
        if (target == null) return BadRequest(new { message = "非法的目录路径" });
        if (!Directory.Exists(target)) return NotFound(new { message = "目录不存在" });

        var rel = NormalizeRel(Path.GetRelativePath(normRoot, target));
        var parent = string.IsNullOrEmpty(rel) ? (string?)null : NormalizeRel(Path.GetDirectoryName(rel) ?? "");

        var dirs = new List<object>();
        var files = new List<object>();
        try
        {
            var di = new DirectoryInfo(target);

            foreach (var sub in di.GetDirectories().OrderByDescending(x => x.LastWriteTime))
            {
                int childDirs = 0, childFiles = 0;
                try { childDirs = sub.GetDirectories().Length; } catch { /* 无权限/并发删除：计数视为 0 */ }
                try { childFiles = sub.GetFiles().Length; } catch { /* 同上 */ }
                dirs.Add(new
                {
                    name = sub.Name,
                    rel = NormalizeRel(Path.GetRelativePath(normRoot, sub.FullName)),
                    mtime = sub.LastWriteTime,
                    dirCount = childDirs,
                    fileCount = childFiles
                });
            }

            foreach (var f in di.GetFiles().OrderByDescending(x => x.LastWriteTime))
            {
                files.Add(new
                {
                    name = f.Name,
                    rel = NormalizeRel(Path.GetRelativePath(normRoot, f.FullName)),
                    bytes = f.Length,
                    mtime = f.LastWriteTime,
                    isLog = f.Extension.Equals(".log", StringComparison.OrdinalIgnoreCase)
                });
            }
        }
        catch (Exception ex)
        {
            _log.LogWarning(ex, "浏览日志目录失败 path={Path}", path);
            return Problem($"读取目录失败：{ex.Message}", statusCode: 500);
        }

        return Ok(new
        {
            root = normRoot, exists = true, path = rel, parent,
            dirCount = dirs.Count, fileCount = files.Count, dirs, files
        });
    }

    // ──────────────────────────────────────────────────── 条目（分页）──

    /// <summary>
    /// 解析单个日志文件为结构化条目（时间 / 标签 / 内容），支持关键字 / 标签 / 仅带标签过滤、
    /// 升降序与分页。超大文件按 <see cref="MaxScanBytes"/> / <see cref="MaxEntries"/> 截断并回 truncated。
    /// </summary>
    [HttpGet("entries")]
    public IActionResult Entries(
        [FromQuery] string file,
        [FromQuery] string? keyword,
        [FromQuery] string? tag,
        [FromQuery] bool onlyTagged = false,
        [FromQuery] string order = "desc",
        [FromQuery] int limit = 300,
        [FromQuery] int offset = 0)
    {
        var (root, _, error) = ResolveLogRoot();
        if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);

        var full = SafeResolve(root, file);
        if (full == null) return BadRequest(new { message = "非法的文件路径" });
        if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });

        var parsed = ParseFile(full);
        IEnumerable<LogEntry> q = parsed.Entries;
        if (onlyTagged) q = q.Where(e => !string.IsNullOrEmpty(e.Tag));
        if (!string.IsNullOrWhiteSpace(tag))
            q = q.Where(e => e.Tag.Contains(tag, StringComparison.OrdinalIgnoreCase));
        if (!string.IsNullOrWhiteSpace(keyword))
            q = q.Where(e => e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)
                             || e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase));

        var filtered = q.ToList();
        if (!string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase))
            filtered.Reverse();

        var clampLimit = Math.Clamp(limit, 1, 2000);
        var clampOffset = Math.Max(0, offset);
        var page = filtered.Skip(clampOffset).Take(clampLimit).Select(Project).ToList();

        return Ok(new
        {
            file, bytes = parsed.Bytes, scannedLines = parsed.ScannedLines,
            truncated = parsed.Truncated, total = filtered.Count,
            offset = clampOffset, limit = clampLimit, order = order.ToLowerInvariant(),
            entries = page
        });
    }

    // ─────────────────────────────────────────────────────── 合订本 ──

    /// <summary>
    /// 「合订本」：把带标签的 Post/Toast 按标签聚合成一册（条数 + 时间范围 + 最新内容 + 最近若干条），
    /// 无标签的归入「滚动记录」。对齐用户诉求「post 和 toast 如果有标签需要是合订本的形式」。
    /// 范围二选一：<c>file</c>（单文件）或 <c>day</c>（某天全部文件，最多 <see cref="MaxDigestFiles"/> 个）。
    /// </summary>
    [HttpGet("digest")]
    public IActionResult Digest(
        [FromQuery] string? file,
        [FromQuery] string? day,
        [FromQuery] string? keyword,
        [FromQuery] int maxPerTag = 100)
    {
        var (root, _, error) = ResolveLogRoot();
        if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);

        var targets = new List<string>();
        string sourceLabel;
        if (!string.IsNullOrWhiteSpace(file))
        {
            var full = SafeResolve(root, file);
            if (full == null) return BadRequest(new { message = "非法的文件路径" });
            if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
            targets.Add(full);
            sourceLabel = file;
        }
        else if (!string.IsNullOrWhiteSpace(day))
        {
            if (!Directory.Exists(root)) return Ok(EmptyDigest("day", day));
            targets = EnumerateLogFiles(root)
                .Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase))
                .OrderBy(f => f.Mtime)
                .Take(MaxDigestFiles)
                .Select(f => f.Full)
                .ToList();
            sourceLabel = day;
        }
        else
        {
            return BadRequest(new { message = "请提供 file 或 day 之一作为合订范围" });
        }

        var clampPerTag = Math.Clamp(maxPerTag, 1, 1000);
        var books = new Dictionary<string, Book>(StringComparer.Ordinal);
        var untagged = new Book { Tag = "" };
        bool truncated = false;
        long scanned = 0;

        foreach (var f in targets)
        {
            var parsed = ParseFile(f);
            truncated |= parsed.Truncated;
            scanned += parsed.Bytes;
            foreach (var e in parsed.Entries)
            {
                if (!string.IsNullOrWhiteSpace(keyword)
                    && !e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)
                    && !e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase))
                    continue;

                if (string.IsNullOrEmpty(e.Tag))
                {
                    Accumulate(untagged, e, clampPerTag);
                }
                else
                {
                    if (!books.TryGetValue(e.Tag, out var b)) { b = new Book { Tag = e.Tag }; books[e.Tag] = b; }
                    Accumulate(b, e, clampPerTag);
                }
            }
            // 跨文件总字节熔断：合订各 Book 已有 maxPerTag 上限，这里再防 64 个大文件把 CPU 拉满。
            if (scanned >= MaxScanBytes) { truncated = true; break; }
        }

        var bookList = books.Values
            .OrderByDescending(b => b.LastTime ?? DateTime.MinValue)
            .Select(b => ToBookDto(b))
            .ToList();

        return Ok(new
        {
            source = string.IsNullOrWhiteSpace(file) ? "day" : "file",
            target = sourceLabel,
            files = targets.Count,
            truncated,
            tagCount = bookList.Count,
            untaggedCount = untagged.Count,
            books = bookList,
            untagged = ToBookDto(untagged)
        });
    }

    // ─────────────────────────────────────────────────────── 日志分析 ──

    /// <summary>
    /// 「日志分析器」：解析单文件（<c>file</c>）或某天（<c>day</c>）日志，产出图表所需的聚合数据：
    /// <list type="number">
    /// <item><b>标签分布</b> tags：各标签条数 + 占比（含「滚动记录」即无标签）；</item>
    /// <item><b>日志量直方图</b> volume：按 <c>granularity</c>(second/minute/hour) 分桶的总量 + Top6 标签拆分（稀疏桶，仅含有数据的时刻）；</item>
    /// <item><b>数值字段识别</b> fields：从内容里抽取 <c>key=value</c>/<c>key:value</c> 的数值字段，给出样本数/最小/最大/均值/最后值；</item>
    /// <item><b>字段时序</b> series：当指定 <c>field</c> 时，返回该字段的 (时间, 值) 点序列（超量自动抽稀）。</item>
    /// </list>
    /// <c>keyword</c> 过滤全部统计；<c>tag</c> 仅聚焦「字段识别 + 字段时序」（不影响标签分布/直方图，便于先看全貌再下钻）。
    /// </summary>
    [HttpGet("analyze")]
    public IActionResult Analyze(
        [FromQuery] string? file,
        [FromQuery] string? day,
        [FromQuery] string granularity = "minute",
        [FromQuery] string? tag = null,
        [FromQuery] string? keyword = null,
        [FromQuery] string? field = null)
    {
        var (root, _, error) = ResolveLogRoot();
        if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);

        var gran = (granularity ?? "minute").ToLowerInvariant() switch
        {
            "second" or "sec" or "s" => Gran.Second,
            "hour" or "h" => Gran.Hour,
            _ => Gran.Minute
        };
        var granName = gran.ToString().ToLowerInvariant();

        var targets = new List<string>();
        string label;
        string source;
        if (!string.IsNullOrWhiteSpace(file))
        {
            var full = SafeResolve(root, file);
            if (full == null) return BadRequest(new { message = "非法的文件路径" });
            if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
            targets.Add(full);
            label = file; source = "file";
        }
        else if (!string.IsNullOrWhiteSpace(day))
        {
            if (!Directory.Exists(root)) return Ok(EmptyAnalysis("day", day, granName));
            targets = EnumerateLogFiles(root)
                .Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase))
                .OrderBy(f => f.Mtime).Take(MaxDigestFiles).Select(f => f.Full).ToList();
            label = day; source = "day";
        }
        else
        {
            return BadRequest(new { message = "请提供 file 或 day 之一作为分析范围" });
        }

        var all = new List<LogEntry>();
        bool truncated = false;
        long aggBytes = 0;
        foreach (var f in targets)
        {
            var parsed = ParseFile(f);
            truncated |= parsed.Truncated;
            aggBytes += parsed.Bytes;
            // 跨文件聚合熔断：总条数 / 总字节达上限即停止纳入并标记 truncated（图表基于已采样数据），
            // 避免某天多个大文件把全部 entries 堆进内存。
            var room = MaxAggregateEntries - all.Count;
            if (room <= 0) { truncated = true; break; }
            if (parsed.Entries.Count > room)
            {
                all.AddRange(parsed.Entries.GetRange(0, room));
                truncated = true;
                break;
            }
            all.AddRange(parsed.Entries);
            if (aggBytes >= MaxScanBytes) { truncated = true; break; }
        }
        if (!string.IsNullOrWhiteSpace(keyword))
            all = all.Where(e => e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)
                              || e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();

        var total = all.Count;

        // ── 1) 标签分布（全量）──
        var tagCounts = new Dictionary<string, int>(StringComparer.Ordinal);
        foreach (var e in all)
        {
            var key = e.Tag ?? "";
            tagCounts.TryGetValue(key, out var c);
            tagCounts[key] = c + 1;
        }
        var tags = tagCounts.OrderByDescending(kv => kv.Value)
            .Select(kv => new
            {
                tag = kv.Key,
                count = kv.Value,
                percent = total > 0 ? Math.Round(kv.Value * 100.0 / total, 2) : 0
            }).ToList();

        // ── 2) 时间范围 + 日志量直方图（稀疏桶 + Top6 标签拆分）──
        var timed = all.Where(e => e.Time != null).Select(e => e.Time!.Value).ToList();
        DateTime? start = timed.Count > 0 ? timed.Min() : (DateTime?)null;
        DateTime? end = timed.Count > 0 ? timed.Max() : (DateTime?)null;

        var topTagNames = tags.Where(t => t.tag != "").Take(6).Select(t => t.tag).ToList();
        var topSet = new HashSet<string>(topTagNames, StringComparer.Ordinal);
        var totalBuckets = new SortedDictionary<DateTime, int>();
        var tagBuckets = topTagNames.ToDictionary(t => t, _ => new Dictionary<DateTime, int>(), StringComparer.Ordinal);
        foreach (var e in all)
        {
            if (e.Time == null) continue;
            var b = TruncateTime(e.Time.Value, gran);
            totalBuckets.TryGetValue(b, out var c);
            totalBuckets[b] = c + 1;
            var tg = e.Tag ?? "";
            if (topSet.Contains(tg))
            {
                var d = tagBuckets[tg];
                d.TryGetValue(b, out var c2);
                d[b] = c2 + 1;
            }
        }
        var bucketTimes = totalBuckets.Keys.ToList();
        var volume = new
        {
            granularity = granName,
            buckets = bucketTimes,
            total = bucketTimes.Select(t => totalBuckets[t]).ToList(),
            topTags = topTagNames.Select(tg => new
            {
                tag = tg,
                counts = bucketTimes.Select(t => tagBuckets[tg].TryGetValue(t, out var v) ? v : 0).ToList()
            }).ToList()
        };

        // ── 3) 数值字段识别（受 tag 聚焦影响）──
        IEnumerable<LogEntry> scope = all;
        if (!string.IsNullOrWhiteSpace(tag))
            scope = all.Where(e => string.Equals(e.Tag, tag, StringComparison.Ordinal)).ToList();

        var fieldAgg = new Dictionary<string, FieldStat>(StringComparer.Ordinal);
        foreach (var e in scope)
        {
            foreach (Match m in NumericFieldRegex.Matches(e.Content))
            {
                if (!double.TryParse(m.Groups["v"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue;
                var k = m.Groups["k"].Value;
                if (!fieldAgg.TryGetValue(k, out var fs)) { fs = new FieldStat(); fieldAgg[k] = fs; }
                fs.N++; fs.Sum += v; fs.Last = v;
                if (v < fs.Min) fs.Min = v;
                if (v > fs.Max) fs.Max = v;
            }
        }
        var fields = fieldAgg.OrderByDescending(kv => kv.Value.N).Take(40).Select(kv => new
        {
            name = kv.Key,
            samples = kv.Value.N,
            min = kv.Value.Min,
            max = kv.Value.Max,
            avg = kv.Value.N > 0 ? Math.Round(kv.Value.Sum / kv.Value.N, 4) : 0,
            last = kv.Value.Last
        }).ToList();

        // ── 4) 选定字段时序（超量抽稀，保留分布形态）──
        object? series = null;
        if (!string.IsNullOrWhiteSpace(field))
        {
            var pts = new List<(DateTime t, double v)>();
            foreach (var e in scope)
            {
                if (e.Time == null) continue;
                foreach (Match m in NumericFieldRegex.Matches(e.Content))
                {
                    if (!string.Equals(m.Groups["k"].Value, field, StringComparison.Ordinal)) continue;
                    if (!double.TryParse(m.Groups["v"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue;
                    pts.Add((e.Time.Value, v));
                }
            }
            const int cap = 8000;
            if (pts.Count > cap)
            {
                var stride = (int)Math.Ceiling(pts.Count / (double)cap);
                pts = pts.Where((_, i) => i % stride == 0).ToList();
            }
            series = new
            {
                field,
                tag = tag ?? "",
                count = pts.Count,
                points = pts.Select(p => new { t = p.t, v = p.v }).ToList()
            };
        }

        return Ok(new
        {
            source, target = label, files = targets.Count, truncated,
            total,
            timeRange = new { start, end },
            granularity = granName,
            tags, volume, fields, series
        });
    }

    // ──────────────────────────────────────────────── 原文 / 下载 ──

    /// <summary>返回日志文件尾部 N 行原文（默认 2000 行），用于「查看原始日志」视图。</summary>
    [HttpGet("raw")]
    public IActionResult Raw([FromQuery] string file, [FromQuery] int tail = 2000)
    {
        var (root, _, error) = ResolveLogRoot();
        if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
        var full = SafeResolve(root, file);
        if (full == null) return BadRequest(new { message = "非法的文件路径" });
        if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });

        var clamp = Math.Clamp(tail, 1, 50000);
        try
        {
            var lines = System.IO.File.ReadLines(full).TakeLast(clamp).ToList();
            var text = string.Join("\n", lines);
            return Content(text, "text/plain; charset=utf-8");
        }
        catch (Exception ex)
        {
            _log.LogWarning(ex, "读取日志原文失败 file={File}", file);
            return Problem($"读取失败：{ex.Message}", statusCode: 500);
        }
    }

    /// <summary>下载原始日志文件。</summary>
    [HttpGet("download")]
    public IActionResult Download([FromQuery] string file)
    {
        var (root, _, error) = ResolveLogRoot();
        if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
        var full = SafeResolve(root, file);
        if (full == null) return BadRequest(new { message = "非法的文件路径" });
        if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });

        var downloadName = Path.GetFileName(full);
        return PhysicalFile(full, "application/octet-stream", downloadName);
    }

    // ─────────────────────────────────────────────────────── helpers ──

    /// <summary>定位日志根：优先 appsettings <c>Logs:Root</c>，否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
    private (string? root, string? workdir, string? error) ResolveLogRoot()
    {
        var overrideRoot = _config["Logs:Root"];
        if (!string.IsNullOrWhiteSpace(overrideRoot))
        {
            var r = Path.GetFullPath(overrideRoot);
            return (r, Path.GetDirectoryName(r), null);
        }

        var wd = _launcher.ResolveWorkingDirectory();
        if (string.IsNullOrWhiteSpace(wd))
            return (null, null,
                "未能定位 SimpleLite 工作目录，无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory，" +
                "或显式设置 Logs:Root 指向日志根目录。");

        return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
    }

    /// <summary>把相对路径安全解析到日志根内（防 ../ 目录穿越），并要求落在 root 子级。</summary>
    private static string? SafeResolve(string root, string? rel)
    {
        if (string.IsNullOrWhiteSpace(rel)) return null;
        var normRoot = Path.GetFullPath(root);
        var full = Path.GetFullPath(Path.Combine(normRoot, rel));
        if (string.Equals(full, normRoot, StringComparison.OrdinalIgnoreCase)) return null;
        var prefix = normRoot.EndsWith(Path.DirectorySeparatorChar) ? normRoot : normRoot + Path.DirectorySeparatorChar;
        return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? full : null;
    }

    /// <summary>把相对目录安全解析到日志根内（允许根自身；防 ../ 目录穿越）。用于目录浏览。</summary>
    private static string? SafeResolveDir(string root, string? rel)
    {
        var normRoot = Path.GetFullPath(root);
        if (string.IsNullOrWhiteSpace(rel) || rel == "/" || rel == ".") return normRoot;
        var full = Path.GetFullPath(Path.Combine(normRoot, rel));
        if (string.Equals(full, normRoot, StringComparison.OrdinalIgnoreCase)) return normRoot;
        var prefix = normRoot.EndsWith(Path.DirectorySeparatorChar) ? normRoot : normRoot + Path.DirectorySeparatorChar;
        return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? full : null;
    }

    /// <summary>相对路径标准化：反斜杠转正斜杠、去尾斜杠、根目录归一为空串。</summary>
    private static string NormalizeRel(string rel)
    {
        if (string.IsNullOrEmpty(rel) || rel == ".") return "";
        return rel.Replace('\\', '/').TrimEnd('/');
    }

    private static List<FileMeta> EnumerateLogFiles(string root)
    {
        var list = new List<FileMeta>();
        IEnumerable<string> files;
        try { files = Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories); }
        catch { return list; }

        foreach (var f in files)
        {
            try
            {
                var fi = new FileInfo(f);
                var rel = Path.GetRelativePath(root, f).Replace('\\', '/');
                var slash = rel.IndexOf('/');
                var day = slash > 0 ? rel[..slash] : "(根目录)";
                var dir = Path.GetDirectoryName(rel)?.Replace('\\', '/') ?? "";
                list.Add(new FileMeta(rel, fi.Name, day, dir, fi.Length, fi.LastWriteTime, f));
            }
            catch { /* 个别文件读元数据失败跳过 */ }
        }
        return list;
    }

    /// <summary>流式解析日志文件为条目；非标准行作为上一条的续行（多行内容）。</summary>
    private LogParseResult ParseFile(string full)
    {
        var entries = new List<LogEntry>();
        long bytes = 0;
        int lineNo = 0;
        long scanned = 0;
        bool truncated = false;

        try { bytes = new FileInfo(full).Length; } catch { /* ignore */ }

        try
        {
            foreach (var raw in System.IO.File.ReadLines(full))
            {
                lineNo++;
                scanned += raw.Length + 2; // 估算含换行
                var line = raw.TrimEnd('\r');
                var m = LineRegex.Match(line);
                if (m.Success)
                {
                    var head = m.Groups["head"].Value;
                    var tm = TimeRegex.Match(head);
                    if (tm.Success
                        && DateTime.TryParseExact(tm.Value, TimeFormat, CultureInfo.InvariantCulture,
                            DateTimeStyles.None, out var dt))
                    {
                        var tag = m.Groups["tag"].Value;
                        if (tag == "/") tag = "";
                        entries.Add(new LogEntry
                        {
                            LineNo = lineNo,
                            Time = dt,
                            Prefix = head[..tm.Index],
                            Tag = tag,
                            Content = m.Groups["content"].Value
                        });
                        if (entries.Count >= MaxEntries) { truncated = true; break; }
                    }
                    else
                    {
                        AppendContinuation(entries, line);
                    }
                }
                else
                {
                    AppendContinuation(entries, line);
                }

                if (scanned >= MaxScanBytes) { truncated = true; break; }
            }
        }
        catch (Exception ex)
        {
            _log.LogWarning(ex, "解析日志文件失败 file={File}", full);
        }

        return new LogParseResult(entries, bytes, lineNo, truncated);
    }

    private static void AppendContinuation(List<LogEntry> entries, string line)
    {
        if (entries.Count == 0)
        {
            // 文件开头就是无时间戳行：作为一条无标签、无时间的内容保留。
            entries.Add(new LogEntry { LineNo = 1, Time = null, Tag = "", Content = line });
            return;
        }
        var last = entries[^1];
        last.Content = string.IsNullOrEmpty(last.Content) ? line : last.Content + "\n" + line;
    }

    private static void Accumulate(Book b, LogEntry e, int maxPerTag)
    {
        b.Count++;
        if (e.Time != null)
        {
            if (b.FirstTime == null || e.Time < b.FirstTime) b.FirstTime = e.Time;
            if (b.LastTime == null || e.Time > b.LastTime) b.LastTime = e.Time;
        }
        b.Latest = e.Content;
        b.LatestTime = e.Time;
        b.Recent.Enqueue(e);
        // 仅保留最近 maxPerTag 条，避免高频标签把内存撑爆（Queue 头部出队 O(1)）。
        if (b.Recent.Count > maxPerTag) b.Recent.Dequeue();
    }

    private static object ToBookDto(Book b) => new
    {
        tag = b.Tag,
        count = b.Count,
        firstTime = b.FirstTime,
        lastTime = b.LastTime,
        latest = b.Latest,
        latestTime = b.LatestTime,
        entries = b.Recent.Select(Project).ToList()
    };

    private static object EmptyDigest(string source, string target) => new
    {
        source, target, files = 0, truncated = false,
        tagCount = 0, untaggedCount = 0,
        books = Array.Empty<object>(),
        untagged = new { tag = "", count = 0, entries = Array.Empty<object>() }
    };

    /// <summary>把时间戳截断到指定粒度的桶起点（用于直方图分桶）。</summary>
    private static DateTime TruncateTime(DateTime t, Gran g) => g switch
    {
        Gran.Second => new DateTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second, t.Kind),
        Gran.Hour => new DateTime(t.Year, t.Month, t.Day, t.Hour, 0, 0, t.Kind),
        _ => new DateTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, 0, t.Kind)
    };

    private static object EmptyAnalysis(string source, string target, string granName) => new
    {
        source, target, files = 0, truncated = false, total = 0,
        timeRange = new { start = (DateTime?)null, end = (DateTime?)null },
        granularity = granName,
        tags = Array.Empty<object>(),
        volume = new { granularity = granName, buckets = Array.Empty<DateTime>(), total = Array.Empty<int>(), topTags = Array.Empty<object>() },
        fields = Array.Empty<object>(),
        series = (object?)null
    };

    private static object Project(LogEntry e) => new
    {
        lineNo = e.LineNo, time = e.Time, prefix = e.Prefix, tag = e.Tag, content = e.Content
    };

    // ─────────────────────────────────────────────────────── 内部类型 ──

    private enum Gran { Second, Minute, Hour }

    /// <summary>「日志分析器」数值字段的累计统计。</summary>
    private sealed class FieldStat
    {
        public long N;
        public double Sum;
        public double Min = double.MaxValue;
        public double Max = double.MinValue;
        public double Last;
    }

    private readonly record struct FileMeta(
        string Rel, string Name, string Day, string Dir, long Bytes, DateTime Mtime, string Full);

    private sealed record LogParseResult(List<LogEntry> Entries, long Bytes, int ScannedLines, bool Truncated);

    private sealed class LogEntry
    {
        public int LineNo { get; set; }
        public DateTime? Time { get; set; }
        public string Prefix { get; set; } = "";
        public string Tag { get; set; } = "";
        public string Content { get; set; } = "";
    }

    private sealed class Book
    {
        public string Tag { get; set; } = "";
        public int Count { get; set; }
        public DateTime? FirstTime { get; set; }
        public DateTime? LastTime { get; set; }
        public string Latest { get; set; } = "";
        public DateTime? LatestTime { get; set; }
        public Queue<LogEntry> Recent { get; } = new();
    }
}
