using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Web.Script.Serialization;
using Ounibo.Catalog.Core;

namespace Ounibo.Catalog.GenServer
{
    // =============================================================================
    // NX 按需生成服务 (二期 A 版, 2026-08-25): 轻量自建队列 + run_journal 常驻 worker
    // 职责: HTTP 接口 + 检索先行 + 文件任务队列 + 输出目录留存复用 + 限流
    // 生成本体由 NX worker (worker_journal.vb, 每席位一个进程) 完成 — 本程序不碰 NX
    // 编译: tools\build-genserver.ps1 (csc, 控制台 exe, 无 WPF 依赖)
    // =============================================================================

    public class GenServerConfig
    {
        public int port = 8899;
        public string apiKey = "";
        public string adminKey = "";       // /admin 配置页密码; 留空 = 仅本机可访问 (127.0.0.1)
        public string dataDir = "";        // 系列 meta + CSV 网格 (与目录数据源同构: series\*.meta.json + csv\*.csv)
        public string mastersJson = "";    // masters.json: 母模/表达式映射/导出格式
        public string mastersDir = "";     // catalog-masters\<系列>\<母模>.prt
        public string outputDir = "";      // output\<系列>\<型号编码>\<多格式文件> (留存不删)
        public string jobsDir = "";        // jobs\job_<id>.json (.running/.done/.failed 状态机)
        public string workDir = "";        // work\<jobId>\ 任务私有母模副本 (任务后清理, TTL 2h)
        public string nxBin = "";          // NX 安装 NXBIN 目录 (run_journal.exe/step214ug.exe/iges.exe 所在); 留空自动探测 C:\Program Files\Siemens\NX*
        public int queueLimit = 200;
        public int batchLimit = 50;
        public int ratePerMin = 10;
        public int workTtlHours = 2;
        public int jobArchiveHours = 24;
        public int outputTtlHours = 0;     // 生成结果留存时长(小时); 0=永不清空(旧行为), >0=超时自动删除 (用户 2026-08-27 需求: 定时清空)
        public int sweepIntervalMin = 10;  // 清理线程间隔(分钟), 默认 10
    }

    /// <summary>系列配置包装 (与 FullCatalogBuilder.SeriesMeta 同构; 独立定义避免依赖打包器)。</summary>
    public class SeriesMeta
    {
        public Series series;
    }

    public class ServerData
    {
        public Dictionary<string, Series> Series;              // series\*.meta.json
        public Dictionary<string, HashSet<string>> GridCodes;  // csv\<CODE>.csv 首列 (标准档位网格)
        public Dictionary<string, MasterSpec> Masters;         // masters.json
    }

    public class MasterSpec
    {
        public string masterPart;
        public Dictionary<string, string> expressionMap;
        public List<List<string>> switchRules;   // [param, 值, NX表达式, 表达式值] (枚举开关)
        public Dictionary<string, object> exports;   // {"step":"214","parasolid":true,"iges":true,"stl":true}
        public int timeoutSec = 300;
    }

    public class JobItem
    {
        public string series;
        public string code;
        public Dictionary<string, object> @params;
    }

    public class Job
    {
        public string id;
        public string token;
        public List<JobItem> items;
        public long created;
    }

    public class JobResult
    {
        public bool ok;
        public string error;
        public List<JobItemResult> items;
    }

    public class JobItemResult
    {
        public string series;
        public string code;
        public bool ok;
        public bool hit;
        public string error;
        public List<string> files;
    }

    public static class GenServerApp
    {
        static JavaScriptSerializer _ser = new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
        static GenServerConfig _cfg;
        static string _cfgPath;
        static ServerData _data;
        static HttpListener _listener;
        static Dictionary<string, int> _rate;              // key → 最近一分钟请求数
        static long _rateWindowStart;
        static readonly object _rateLock = new object();

        static readonly Dictionary<string, string> ExtMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            { "step", ".stp" }, { "parasolid", ".x_t" }, { "iges", ".igs" }, { "stl", ".stl" }, { "prt", ".prt" }
        };

        public static int Main(string[] args)
        {
            try
            {
                var cfgPath = args.Length > 0 ? args[0] : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "genserver-config.json");
                _cfgPath = cfgPath;
                _cfg = _ser.Deserialize<GenServerConfig>(File.ReadAllText(cfgPath, Encoding.UTF8));
                if (_cfg == null) _cfg = new GenServerConfig();
                ResolvePaths();
                Console.WriteLine("[GenServer] 配置: " + cfgPath);
                _data = LoadData();
                _rate = new Dictionary<string, int>();
                _rateWindowStart = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMinute;

                // worker 配置 (行式 key=value, NX journal 无 JSON 库): 写到配置同目录, worker 以该目录为工作目录
                try
                {
                    var wc = new StringBuilder();
                    wc.AppendLine("jobsDir=" + _cfg.jobsDir);
                    wc.AppendLine("outputDir=" + _cfg.outputDir);
                    wc.AppendLine("mastersDir=" + _cfg.mastersDir);
                    wc.AppendLine("workDir=" + _cfg.workDir);
                    wc.AppendLine("nxBin=" + (_cfg.nxBin ?? ""));
                    File.WriteAllText(Path.Combine(Path.GetDirectoryName(cfgPath) ?? ".", "worker-config.txt"), wc.ToString(), Encoding.UTF8);
                    Console.WriteLine("[GenServer] 已生成 worker-config.txt (worker 以本配置目录为工作目录启动)");
                }
                catch (Exception ex) { Console.WriteLine("[GenServer] worker-config.txt 生成失败: " + ex.Message); }

                _listener = new HttpListener();
                _listener.Prefixes.Add(string.Format("http://+:{0}/", _cfg.port));
                try
                {
                    _listener.Start();
                    Console.WriteLine("[GenServer] 监听 http://0.0.0.0:" + _cfg.port + "/  (Ctrl+C 退出)");
                }
                catch (HttpListenerException)
                {
                    // 无 http.sys URL ACL (非管理员): 回退仅本机监听 (联调模式)
                    _listener = new HttpListener();
                    _listener.Prefixes.Add(string.Format("http://localhost:{0}/", _cfg.port));
                    _listener.Start();
                    Console.WriteLine("[GenServer] 警告: 无 URL ACL 权限, 仅本机可访问。生产部署先执行:");
                    Console.WriteLine("  netsh http add urlacl url=http://+:" + _cfg.port + "/ user=Everyone");
                }
                Console.WriteLine("[GenServer] 输出目录: " + _cfg.outputDir + " | 留存 TTL: " + (_cfg.outputTtlHours > 0 ? _cfg.outputTtlHours + "h" : "永不清空") + " | 队列上限: " + _cfg.queueLimit);

                // 后台清理: 任务私有工作目录 TTL + 过期 job 归档 + 生成结果 TTL 清空
                var cleaner = new Thread(() =>
                {
                    while (true)
                    {
                        try { SweepWork(); } catch { }
                        Thread.Sleep(Math.Max(1, _cfg.sweepIntervalMin) * 60 * 1000);
                    }
                });
                cleaner.IsBackground = true;
                cleaner.Start();

                while (_listener.IsListening)
                {
                    HttpListenerContext ctx;
                    try { ctx = _listener.GetContext(); }
                    catch { break; }
                    ThreadPool.QueueUserWorkItem(_ => { try { Handle(ctx); } catch (Exception ex) { TryWriteJson(ctx, 500, new { error = ex.Message }); } });
                }
                return 0;
            }
            catch (Exception ex)
            {
                Console.WriteLine("[GenServer] 启动失败: " + ex.Message);
                return 1;
            }
        }

        static void ResolvePaths()
        {
            var baseDir = AppDomain.CurrentDomain.BaseDirectory;
            string R(string p) { return string.IsNullOrEmpty(p) ? null : (Path.IsPathRooted(p) ? p : Path.GetFullPath(Path.Combine(baseDir, p))); }
            _cfg.dataDir = R(_cfg.dataDir);
            _cfg.mastersJson = R(_cfg.mastersJson);
            _cfg.mastersDir = R(_cfg.mastersDir);
            _cfg.outputDir = R(_cfg.outputDir);
            _cfg.jobsDir = R(_cfg.jobsDir);
            _cfg.workDir = R(_cfg.workDir);
            if (string.IsNullOrEmpty(_cfg.nxBin)) _cfg.nxBin = AutoDetectNxBin();
            foreach (var d in new[] { _cfg.outputDir, _cfg.jobsDir, _cfg.workDir })
                if (d != null) Directory.CreateDirectory(d);
        }

        // 自动探测 NX 安装目录 (部署到新机器无需手填): C:\Program Files\Siemens\NX*\NXBIN\run_journal.exe
        static string AutoDetectNxBin()
        {
            foreach (var root in new[] {
                Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Siemens"),
                Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Siemens"),
                @"C:\Program Files\Siemens" })
            {
                if (!Directory.Exists(root)) continue;
                foreach (var ver in Directory.GetDirectories(root, "NX*"))
                {
                    var bin = Path.Combine(ver, "NXBIN");
                    if (File.Exists(Path.Combine(bin, "run_journal.exe"))) return bin;
                }
            }
            return null;
        }

        static ServerData LoadData()
        {
            var d = new ServerData { Series = new Dictionary<string, Series>(StringComparer.OrdinalIgnoreCase), GridCodes = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase) };
            var seriesDir = Path.Combine(_cfg.dataDir, "series");
            foreach (var f in Directory.GetFiles(seriesDir, "*.meta.json"))
            {
                try
                {
                    var meta = _ser.Deserialize<SeriesMeta>(File.ReadAllText(f, Encoding.UTF8));
                    if (meta != null && meta.series != null) d.Series[meta.series.code] = meta.series;
                }
                catch (Exception ex) { Console.WriteLine("[GenServer] 系列配置解析失败 " + Path.GetFileName(f) + ": " + ex.Message); }
            }
            var csvDir = Path.Combine(_cfg.dataDir, "csv");
            if (Directory.Exists(csvDir))
                foreach (var f in Directory.GetFiles(csvDir, "*.csv"))
                {
                    var code = Path.GetFileNameWithoutExtension(f);
                    var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                    try
                    {
                        var lines = File.ReadAllLines(f, Encoding.UTF8).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
                        foreach (var l in lines.Skip(1))
                        {
                            var i = l.IndexOf(',');
                            set.Add(i < 0 ? l.Trim() : l.Substring(0, i).Trim());
                        }
                    }
                    catch { }
                    d.GridCodes[code] = set;
                }
            d.Masters = new Dictionary<string, MasterSpec>(StringComparer.OrdinalIgnoreCase);
            if (_cfg.mastersJson != null && File.Exists(_cfg.mastersJson))
            {
                // 先解析为通用对象再逐条转换: 容忍 "note" 等说明字段 (非对象条目跳过)
                var raw = _ser.DeserializeObject(File.ReadAllText(_cfg.mastersJson, Encoding.UTF8)) as Dictionary<string, object>;
                if (raw != null)
                    foreach (var kv in raw)
                        if (kv.Value is Dictionary<string, object>)
                            try { d.Masters[kv.Key] = _ser.ConvertToType<MasterSpec>(kv.Value); } catch { }
            }
            Console.WriteLine("[GenServer] 数据: " + d.Series.Count + " 系列, " + d.GridCodes.Count + " 网格表, " + d.Masters.Count + " 母模配置");
            return d;
        }

        // ---- 检索先行: 输出目录命中判定 (目录存在且配置的全部格式文件齐全) ----
        static bool OutputHit(string series, string code, MasterSpec m)
        {
            var dir = Path.Combine(_cfg.outputDir, SafeName(series), SafeName(code));
            if (!Directory.Exists(dir)) return false;
            foreach (var fmt in ExportsOf(m))
            {
                string ext;
                if (!ExtMap.TryGetValue(fmt, out ext)) ext = "." + fmt;
                if (!File.Exists(Path.Combine(dir, code + ext))) return false;
            }
            return true;
        }

        static List<string> ExportsOf(MasterSpec m)
        {
            var list = new List<string>();
            if (m == null || m.exports == null) { list.Add("step"); return list; }
            foreach (var kv in m.exports)
                if (kv.Key == "step" || Convert.ToBoolean(kv.Value)) list.Add(kv.Key);
            return list;
        }

        static string SafeName(string s)
        {
            var r = new StringBuilder();
            foreach (var c in (s ?? ""))
                if (char.IsLetterOrDigit(c) || c == '-' || c == '_') r.Append(c);
            return r.Length == 0 ? "_" : r.ToString();
        }

        // ---- HTTP ----
        static void Handle(HttpListenerContext ctx)
        {
            var req = ctx.Request;
            var path = req.Url.AbsolutePath.TrimEnd('/');
            var qs = req.QueryString;

            // CORS: 网页站跨域调用生成服务 (预检 + 常规响应头)
            ctx.Response.AddHeader("Access-Control-Allow-Origin", "*");
            ctx.Response.AddHeader("Access-Control-Allow-Headers", "Content-Type, X-Api-Key");
            ctx.Response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
            if (req.HttpMethod == "OPTIONS")
            {
                ctx.Response.StatusCode = 204;
                ctx.Response.Close();
                return;
            }

            if (path == "/health")
            {
                TryWriteJson(ctx, 200, HealthStatus());
                return;
            }

            // /admin 配置页 (部署到新机器时的 NX/目录/端口 配置入口; 密码登录保护)
            if (path == "/admin" || path == "/admin-config" || path == "/admin-save" || path == "/admin-masters" || path == "/admin-masters-save" || path == "/admin-login" || path == "/admin-clear-output")
            {
                HandleAdmin(ctx, path);
                return;
            }

            // 限流 (API key 维度, 滑动分钟窗口)
            var key = req.Headers["X-Api-Key"] ?? "";
            if (!RateOk(key))
            {
                TryWriteJson(ctx, 429, new { error = "too_many_requests" });
                return;
            }
            // apiKey 为空 = 匿名模式 (公开网页站点无法保密密钥; 仅限流兜底, 公网部署建议反向代理加白名单)
            if (!string.IsNullOrEmpty(_cfg.apiKey) && key != _cfg.apiKey)
            {
                TryWriteJson(ctx, 401, new { error = "unauthorized" });
                return;
            }

            if (path == "/gen" && req.HttpMethod == "POST")
            {
                var body = ReadBody(req);
                var j = _ser.Deserialize<Dictionary<string, object>>(body);
                var series = Str(j, "series");
                var code = Str(j, "code");
                var pars = j.ContainsKey("params") ? j["params"] as Dictionary<string, object> : null;
                HandleGen(ctx, new List<Dictionary<string, object>> { j });
                return;
            }
            if (path == "/gen-batch" && req.HttpMethod == "POST")
            {
                var body = ReadBody(req);
                var j = _ser.Deserialize<Dictionary<string, object>>(body);
                // 注意: 泛型 Deserialize 路径下 JSON 数组是 ArrayList 而非 object[] (踩坑 #23)
                var items = new List<Dictionary<string, object>>();
                var raw = j.ContainsKey("items") ? j["items"] as System.Collections.IEnumerable : null;
                if (raw != null)
                    foreach (var it in raw)
                        if (it is Dictionary<string, object>) items.Add((Dictionary<string, object>)it);
                if (items.Count == 0 || items.Count > _cfg.batchLimit)
                {
                    TryWriteJson(ctx, 400, new { error = "invalid_batch" });
                    return;
                }
                HandleGen(ctx, items);
                return;
            }
            if (path == "/status")
            {
                TryWriteJson(ctx, 200, JobStatus(qs["job"]));
                return;
            }
            if (path == "/file")
            {
                ServeModelZip(ctx, qs["job"], qs["token"], qs["code"]);
                return;
            }
            if (path == "/files")
            {
                ServeJobZip(ctx, qs["job"], qs["token"]);
                return;
            }
            if (path == "/download")
            {
                // 直接下载 (检索命中/已留存场景, 无任务凭证): 全格式 zip 或单格式文件 (fmt)
                ServeDownloadZip(ctx, qs["series"], qs["code"], qs["fmt"]);
                return;
            }
            if (path.StartsWith("/preview/"))
            {
                // 网页内 3D 预览: /preview/{series}/{code}.stp 返回 inline STEP (路径带 .stp, 查看器按 url= 加载)
                ServePreview(ctx, path.Substring("/preview/".Length));
                return;
            }
            TryWriteJson(ctx, 404, new { error = "not_found" });
        }

        // ---- /health 状态 (网页端实时显示: 服务/worker/NX 是否就绪) ----
        static object HealthStatus()
        {
            int queued = 0, running = 0;
            try
            {
                if (_cfg != null && _cfg.jobsDir != null && Directory.Exists(_cfg.jobsDir))
                {
                    queued = Directory.GetFiles(_cfg.jobsDir, "job_*.json").Length;
                    running = Directory.GetFiles(_cfg.jobsDir, "job_*.running").Length;
                }
            }
            catch { }
            bool workerAlive = false;
            string workerLast = "";
            try
            {
                if (_cfg != null && _cfg.workDir != null && Directory.Exists(_cfg.workDir))
                {
                    var hbs = Directory.GetFiles(_cfg.workDir, "heartbeat_*.txt");
                    foreach (var h in hbs)
                    {
                        try
                        {
                            var t = File.GetLastWriteTimeUtc(h);
                            if ((DateTime.UtcNow - t).TotalMinutes < 2) workerAlive = true;
                            if (workerLast == "" || t > DateTime.UtcNow.AddMinutes(-2)) workerLast = t.ToLocalTime().ToString("HH:mm:ss");
                        }
                        catch { }
                    }
                }
            }
            catch { }
            bool nxOk = !string.IsNullOrEmpty(_cfg.nxBin) && Directory.Exists(_cfg.nxBin);
            return new
            {
                ok = true,
                state = workerAlive ? "ready" : (queued + running > 0 ? "no_worker" : "idle"),
                workerAlive = workerAlive,
                workerLast = workerLast,
                nxOk = nxOk,
                nxBin = _cfg.nxBin,
                queued = queued,
                running = running,
                port = _cfg.port
            };
        }

        // ---- /admin 配置页 (部署到新机器时的 NX/目录/端口 配置入口) ----
        static void HandleAdmin(HttpListenerContext ctx, string path)
        {
            // 登录端点: POST {pwd} → 校验 adminKey → 种 cookie
            if (path == "/admin-login" && ctx.Request.HttpMethod == "POST")
            {
                try
                {
                    var b = ReadBody(ctx.Request);
                    var j = _ser.Deserialize<Dictionary<string, object>>(b);
                    var pwd = Str(j, "pwd");
                    if (string.IsNullOrEmpty(_cfg.adminKey) || pwd == _cfg.adminKey)
                    {
                        ctx.Response.AddHeader("Set-Cookie", "adminauth=1; Path=/; HttpOnly");
                        TryWriteJson(ctx, 200, new { ok = true });
                    }
                    else
                    {
                        TryWriteJson(ctx, 401, new { error = "wrong_password" });
                    }
                }
                catch (Exception ex) { TryWriteJson(ctx, 500, new { error = ex.Message }); }
                return;
            }

            // 访问保护: adminKey 非空 → 需 cookie adminauth=1 或查询串 k=adminKey; 否则仅本机回环
            if (!string.IsNullOrEmpty(_cfg.adminKey))
            {
                var ck = ctx.Request.Cookies["adminauth"];
                var k = ctx.Request.QueryString["k"] ?? "";
                var authed = (ck != null && ck.Value == "1") || k == _cfg.adminKey;
                if (!authed)
                {
                    // 未登录 → 渲染登录页
                    if (path == "/admin") { TryWriteHtml(ctx, AdminLoginHtml()); return; }
                    TryWriteJson(ctx, 401, new { error = "admin_unauthorized" });
                    return;
                }
            }
            else
            {
                var remote = ctx.Request.RemoteEndPoint;
                if (remote == null || !IsLoopback(remote.Address))
                {
                    TryWriteJson(ctx, 403, new { error = "admin_local_only" });
                    return;
                }
            }

            if (path == "/admin-masters")
            {
                // 返回 masters.json 原文 (参数映射表; 供后台编辑)
                try
                {
                    var txt = File.Exists(_cfg.mastersJson) ? File.ReadAllText(_cfg.mastersJson, Encoding.UTF8) : "{}";
                    TryWriteJson(ctx, 200, new { masters = txt, path = _cfg.mastersJson });
                }
                catch (Exception ex) { TryWriteJson(ctx, 500, new { error = ex.Message }); }
                return;
            }

            if (path == "/admin-masters-save" && ctx.Request.HttpMethod == "POST")
            {
                // 保存 masters.json (参数映射表; JSON 校验通过后写回并热重载)
                try
                {
                    var body = ReadBody(ctx.Request);
                    var j = _ser.Deserialize<Dictionary<string, object>>(body);
                    var txt = Str(j, "masters");
                    // 校验 JSON 合法 (能反序列化为对象即可)
                    _ser.DeserializeObject(txt);
                    File.WriteAllText(_cfg.mastersJson, txt, Encoding.UTF8);
                    _data = LoadData();   // 热重载母模配置
                    TryWriteJson(ctx, 200, new { ok = true });
                }
                catch (Exception ex) { TryWriteJson(ctx, 400, new { error = "invalid_json: " + ex.Message }); }
                return;
            }

            if (path == "/admin-clear-output" && ctx.Request.HttpMethod == "POST")
            {
                // 手动清空生成数模 (output 目录全删)
                try
                {
                    int n = 0;
                    if (_cfg.outputDir != null && Directory.Exists(_cfg.outputDir))
                        foreach (var sd in Directory.GetDirectories(_cfg.outputDir))
                            foreach (var md in Directory.GetDirectories(sd))
                            { Directory.Delete(md, true); n++; }
                    TryWriteJson(ctx, 200, new { ok = true, cleared = n });
                }
                catch (Exception ex) { TryWriteJson(ctx, 500, new { error = ex.Message }); }
                return;
            }

            if (path == "/admin-config")
            {
                // 返回当前配置 + NX 探测结果 (不返回 adminKey 明文)
                var nxCandidates = DetectNxBinAll();
                TryWriteJson(ctx, 200, new
                {
                    port = _cfg.port,
                    apiKey = _cfg.apiKey,
                    hasAdminKey = !string.IsNullOrEmpty(_cfg.adminKey),
                    dataDir = _cfg.dataDir,
                    mastersJson = _cfg.mastersJson,
                    mastersDir = _cfg.mastersDir,
                    outputDir = _cfg.outputDir,
                    jobsDir = _cfg.jobsDir,
                    workDir = _cfg.workDir,
                    nxBin = _cfg.nxBin,
                    nxCandidates = nxCandidates,
                    queueLimit = _cfg.queueLimit,
                    batchLimit = _cfg.batchLimit,
                    ratePerMin = _cfg.ratePerMin,
                    outputTtlHours = _cfg.outputTtlHours
                });
                return;
            }

            if (path == "/admin-save" && ctx.Request.HttpMethod == "POST")
            {
                var body = ReadBody(ctx.Request);
                var j = _ser.Deserialize<Dictionary<string, object>>(body);
                if (j.ContainsKey("port")) _cfg.port = Convert.ToInt32(j["port"]);
                if (j.ContainsKey("apiKey")) _cfg.apiKey = Str(j, "apiKey");
                if (j.ContainsKey("adminKey") && Str(j, "adminKey").Length > 0) _cfg.adminKey = Str(j, "adminKey");
                if (j.ContainsKey("dataDir") && Str(j, "dataDir").Length > 0) _cfg.dataDir = Str(j, "dataDir");
                if (j.ContainsKey("mastersJson") && Str(j, "mastersJson").Length > 0) _cfg.mastersJson = Str(j, "mastersJson");
                if (j.ContainsKey("mastersDir") && Str(j, "mastersDir").Length > 0) _cfg.mastersDir = Str(j, "mastersDir");
                if (j.ContainsKey("outputDir") && Str(j, "outputDir").Length > 0) _cfg.outputDir = Str(j, "outputDir");
                if (j.ContainsKey("jobsDir") && Str(j, "jobsDir").Length > 0) _cfg.jobsDir = Str(j, "jobsDir");
                if (j.ContainsKey("workDir") && Str(j, "workDir").Length > 0) _cfg.workDir = Str(j, "workDir");
                if (j.ContainsKey("nxBin") && Str(j, "nxBin").Length > 0) _cfg.nxBin = Str(j, "nxBin");
                if (j.ContainsKey("queueLimit")) _cfg.queueLimit = Convert.ToInt32(j["queueLimit"]);
                if (j.ContainsKey("batchLimit")) _cfg.batchLimit = Convert.ToInt32(j["batchLimit"]);
                if (j.ContainsKey("ratePerMin")) _cfg.ratePerMin = Convert.ToInt32(j["ratePerMin"]);
                if (j.ContainsKey("outputTtlHours")) _cfg.outputTtlHours = Convert.ToInt32(j["outputTtlHours"]);
                ResolvePaths();
                WriteWorkerConfig();
                SaveConfigFile();
                TryWriteJson(ctx, 200, new { ok = true, nxBin = _cfg.nxBin });
                return;
            }

            // 默认 /admin → 渲染 HTML 配置表单
            TryWriteHtml(ctx, AdminPageHtml());
        }

        static bool IsLoopback(System.Net.IPAddress a)
        {
            return System.Net.IPAddress.IsLoopback(a) || a.Equals(System.Net.IPAddress.Any) || a.Equals(System.Net.IPAddress.IPv6Any);
        }

        // 探测所有 NX*\NXBIN 目录 (支持 2506/2512 多版本, 供配置页下拉选择)
        static List<string> DetectNxBinAll()
        {
            var list = new List<string>();
            foreach (var root in new[] {
                Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Siemens"),
                Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Siemens"),
                @"C:\Program Files\Siemens" })
            {
                if (!Directory.Exists(root)) continue;
                foreach (var ver in Directory.GetDirectories(root, "NX*"))
                {
                    var bin = Path.Combine(ver, "NXBIN");
                    if (File.Exists(Path.Combine(bin, "run_journal.exe")) && !list.Contains(bin)) list.Add(bin);
                }
            }
            list.Sort();
            return list;
        }

        static void WriteWorkerConfig()
        {
            try
            {
                var wc = new StringBuilder();
                wc.AppendLine("jobsDir=" + _cfg.jobsDir);
                wc.AppendLine("outputDir=" + _cfg.outputDir);
                wc.AppendLine("mastersDir=" + _cfg.mastersDir);
                wc.AppendLine("workDir=" + _cfg.workDir);
                wc.AppendLine("nxBin=" + (_cfg.nxBin ?? ""));
                File.WriteAllText(Path.Combine(Path.GetDirectoryName(_cfgPath) ?? ".", "worker-config.txt"), wc.ToString(), Encoding.UTF8);
            }
            catch (Exception ex) { Console.WriteLine("[GenServer] worker-config.txt 写失败: " + ex.Message); }
        }

        static void SaveConfigFile()
        {
            File.WriteAllText(_cfgPath, _ser.Serialize(_cfg), Encoding.UTF8);
            Console.WriteLine("[GenServer] 配置已保存: " + _cfgPath);
        }

        static string HtmlEnc(string s)
        {
            return (s ?? "").Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
        }

        static string AdminLoginHtml()
        {
            return @"<!DOCTYPE html><html lang='zh'><head><meta charset='utf-8'>
<title>NX 生成服务 · 登录</title>
<style>body{font-family:'Microsoft YaHei',sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f0f2f5}
.card{background:#fff;padding:40px;border-radius:8px;box-shadow:0 2px 16px rgba(0,0,0,.12);width:320px;text-align:center}
h1{font-size:18px;margin:0 0 24px;color:#1b4f8c}
input{width:100%;padding:10px;font-size:15px;box-sizing:border-box;border:1px solid #ccc;border-radius:4px;margin-bottom:16px}
button{width:100%;padding:10px;font-size:15px;background:#0a6;color:#fff;border:none;border-radius:4px;cursor:pointer}
button:hover{background:#086}#err{color:#c62828;font-size:13px;margin-top:12px;min-height:18px}</style></head><body>
<div class='card'>
<h1>NX 生成服务 · 管理登录</h1>
<input id='pwd' type='password' placeholder='请输入管理密码' autofocus>
<button onclick='login()'>登录</button>
<div id='err'></div>
</div>
<script>
document.getElementById('pwd').addEventListener('keydown',function(e){if(e.key==='Enter')login();});
function login(){
  var p=document.getElementById('pwd').value;
  fetch('/admin-login',{method:'POST',body:JSON.stringify({pwd:p})})
    .then(function(r){return r.json().then(function(j){return {ok:r.ok,j:j};});})
    .then(function(o){
      if(o.ok){ location.href='/admin'; }
      else { document.getElementById('err').textContent='密码错误'; }
    })
    .catch(function(){document.getElementById('err').textContent='登录失败，请重试';});
}
</script></body></html>";
        }

        static string AdminPageHtml()
        {
            var nxOptions = new StringBuilder();
            foreach (var c in DetectNxBinAll())
                nxOptions.Append("<option value=\"").Append(HtmlEnc(c)).Append("\"")
                          .Append(c == _cfg.nxBin ? " selected" : "").Append(">")
                          .Append(HtmlEnc(c)).Append("</option>");
            var html = @"<!DOCTYPE html><html lang='zh'><head><meta charset='utf-8'>
<title>NX 生成服务配置</title>
<style>body{font-family:'Microsoft YaHei',sans-serif;max-width:760px;margin:0 auto;padding:0 16px;color:#222}
.tabs{display:flex;border-bottom:2px solid #ddd;margin:20px 0 0}
.tabs .tab{padding:12px 24px;font-size:15px;cursor:pointer;color:#666;border-bottom:3px solid transparent;margin-bottom:-2px}
.tabs .tab.active{color:#1b4f8c;border-bottom-color:#1b4f8c;font-weight:bold}
.panel{display:none}.panel.active{display:block;padding-top:16px}
label{display:block;margin:14px 0 4px;font-size:13px;color:#555}
input,select,textarea{width:100%;padding:8px;font-size:14px;box-sizing:border-box;border:1px solid #ccc;border-radius:4px}
button{margin-top:20px;padding:10px 24px;font-size:15px;background:#0a6;color:#fff;border:none;border-radius:4px;cursor:pointer}
button:hover{background:#086}.btn2{background:#4a90d9}.btn3{background:#c62828}
.hint{font-size:12px;color:#999;margin-top:2px}
.msg{margin-top:12px;font-weight:bold}</style></head><body>
<div class='tabs'>
  <div class='tab active' id='tabUg' onclick='switchTab(1)'>UG 配置</div>
  <div class='tab' id='tabParam' onclick='switchTab(2)'>参数配置</div>
  <div class='tab' id='tabModel' onclick='switchTab(3)'>模型管理</div>
</div>

<div class='panel active' id='panelUg'>
<h1 style='font-size:18px'>UG / NX 部署配置</h1>
<p class='hint'>保存后需重启 GenServer 使端口/密钥生效（目录项即时生效）。</p>
<label>NX 版本目录 (NXBIN)</label>
<select id='nxBin'>" + nxOptions.ToString() + @"<option value=''>（未探测到，手动填下方）</option></select>
<input id='nxBinCustom' placeholder='手动填 NXBIN 完整路径，如 C:\Program Files\Siemens\NX2506\NXBIN'>
<label>数据源目录 dataDir</label><input id='dataDir'>
<label>母模目录 mastersDir</label><input id='mastersDir'>
<label>masters.json 路径</label><input id='mastersJson'>
<label>输出目录 outputDir (留存不删)</label><input id='outputDir'>
<label>任务目录 jobsDir</label><input id='jobsDir'>
<label>工作目录 workDir</label><input id='workDir'>
<label>端口 port</label><input id='port' type='number'>
<label>API Key (留空=匿名模式)</label><input id='apiKey'>
<label>管理页密码 adminKey (留空=仅本机可访问)</label><input id='adminKey' type='password'>
<label>队列上限 queueLimit</label><input id='queueLimit' type='number'>
<label>批量上限 batchLimit</label><input id='batchLimit' type='number'>
<label>限流 ratePerMin</label><input id='ratePerMin' type='number'>
<button onclick='save()'>保存配置</button><div class='msg' id='msg'></div>
</div>

<div class='panel' id='panelParam'>
<h1 style='font-size:18px'>参数映射配置</h1>
<p class='hint'>母模 ↔ 目录参数的映射表（expressionMap / switchRules / exports）。保存后即时热重载，无需重启。</p>
<textarea id='mastersTxt' rows='26' style='font-family:Consolas,monospace;font-size:12px'></textarea>
<button onclick='saveMasters()' class='btn2'>保存参数映射</button><div class='msg' id='msgM'></div>
</div>

<div class='panel' id='panelModel'>
<h1 style='font-size:18px'>模型管理</h1>
<p class='hint'>管理已生成的数模（output 目录）：手动清理 或 设置定时清理。</p>
<hr style='margin:20px 0 8px'>
<h2 style='font-size:15px'>定时清理</h2>
<label>生成结果留存时长 outputTtlHours (小时, 0=永不清空)</label><input id='outputTtlHours' type='number'>
<p class='hint'>设 0 = 永不清空；设 N = 生成后 N 小时自动删除。保存后即时生效。</p>
<button onclick='saveTtl()'>保存定时清理</button><div class='msg' id='msgT'></div>
<hr style='margin:24px 0 8px'>
<h2 style='font-size:15px'>手动清理</h2>
<p class='hint'>立即删除 output 目录下所有已生成的数模。清空后重新生成需重新排队。</p>
<button onclick='clearOutput()' class='btn3'>清空全部生成数模</button><div class='msg' id='msgC'></div>
</div>

<script>
function switchTab(n){
  document.getElementById('tabUg').className='tab'+(n===1?' active':'');
  document.getElementById('tabParam').className='tab'+(n===2?' active':'');
  document.getElementById('tabModel').className='tab'+(n===3?' active':'');
  document.getElementById('panelUg').className='panel'+(n===1?' active':'');
  document.getElementById('panelParam').className='panel'+(n===2?' active':'');
  document.getElementById('panelModel').className='panel'+(n===3?' active':'');
}
fetch('/admin-config').then(r=>r.json()).then(c=>{
  document.getElementById('dataDir').value=c.dataDir||'';
  document.getElementById('mastersDir').value=c.mastersDir||'';
  document.getElementById('mastersJson').value=c.mastersJson||'';
  document.getElementById('outputDir').value=c.outputDir||'';
  document.getElementById('jobsDir').value=c.jobsDir||'';
  document.getElementById('workDir').value=c.workDir||'';
  document.getElementById('port').value=c.port;
  document.getElementById('apiKey').value=c.apiKey||'';
  document.getElementById('queueLimit').value=c.queueLimit;
  document.getElementById('batchLimit').value=c.batchLimit;
  document.getElementById('ratePerMin').value=c.ratePerMin;
  document.getElementById('outputTtlHours').value=c.outputTtlHours;
  var nb=document.getElementById('nxBin');
  if(c.nxBin && !nb.value){document.getElementById('nxBinCustom').value=c.nxBin;}
});
fetch('/admin-masters').then(r=>r.json()).then(j=>{ document.getElementById('mastersTxt').value=j.masters||'{}'; });
function save(){
  var body={
    dataDir:document.getElementById('dataDir').value,
    mastersDir:document.getElementById('mastersDir').value,
    mastersJson:document.getElementById('mastersJson').value,
    outputDir:document.getElementById('outputDir').value,
    jobsDir:document.getElementById('jobsDir').value,
    workDir:document.getElementById('workDir').value,
    port:document.getElementById('port').value,
    apiKey:document.getElementById('apiKey').value,
    adminKey:document.getElementById('adminKey').value,
    queueLimit:document.getElementById('queueLimit').value,
    batchLimit:document.getElementById('batchLimit').value,
    ratePerMin:document.getElementById('ratePerMin').value,
    outputTtlHours:document.getElementById('outputTtlHours').value,
    nxBin:(document.getElementById('nxBin').value||document.getElementById('nxBinCustom').value)
  };
  fetch('/admin-save',{method:'POST',body:JSON.stringify(body)}).then(r=>r.json()).then(j=>{
    document.getElementById('msg').textContent=j.ok?('已保存, NX目录='+j.nxBin):('失败: '+JSON.stringify(j));
  });
}
function saveMasters(){
  fetch('/admin-masters-save',{method:'POST',body:JSON.stringify({masters:document.getElementById('mastersTxt').value})})
    .then(r=>r.json()).then(j=>{
      document.getElementById('msgM').textContent=j.ok?'已保存并热重载':('失败: '+JSON.stringify(j));
    });
}
function clearOutput(){
  if(!confirm('确认清空全部已生成的数模？此操作不可撤销。')) return;
  fetch('/admin-clear-output',{method:'POST'})
    .then(r=>r.json()).then(j=>{
      document.getElementById('msgC').textContent=j.ok?('已清空 '+j.cleared+' 个型号'):('失败: '+JSON.stringify(j));
    });
}
function saveTtl(){
  fetch('/admin-save',{method:'POST',body:JSON.stringify({outputTtlHours:document.getElementById('outputTtlHours').value})})
    .then(r=>r.json()).then(j=>{
      document.getElementById('msgT').textContent=j.ok?('已保存定时清理设置'):('失败: '+JSON.stringify(j));
    });
}
</script></body></html>";
            return html;
        }

        static void TryWriteHtml(HttpListenerContext ctx, string html)
        {
            try
            {
                var bytes = Encoding.UTF8.GetBytes(html);
                ctx.Response.StatusCode = 200;
                ctx.Response.ContentType = "text/html; charset=utf-8";
                ctx.Response.ContentLength64 = bytes.Length;
                ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
                ctx.Response.Close();
            }
            catch { }
        }

        static bool RateOk(string key)
        {
            lock (_rateLock)
            {
                var now = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMinute;
                if (now != _rateWindowStart) { _rate.Clear(); _rateWindowStart = now; }
                int n;
                _rate.TryGetValue(key, out n);
                if (n >= _cfg.ratePerMin) return false;
                _rate[key] = n + 1;
                return true;
            }
        }

        // 单个/批量统一入口: 逐项校验 + 检索先行, 未命中项写一个任务 (单个=1项批量)
        static void HandleGen(HttpListenerContext ctx, List<Dictionary<string, object>> items)
        {
            var hitCodes = new List<string>();
            var miss = new List<JobItem>();
            foreach (var j in items)
            {
                var series = Str(j, "series");
                var code = Str(j, "code");
                var pars = j.ContainsKey("params") ? j["params"] as Dictionary<string, object> : null;
                if (pars == null)
                {
                    TryWriteJson(ctx, 400, new { error = "invalid_params" });
                    return;
                }
                string err = Validate(series, code, pars);
                if (err != null)
                {
                    TryWriteJson(ctx, 400, new { error = err });
                    return;
                }
                var m = _data.Masters.ContainsKey(series) ? _data.Masters[series] : null;
                if (OutputHit(series, code, m)) hitCodes.Add(code);
                else miss.Add(new JobItem { series = series, code = code, @params = pars });
            }
            if (miss.Count == 0)
            {
                TryWriteJson(ctx, 200, new { immediate = true, hitCodes = hitCodes });
                return;
            }
            lock (_rateLock)
            {
                if (CountQueued() >= _cfg.queueLimit)
                {
                    TryWriteJson(ctx, 429, new { error = "queue_busy" });
                    return;
                }
            }
            var job = new Job
            {
                id = Guid.NewGuid().ToString("N").Substring(0, 12),
                token = Guid.NewGuid().ToString("N"),
                items = miss,
                created = DateTime.UtcNow.Ticks
            };
            WriteJob(job);
            TryWriteJson(ctx, 200, new { jobId = job.id, token = job.token, hitCodes = hitCodes });
        }

        // 服务端二次校验 (与目录端同一引擎): 系列存在且参数化 + 域/规则 + 编码模板一致 + 非网格
        static string Validate(string series, string code, Dictionary<string, object> pars)
        {
            Series s;
            if (!_data.Series.TryGetValue(series, out s)) return "unknown_series";
            if (!ParametricEngine.IsParametric(s)) return "not_parametric";
            if (string.IsNullOrEmpty(code) || !System.Text.RegularExpressions.Regex.IsMatch(code, "^[A-Za-z0-9][A-Za-z0-9_\\-]*$"))
                return "invalid_code";
            if (ParametricEngine.ValidateCombo(s, pars) != null) return "invalid_params";
            var regen = ParametricEngine.RenderCode(s.modelCodeTemplate, pars);
            if (regen == null || !string.Equals(regen, code, StringComparison.OrdinalIgnoreCase)) return "code_mismatch";
            HashSet<string> grid;
            if (_data.GridCodes.TryGetValue(series, out grid) && grid.Contains(code)) return "standard_grid";
            if (!_data.Masters.ContainsKey(series)) return "no_master";
            return null;
        }

        // ---- 任务文件 ----
        static int CountQueued()
        {
            try { return Directory.GetFiles(_cfg.jobsDir, "job_*.json").Length; }
            catch { return int.MaxValue; }
        }

        static void WriteJob(Job job)
        {
            var path = Path.Combine(_cfg.jobsDir, "job_" + job.id + ".json");
            // 行式键值格式 (worker 是 NX journal, 无 JSON 库; 母模/映射/开关/导出配置全部烘进任务)
            var sb = new StringBuilder();
            foreach (var it in job.items)
            {
                sb.AppendLine("ITEM");
                sb.AppendLine("SERIES=" + it.series);
                sb.AppendLine("CODE=" + it.code);
                foreach (var kv in it.@params ?? new Dictionary<string, object>())
                    sb.AppendLine("PARAM " + kv.Key + "=" + Convert.ToString(kv.Value, CultureInfo.InvariantCulture));
                MasterSpec m;
                if (_data.Masters.TryGetValue(it.series, out m) && m != null)
                {
                    sb.AppendLine("MASTER=" + (m.masterPart ?? ""));
                    foreach (var kv in m.expressionMap ?? new Dictionary<string, string>())
                        sb.AppendLine("MAP " + kv.Key + "=" + kv.Value);
                    foreach (var r in m.switchRules ?? new List<List<string>>())
                        if (r != null && r.Count >= 4)
                            sb.AppendLine("SW " + r[0] + "," + r[1] + "=" + r[2] + "," + r[3]);
                    foreach (var kv in m.exports ?? new Dictionary<string, object>())
                        if (kv.Key == "step" || Convert.ToBoolean(kv.Value))
                            sb.AppendLine("EXP " + kv.Key + "=1");
                }
                sb.AppendLine("END");
            }
            File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
            // token 侧车文件: .done 结果里不含 token, 下载校验独立读它 (跨状态有效)
            File.WriteAllText(Path.Combine(_cfg.jobsDir, "job_" + job.id + ".tok"), job.token, Encoding.UTF8);
        }

        // .done 行式键值 → JobResult (worker 写入, 与 BuildJobText 同族格式)
        static JobResult ParseDone(string text)
        {
            var res = new JobResult { items = new List<JobItemResult>() };
            JobItemResult cur = null;
            foreach (var raw in text.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None))
            {
                var l = raw.Trim();
                if (l.Length == 0) continue;
                if (l == "ITEM") { cur = new JobItemResult { files = new List<string>() }; res.items.Add(cur); continue; }
                if (l == "END") { cur = null; continue; }
                if (cur != null)
                {
                    var i = l.IndexOf('=');
                    if (i < 0) continue;
                    var k = l.Substring(0, i).Trim();
                    var v = l.Substring(i + 1).Trim();
                    if (k == "SERIES") cur.series = v;
                    else if (k == "CODE") cur.code = v;
                    else if (k == "OK") cur.ok = v == "1";
                    else if (k == "HIT") cur.hit = v == "1";
                    else if (k == "ERROR") cur.error = v;
                    else if (k == "FILE") cur.files.Add(v);
                }
                else if (l.StartsWith("OK=")) res.ok = l.Substring(3).Trim() == "1";
                else if (l.StartsWith("ERROR=")) res.error = l.Substring(6).Trim();
            }
            return res;
        }

        static object JobStatus(string id)
        {
            if (id == null || !SafeName(id).Equals(id, StringComparison.OrdinalIgnoreCase)) return new { error = "invalid_job" };
            var baseName = Path.Combine(_cfg.jobsDir, "job_" + id);
            if (File.Exists(baseName + ".json")) return new { state = "queued" };
            if (File.Exists(baseName + ".running"))
            {
                // worker 逐项进度 (.prog: DONE=n / TOTAL=m), 批量打包 UI 显示用
                var progPath = baseName + ".prog";
                if (File.Exists(progPath))
                    try
                    {
                        object done = null, total = null;
                        foreach (var raw in File.ReadAllLines(progPath, Encoding.UTF8))
                        {
                            var l = raw.Trim();
                            if (l.StartsWith("DONE=")) done = l.Substring(5).Trim();
                            else if (l.StartsWith("TOTAL=")) total = l.Substring(6).Trim();
                        }
                        if (done != null || total != null)
                            return new Dictionary<string, object> { { "state", "running" }, { "done", done }, { "total", total } };
                    }
                    catch { }
                return new { state = "running" };
            }
            if (File.Exists(baseName + ".done"))
            {
                var r = ParseDone(File.ReadAllText(baseName + ".done", Encoding.UTF8));
                return new { state = "done", ok = r.ok, items = r.items };
            }
            if (File.Exists(baseName + ".failed"))
                return new { state = "failed", error = File.ReadAllText(baseName + ".failed", Encoding.UTF8) };
            var archived = Path.Combine(_cfg.jobsDir, "archived", "job_" + id + ".json");
            if (File.Exists(archived)) return new { state = "done", archived = true };
            return new { error = "job_not_found" };
        }

        // 单型号全格式 zip (单个下载 / 批量打包的逐型号合并)
        static void ServeModelZip(HttpListenerContext ctx, string jobId, string token, string code)
        {
            var ok = TokenOk(jobId, token, consume: false);
            if (ok.ContainsKey("error")) { TryWriteJson(ctx, 401, new { error = ok["error"] }); return; }
            if (code == null || !System.Text.RegularExpressions.Regex.IsMatch(code, "^[A-Za-z0-9][A-Za-z0-9_\\-]*$"))
            { TryWriteJson(ctx, 400, new { error = "invalid_code" }); return; }
            var jobPath = Path.Combine(_cfg.jobsDir, "job_" + jobId + ".done");
            if (!File.Exists(jobPath)) { TryWriteJson(ctx, 404, new { error = "job_not_done" }); return; }
            var res = ParseDone(File.ReadAllText(jobPath, Encoding.UTF8));
            var it = (res.items ?? new List<JobItemResult>()).FirstOrDefault(x => x.code == code);
            if (it == null || !it.ok) { TryWriteJson(ctx, 404, new { error = "item_not_found" }); return; }
            var dir = Path.Combine(_cfg.outputDir, SafeName(it.series), SafeName(code));
            WriteZip(ctx, dir, it.files);
        }

        // 批量任务整体 zip: 每型号一个子文件夹 (购物车批量打包用)
        static void ServeJobZip(HttpListenerContext ctx, string jobId, string token)
        {
            // 先读结果再消费 token (TokenOk 会把 .done 归档, 顺序不能反)
            var jobPath = Path.Combine(_cfg.jobsDir, "job_" + jobId + ".done");
            JobResult res = null;
            if (File.Exists(jobPath))
                try { res = ParseDone(File.ReadAllText(jobPath, Encoding.UTF8)); } catch { }
            if (res == null) { TryWriteJson(ctx, 404, new { error = "job_not_done" }); return; }
            var ok = TokenOk(jobId, token, consume: true);
            if (ok.ContainsKey("error")) { TryWriteJson(ctx, 401, new { error = ok["error"] }); return; }
            var entries = new List<Tuple<string, string>>();
            foreach (var it in (res.items ?? new List<JobItemResult>()).Where(x => x.ok))
            {
                var dir = Path.Combine(_cfg.outputDir, SafeName(it.series), SafeName(it.code));
                foreach (var f in (it.files ?? new List<string>()))
                    entries.Add(new Tuple<string, string>(Path.Combine(dir, f), SafeName(it.code) + "/" + f));
            }
            WriteZipEntries(ctx, entries);
        }

        static Dictionary<string, object> TokenOk(string jobId, string token, bool consume)
        {
            if (jobId == null || token == null || !SafeName(jobId).Equals(jobId, StringComparison.OrdinalIgnoreCase))
                return new Dictionary<string, object> { { "error", "invalid_job" } };
            var baseName = Path.Combine(_cfg.jobsDir, "job_" + jobId);
            var tokPath = baseName + ".tok";
            if (!File.Exists(tokPath)) return new Dictionary<string, object> { { "error", "bad_token" } };
            var saved = File.ReadAllText(tokPath, Encoding.UTF8).Trim();
            if (saved != token) return new Dictionary<string, object> { { "error", "bad_token" } };
            if (consume)
            {
                // 一次性 token: 归档任务状态文件并删除 token (输出目录文件照旧留存不删)
                var archDir = Path.Combine(_cfg.jobsDir, "archived");
                Directory.CreateDirectory(archDir);
                foreach (var suffix in new[] { ".json", ".running", ".done" })
                {
                    var p = baseName + suffix;
                    if (!File.Exists(p)) continue;
                    var dest = Path.Combine(archDir, Path.GetFileName(p));
                    if (File.Exists(dest)) File.Delete(dest);
                    File.Move(p, dest);
                }
                try { File.Delete(tokPath); } catch { }
            }
            return new Dictionary<string, object>();
        }

        // 网页内 3D 预览: /preview/{series}/{code}.stp → inline STEP (不触发下载, 查看器 url= 直接 fetch)
        static void ServePreview(HttpListenerContext ctx, string rel)
        {
            // rel 形如 "KC/KC0032-87.stp"
            var parts = rel.Split('/');
            if (parts.Length < 2)
            { TryWriteJson(ctx, 404, new { error = "not_found" }); return; }
            var series = parts[0];
            var file = parts[parts.Length - 1];
            if (!System.Text.RegularExpressions.Regex.IsMatch(series, "^[A-Za-z0-9][A-Za-z0-9_\\-]*$") ||
                !System.Text.RegularExpressions.Regex.IsMatch(file, "^[A-Za-z0-9][A-Za-z0-9_\\-]*\\.(stp|step)$"))
            { TryWriteJson(ctx, 400, new { error = "invalid_path" }); return; }
            var dir = Path.Combine(_cfg.outputDir, SafeName(series), Path.GetFileNameWithoutExtension(file));
            var full = Path.Combine(dir, file);
            if (!File.Exists(full)) { TryWriteJson(ctx, 404, new { error = "not_generated" }); return; }
            var bytes = File.ReadAllBytes(full);
            ctx.Response.StatusCode = 200;
            ctx.Response.ContentType = "application/octet-stream";
            ctx.Response.AddHeader("Content-Disposition", "inline; filename=\"" + file + "\"");
            ctx.Response.ContentLength64 = bytes.Length;
            ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
            ctx.Response.Close();
        }

        // 直接下载 (无任务凭证): 输出目录留存文件 (默认全格式 zip; fmt=step/parasolid/iges/stl 时给单文件)
        static void ServeDownloadZip(HttpListenerContext ctx, string series, string code, string fmt)
        {
            if (series == null || code == null ||
                !System.Text.RegularExpressions.Regex.IsMatch(series, "^[A-Za-z0-9][A-Za-z0-9_\\-]*$") ||
                !System.Text.RegularExpressions.Regex.IsMatch(code, "^[A-Za-z0-9][A-Za-z0-9_\\-]*$"))
            { TryWriteJson(ctx, 400, new { error = "invalid_code" }); return; }
            var dir = Path.Combine(_cfg.outputDir, SafeName(series), SafeName(code));
            if (!Directory.Exists(dir)) { TryWriteJson(ctx, 404, new { error = "not_generated" }); return; }
            var files = Directory.GetFiles(dir).Select(f => Path.GetFileName(f)).ToList();
            if (files.Count == 0) { TryWriteJson(ctx, 404, new { error = "not_generated" }); return; }
            if (!string.IsNullOrEmpty(fmt))
            {
                // 单格式下载: fmt 接受格式名 (step/parasolid/iges/stl) 或直接扩展名 (stp/x_t/igs/stl)
                string ext;
                if (!ExtMap.TryGetValue(fmt.ToLowerInvariant(), out ext))
                {
                    var ext2 = "." + fmt.TrimStart('.');
                    if (ext2 != ".stp" && ext2 != ".x_t" && ext2 != ".igs" && ext2 != ".stl" && ext2 != ".prt")
                    { TryWriteJson(ctx, 400, new { error = "invalid_fmt" }); return; }
                    ext = ext2;
                }
                var file = Path.Combine(dir, code + ext);
                if (!File.Exists(file)) { TryWriteJson(ctx, 404, new { error = "fmt_not_generated" }); return; }
                var bytes = File.ReadAllBytes(file);
                ctx.Response.StatusCode = 200;
                ctx.Response.ContentType = "application/octet-stream";
                ctx.Response.AddHeader("Content-Disposition", "attachment; filename=\"" + code + ext + "\"");
                ctx.Response.ContentLength64 = bytes.Length;
                ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
                ctx.Response.Close();
                return;
            }
            WriteZip(ctx, dir, files);
        }

        static void WriteZip(HttpListenerContext ctx, string dir, List<string> files)
        {
            var entries = (files ?? new List<string>())
                .Select(f => new Tuple<string, string>(Path.Combine(dir, f), Path.GetFileName(f)))
                .ToList();
            WriteZipEntries(ctx, entries);
        }

        static void WriteZipEntries(HttpListenerContext ctx, List<Tuple<string, string>> entries)
        {
            using (var ms = new MemoryStream())
            {
                using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, true))
                {
                    foreach (var e in entries)
                    {
                        if (!File.Exists(e.Item1)) continue;
                        var entry = zip.CreateEntry(e.Item2);
                        using (var es = entry.Open())
                        {
                            var bytes = File.ReadAllBytes(e.Item1);
                            es.Write(bytes, 0, bytes.Length);
                        }
                    }
                }
                var data = ms.ToArray();
                ctx.Response.StatusCode = 200;
                ctx.Response.ContentType = "application/zip";
                ctx.Response.AddHeader("Content-Disposition", "attachment; filename=\"onemanbot_gen.zip\"");
                ctx.Response.ContentLength64 = data.Length;
                ctx.Response.OutputStream.Write(data, 0, data.Length);
                ctx.Response.Close();
            }
        }

        // ---- 清理 ----
        static void SweepWork()
        {
            try
            {
                var ttl = DateTime.UtcNow.AddHours(-_cfg.workTtlHours);
                foreach (var d in Directory.GetDirectories(_cfg.workDir))
                    if (Directory.GetLastWriteTimeUtc(d) < ttl)
                        try { Directory.Delete(d, true); } catch { }
                var archDir = Path.Combine(_cfg.jobsDir, "archived");
                Directory.CreateDirectory(archDir);
                var old = DateTime.UtcNow.AddHours(-_cfg.jobArchiveHours);
                foreach (var f in Directory.GetFiles(_cfg.jobsDir, "job_*.done"))
                    if (File.GetLastWriteTimeUtc(f) < old)
                    {
                        var dest = Path.Combine(archDir, Path.GetFileName(f));
                        if (File.Exists(dest)) File.Delete(dest);
                        File.Move(f, dest);
                        try { var tok = Path.ChangeExtension(f, ".tok"); if (File.Exists(tok)) File.Delete(tok); } catch { }   // 24h 后下载凭证过期
                    }
                // 生成结果 TTL 清空 (用户需求: output 可在指定时间后自动删除; outputTtlHours=0 则永不清)
                if (_cfg.outputTtlHours > 0)
                {
                    var outTtl = DateTime.UtcNow.AddHours(-_cfg.outputTtlHours);
                    foreach (var seriesDir in Directory.GetDirectories(_cfg.outputDir))
                        foreach (var modelDir in Directory.GetDirectories(seriesDir))
                            if (Directory.GetLastWriteTimeUtc(modelDir) < outTtl)
                                try { Directory.Delete(modelDir, true); } catch { }
                }
            }
            catch (Exception ex) { Console.WriteLine("[GenServer] 清理异常: " + ex.Message); }
        }

        // ---- 工具 ----
        static string Str(Dictionary<string, object> d, string key)
        {
            return d.ContainsKey(key) && d[key] != null ? Convert.ToString(d[key]) : "";
        }

        static string ReadBody(HttpListenerRequest req)
        {
            using (var sr = new StreamReader(req.InputStream, Encoding.UTF8))
                return sr.ReadToEnd();
        }

        static void TryWriteJson(HttpListenerContext ctx, int code, object obj)
        {
            try
            {
                var bytes = Encoding.UTF8.GetBytes(_ser.Serialize(obj));
                ctx.Response.StatusCode = code;
                ctx.Response.ContentType = "application/json; charset=utf-8";
                ctx.Response.ContentLength64 = bytes.Length;
                ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
                ctx.Response.Close();
            }
            catch { }
        }
    }
}
