/**
 **
 ** McpServer.java - Embedded HTTP server exposing ImageJ plugins via MCP API
 **
 ** Copyright (C) 2026 Elphel, Inc.
 **
 ** -----------------------------------------------------------------------------**
 **
 **  McpServer.java is free software: you can redistribute it and/or modify
 **  it under the terms of the GNU General Public License as published by
 **  the Free Software Foundation, either version 3 of the License, or
 **  (at your option) any later version.
 **
 **  This program is distributed in the hope that it will be useful,
 **  but WITHOUT ANY WARRANTY; without even the implied warranty of
 **  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 **  GNU General Public License for more details.
 **
 **  You should have received a copy of the GNU General Public License
 **  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 ** -----------------------------------------------------------------------------**
 **
 */
package com.elphel.imagej.mcp;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.elphel.imagej.correction.Eyesis_Correction;
import com.elphel.imagej.mcp.McpFsAccess;
import com.elphel.imagej.tileprocessor.IntersceneGlobalRefine;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;

public class McpServer {
    private static McpServer INSTANCE;

    private final Eyesis_Correction owner;
    private final int port;
    private HttpServer server;

    public static synchronized McpServer startIfNeeded(Eyesis_Correction owner, int port) {
        if (INSTANCE != null) {
            return INSTANCE;
        }
        McpFsAccess.ensureConfigured();
        McpServer instance = new McpServer(owner, port);
        instance.start();
        if (hasReverseSshTunnel(port)) {
            System.out.println("MCP: reverse SSH tunnel detected for port " + port);
        } else {
            System.out.println("MCP: local-only (no reverse SSH tunnel detected; see mcp-remote-ssh-tunnel.md)");
        }
        INSTANCE = instance;
        return instance;
    }

    private static boolean hasReverseSshTunnel(int port) {
        String needle = "127.0.0.1:" + port + ":127.0.0.1:" + port;
        final boolean[] found = new boolean[] { false };
        java.lang.ProcessHandle.allProcesses().forEach(handle -> {
            if (found[0]) {
                return;
            }
            java.lang.ProcessHandle.Info info = handle.info();
            if (!info.command().isPresent()) {
                return;
            }
            String cmd = info.command().orElse("");
            if (!cmd.endsWith("ssh")) {
                return;
            }
            String[] args = info.arguments().orElse(new String[0]);
            if (args.length == 0) {
                return;
            }
            boolean hasR = false;
            boolean hasMatch = false;
            for (String arg : args) {
                if ("-R".equals(arg)) {
                    hasR = true;
                } else if (arg.contains(needle)) {
                    hasMatch = true;
                }
            }
            if (hasR && hasMatch) {
                found[0] = true;
            }
        });
        return found[0];
    }

    private McpServer(Eyesis_Correction owner, int port) {
        this.owner = owner;
        this.port = port;
    }

    private void start() {
        try {
            server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
        } catch (IOException e) {
            System.out.println("MCP: failed to start HTTP server on port " + port + ": " + e.getMessage());
            return;
        }
        server.createContext("/mcp/status", new StatusHandler());
        server.createContext("/mcp/dialog", new DialogHandler());
        server.createContext("/mcp/dialog/values", new DialogValuesHandler());
        server.createContext("/mcp/dialog/submit", new DialogSubmitHandler());
        server.createContext("/mcp/button", new ButtonHandler());
        server.createContext("/mcp/interrupt", new InterruptHandler());
        server.createContext("/mcp/interrupt/confirm", new InterruptConfirmHandler());
        server.createContext("/mcp/fs/roots", new FsRootsHandler());
        server.createContext("/mcp/fs/list", new FsListHandler());
        server.createContext("/mcp/fs/read", new FsReadHandler());
        server.createContext("/mcp/fs/head", new FsHeadHandler());
        server.createContext("/mcp/fs/tail", new FsTailHandler());
        server.createContext("/mcp/fs/glob", new FsGlobHandler());
        server.createContext("/mcp/fs/csvcol", new FsCsvColHandler());
        server.createContext("/mcp/rag/query", new RagQueryHandler());
        server.createContext("/mcp/cuas/targets", new CuasTargetsHandler());
        server.setExecutor(null);
        server.start();
        if (Eyesis_Correction.MCP_DEBUG_LEVEL >= Eyesis_Correction.MINIMAL_DEBUG_MCP) {
            System.out.println("MCP: server started on http://127.0.0.1:" + port);
        }
    }

    private class StatusHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            String response = buildStatusJson();
            sendJson(exchange, 200, response);
        }
    }

    private class DialogHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            String response = buildDialogJson();
            sendJson(exchange, 200, response);
        }
    }

    private class DialogValuesHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
                sendJson(exchange, 405, "{\"ok\":false,\"error\":\"POST required\"}");
                return;
            }
            Map<String, String> params = parseParams(exchange);
            String id = params.get("id");
            String label = params.get("label");
            String value = params.get("value");
            if (label == null) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Missing label\"}");
                return;
            }
            boolean applied = (id == null) ? McpDialogRegistry.setValue(label, value) : McpDialogRegistry.setValueForId(id, label, value);
            if (applied) {
                if (Eyesis_Correction.MCP_DEBUG_LEVEL >= Eyesis_Correction.MINIMAL_DEBUG_MCP) {
                    System.out.println("MCP: dialog value label=\"" + label + "\"");
                }
                sendJson(exchange, 200, "{\"ok\":true}");
            } else {
                sendJson(exchange, 409, "{\"ok\":false,\"error\":\"No active dialog or id mismatch\"}");
            }
        }
    }

    private class DialogSubmitHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
                sendJson(exchange, 405, "{\"ok\":false,\"error\":\"POST required\"}");
                return;
            }
            Map<String, String> params = parseParams(exchange);
            String id = params.get("id");
            boolean ok = parseBool(params.get("ok"), true);
            boolean applied = (id == null) ? McpDialogRegistry.submitCurrent(ok) : McpDialogRegistry.submitForId(id, ok);
            if (applied && Eyesis_Correction.MCP_DEBUG_LEVEL >= Eyesis_Correction.MINIMAL_DEBUG_MCP) {
                System.out.println("MCP: dialog submit ok=" + ok);
            }
            sendJson(exchange, 200, applied ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"No active dialog\"}");
        }
    }

    private class ButtonHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
                sendJson(exchange, 405, "{\"ok\":false,\"error\":\"POST required\"}");
                return;
            }
            Map<String, String> params = parseParams(exchange);
            String label = params.get("label");
            if (label == null) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Missing label\"}");
                return;
            }
            owner.triggerCommand(label, true);
            if (Eyesis_Correction.MCP_DEBUG_LEVEL >= Eyesis_Correction.MINIMAL_DEBUG_MCP) {
                System.out.println("MCP: button \"" + label + "\"");
            }
            sendJson(exchange, 200, "{\"ok\":true}");
        }
    }

    // codex 2026-01-27: request interrupt via MCP
    private class InterruptHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
                sendJson(exchange, 405, "{\"ok\":false,\"error\":\"POST required\"}");
                return;
            }
            Map<String, String> params = parseParams(exchange);
            boolean confirm = parseBool(params.get("confirm"), true);
            boolean asap = parseBool(params.get("asap"), false);
            owner.requestSyncStop(confirm, asap);
            sendJson(exchange, 200, "{\"ok\":true}");
        }
    }

    // codex 2026-01-27: confirm or cancel interrupt via MCP
    private class InterruptConfirmHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
                sendJson(exchange, 405, "{\"ok\":false,\"error\":\"POST required\"}");
                return;
            }
            Map<String, String> params = parseParams(exchange);
            boolean stop = parseBool(params.get("stop"), true);
            boolean applied = owner.confirmSyncStop(stop);
            sendJson(exchange, 200, applied ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"No pending confirm\"}");
        }
    }

    // codex 2026-01-28: list allowed roots for MCP file access
    private class FsRootsHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            List<java.nio.file.Path> roots = McpFsAccess.getAllowedRoots();
            StringBuilder sb = new StringBuilder();
            sb.append("{\"ok\":true,\"roots\":[");
            int outIndex = 0;
            for (int i = 0; i < roots.size(); i++) {
                java.nio.file.Path root = roots.get(i);
                if (!java.nio.file.Files.isDirectory(root)) {
                    continue;
                }
                if (outIndex > 0) {
                    sb.append(",");
                }
                sb.append("\"").append(jsonEscape(root.toString())).append("\"");
                outIndex++;
            }
            sb.append("]}");
            sendJson(exchange, 200, sb.toString());
        }
    }

    private class FsListHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            Map<String, String> params = parseParams(exchange);
            String pathStr = params.get("path");
            if (pathStr == null) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Missing path\"}");
                return;
            }
            java.nio.file.Path path = McpFsAccess.normalizePath(pathStr);
            if (path == null || !McpFsAccess.isAllowed(path)) {
                sendJson(exchange, 403, "{\"ok\":false,\"error\":\"Path not allowed\"}");
                return;
            }
            if (!java.nio.file.Files.isDirectory(path)) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Not a directory\"}");
                return;
            }
            int max = parseInt(params.get("max"), 1000);
            StringBuilder sb = new StringBuilder();
            sb.append("{\"ok\":true,\"entries\":[");
            int count = 0;
            try (java.util.stream.Stream<java.nio.file.Path> stream = java.nio.file.Files.list(path)) {
                java.util.Iterator<java.nio.file.Path> it = stream.iterator();
                while (it.hasNext()) {
                    if (count >= max) {
                        break;
                    }
                    java.nio.file.Path entry = it.next();
                    if (count > 0) {
                        sb.append(",");
                    }
                    sb.append("{");
                    sb.append("\"name\":\"").append(jsonEscape(entry.getFileName().toString())).append("\"");
                    sb.append(",\"path\":\"").append(jsonEscape(entry.toString())).append("\"");
                    boolean isDir = java.nio.file.Files.isDirectory(entry);
                    sb.append(",\"type\":\"").append(isDir ? "dir" : "file").append("\"");
                    if (!isDir) {
                        try {
                            sb.append(",\"size\":").append(java.nio.file.Files.size(entry));
                        } catch (IOException e) {
                            sb.append(",\"size\":-1");
                        }
                    }
                    try {
                        sb.append(",\"mtime\":").append(java.nio.file.Files.getLastModifiedTime(entry).toMillis());
                    } catch (IOException e) {
                        sb.append(",\"mtime\":-1");
                    }
                    sb.append("}");
                    count++;
                }
            }
            sb.append("]}");
            sendJson(exchange, 200, sb.toString());
        }
    }

    private class FsReadHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            Map<String, String> params = parseParams(exchange);
            String pathStr = params.get("path");
            if (pathStr == null) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Missing path\"}");
                return;
            }
            java.nio.file.Path path = McpFsAccess.normalizePath(pathStr);
            if (path == null || !McpFsAccess.isAllowed(path)) {
                sendJson(exchange, 403, "{\"ok\":false,\"error\":\"Path not allowed\"}");
                return;
            }
            if (!java.nio.file.Files.isRegularFile(path)) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Not a file\"}");
                return;
            }
            long offset = parseLong(params.get("offset"), 0L);
            int maxBytes = parseInt(params.get("maxBytes"), 262144);
            if (maxBytes < 1) {
                maxBytes = 1;
            }
            byte[] data;
            try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(path.toFile(), "r")) {
                if (offset > 0) {
                    raf.seek(offset);
                }
                int len = (int) Math.min(maxBytes, raf.length() - raf.getFilePointer());
                if (len < 0) {
                    len = 0;
                }
                data = new byte[len];
                int read = raf.read(data);
                if (read < len) {
                    data = java.util.Arrays.copyOf(data, Math.max(read, 0));
                }
            }
            String text = new String(data, StandardCharsets.UTF_8);
            StringBuilder sb = new StringBuilder();
            sb.append("{\"ok\":true,\"bytes\":").append(data.length).append(",\"data\":\"");
            sb.append(jsonEscape(text)).append("\"}");
            sendJson(exchange, 200, sb.toString());
        }
    }

    private class FsHeadHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            handleHeadTail(exchange, true);
        }
    }

    private class FsTailHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            handleHeadTail(exchange, false);
        }
    }

    private void handleHeadTail(HttpExchange exchange, boolean head) throws IOException {
        Map<String, String> params = parseParams(exchange);
        String pathStr = params.get("path");
        if (pathStr == null) {
            sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Missing path\"}");
            return;
        }
        java.nio.file.Path path = McpFsAccess.normalizePath(pathStr);
        if (path == null || !McpFsAccess.isAllowed(path)) {
            sendJson(exchange, 403, "{\"ok\":false,\"error\":\"Path not allowed\"}");
            return;
        }
        if (!java.nio.file.Files.isRegularFile(path)) {
            sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Not a file\"}");
            return;
        }
        int lines = parseInt(params.get("lines"), 200);
        if (lines < 1) {
            lines = 1;
        }
        List<String> result = new ArrayList<String>();
        if (head) {
            try (java.io.BufferedReader reader = java.nio.file.Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
                String line;
                while ((line = reader.readLine()) != null && result.size() < lines) {
                    result.add(line);
                }
            }
        } else {
            java.util.ArrayDeque<String> deque = new java.util.ArrayDeque<String>(lines);
            try (java.io.BufferedReader reader = java.nio.file.Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
                String line;
                while ((line = reader.readLine()) != null) {
                    if (deque.size() >= lines) {
                        deque.pollFirst();
                    }
                    deque.addLast(line);
                }
            }
            result.addAll(deque);
        }
        StringBuilder sb = new StringBuilder();
        sb.append("{\"ok\":true,\"lines\":[");
        for (int i = 0; i < result.size(); i++) {
            if (i > 0) {
                sb.append(",");
            }
            sb.append("\"").append(jsonEscape(result.get(i))).append("\"");
        }
        sb.append("]}");
        sendJson(exchange, 200, sb.toString());
    }

    private class FsGlobHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            Map<String, String> params = parseParams(exchange);
            String pattern = params.get("pattern");
            if (pattern == null) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Missing pattern\"}");
                return;
            }
            int max = parseInt(params.get("max"), 1000);
            int maxDepth = parseInt(params.get("maxDepth"), 6);
            java.nio.file.FileSystem fs = java.nio.file.FileSystems.getDefault();
            java.nio.file.PathMatcher matcher = fs.getPathMatcher("glob:" + pattern);
            List<java.nio.file.Path> roots = McpFsAccess.getAllowedRoots();
            StringBuilder sb = new StringBuilder();
            sb.append("{\"ok\":true,\"matches\":[");
            int count = 0;
            for (java.nio.file.Path root : roots) {
                if (count >= max) {
                    break;
                }
                if (!java.nio.file.Files.exists(root)) {
                    continue;
                }
                try (java.util.stream.Stream<java.nio.file.Path> stream = java.nio.file.Files.walk(root, maxDepth)) {
                    java.util.Iterator<java.nio.file.Path> it = stream.iterator();
                    while (it.hasNext()) {
                        if (count >= max) {
                            break;
                        }
                        java.nio.file.Path path = it.next();
                        boolean matched = matcher.matches(path);
                        if (!matched) {
                            try {
                                java.nio.file.Path rel = root.relativize(path);
                                matched = matcher.matches(rel);
                            } catch (IllegalArgumentException e) {
                                matched = false;
                            }
                        }
                        if (matched) {
                            if (count > 0) {
                                sb.append(",");
                            }
                            sb.append("\"").append(jsonEscape(path.toString())).append("\"");
                            count++;
                        }
                    }
                }
            }
            sb.append("]}");
            sendJson(exchange, 200, sb.toString());
        }
    }

    // codex 2026-01-28: read a single column from a delimited text file (1-based column index)
    private class FsCsvColHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            Map<String, String> params = parseParams(exchange);
            String pathStr = params.get("path");
            String colStr = params.get("col");
            if (pathStr == null || colStr == null) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Missing path or col\"}");
                return;
            }
            int col = parseInt(colStr, -1);
            if (col < 1) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Column index must be >= 1\"}");
                return;
            }
            java.nio.file.Path path = McpFsAccess.normalizePath(pathStr);
            if (path == null || !McpFsAccess.isAllowed(path)) {
                sendJson(exchange, 403, "{\"ok\":false,\"error\":\"Path not allowed\"}");
                return;
            }
            if (!java.nio.file.Files.isRegularFile(path)) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Not a file\"}");
                return;
            }
            String sep = params.get("sep");
            String delim = "\t";
            if (sep != null && !sep.isEmpty()) {
                if ("\\t".equals(sep) || "tab".equalsIgnoreCase(sep)) {
                    delim = "\t";
                } else {
                    delim = sep;
                }
            }
            int max = parseInt(params.get("max"), 100000);
            if (max < 1) {
                max = 1;
            }
            String splitRegex = java.util.regex.Pattern.quote(delim);
            List<String> values = new ArrayList<String>();
            try (java.io.BufferedReader reader = java.nio.file.Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
                String line;
                while ((line = reader.readLine()) != null) {
                    String[] parts = line.split(splitRegex, -1);
                    String value = (col <= parts.length) ? parts[col - 1] : "";
                    values.add(value);
                    if (values.size() >= max) {
                        break;
                    }
                }
            }
            StringBuilder sb = new StringBuilder();
            sb.append("{\"ok\":true,\"col\":").append(col).append(",\"values\":[");
            for (int i = 0; i < values.size(); i++) {
                if (i > 0) {
                    sb.append(",");
                }
                sb.append("\"").append(jsonEscape(values.get(i))).append("\"");
            }
            sb.append("]}");
            sendJson(exchange, 200, sb.toString());
        }
    }

    // codex 2026-02-05: query local RAG index via rag_query.py
    private class RagQueryHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            Map<String, String> params = parseParams(exchange);
            String text = params.get("text");
            if (text == null || text.isEmpty()) {
                sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Missing text\"}");
                return;
            }
            int topK = parseInt(params.get("topK"), 5);
            if (topK < 1) {
                topK = 1;
            }
            String ragRoot = System.getProperty("elphel.rag.root");
            if (ragRoot == null || ragRoot.trim().isEmpty()) {
                ragRoot = "/home/elphel/git/imagej-elphel";
            }
            String venvPython = ragRoot + "/attic/CODEX/rag_index/.venv/bin/python";
            String script = ragRoot + "/scripts/rag_query.py";
            List<String> cmd = new ArrayList<String>();
            cmd.add(venvPython);
            cmd.add(script);
            cmd.add("--json");
            cmd.add("--top-k");
            cmd.add(String.valueOf(topK));
            cmd.add("--index-dir");
            cmd.add(ragRoot + "/attic/CODEX/rag_index");
            cmd.add(text);
            ProcessBuilder pb = new ProcessBuilder(cmd);
            pb.redirectErrorStream(true);
            Process p;
            try {
                p = pb.start();
            } catch (IOException e) {
                String detail = jsonEscape("rag_root=" + ragRoot + " venv=" + venvPython + " script=" + script + " err=" + e.getMessage());
                sendJson(exchange, 500, "{\"ok\":false,\"error\":\"Failed to start rag_query\",\"detail\":\"" + detail + "\"}");
                return;
            }
            byte[] out;
            try {
                out = p.getInputStream().readAllBytes();
            } catch (IOException e) {
                sendJson(exchange, 500, "{\"ok\":false,\"error\":\"Failed to read rag_query output\"}");
                return;
            }
            try {
                p.waitFor();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            String output = new String(out, StandardCharsets.UTF_8).trim();
            if (p.exitValue() != 0) {
                String err = jsonEscape(output);
                sendJson(exchange, 500, "{\"ok\":false,\"error\":\"rag_query failed\",\"detail\":\"" + err + "\"}");
                return;
            }
            if (output.isEmpty()) {
                sendJson(exchange, 500, "{\"ok\":false,\"error\":\"rag_query returned empty\"}");
                return;
            }
            sendJson(exchange, 200, output);
        }
    }

    private String buildStatusJson() {
        int stopRequested = owner.getSyncStopRequested();
        IntersceneGlobalRefine.ProgressSnapshot progress = IntersceneGlobalRefine.getProgressSnapshot();
        long now = System.currentTimeMillis();
        StringBuilder sb = new StringBuilder();
        sb.append("{");
        sb.append("\"running\":").append(owner.isSyncRunning());
        sb.append(",\"stopRequested\":").append(stopRequested);
        sb.append(",\"confirmPending\":").append(owner.isSyncConfirmPending());
        sb.append(",\"confirmConvenient\":").append(owner.isSyncConfirmConvenient());
        sb.append(",\"buttonLabel\":\"").append(jsonEscape(owner.getSyncButtonLabel())).append("\"");
        sb.append(",\"globalLma\":");
        appendGlobalLmaJson(
                sb,
                progress,
                now);
        sb.append("}");
        return sb.toString();
    }

    private static void appendGlobalLmaJson(
            StringBuilder sb,
            IntersceneGlobalRefine.ProgressSnapshot progress,
            long nowMs) {
        if (progress == null) {
            sb.append("null");
            return;
        }
        long elapsedMs = 0L;
        if (progress.startedMs > 0L) {
            long stopTs = (progress.active || (progress.finishedMs <= 0L)) ? nowMs : progress.finishedMs;
            elapsedMs = Math.max(0L, stopTs - progress.startedMs);
        }
        sb.append("{");
        sb.append("\"active\":").append(progress.active);
        sb.append(",\"status\":\"").append(jsonEscape(progress.status)).append("\"");
        sb.append(",\"stage\":\"").append(jsonEscape(progress.stage)).append("\"");
        sb.append(",\"startedMs\":").append(progress.startedMs);
        sb.append(",\"updatedMs\":").append(progress.updatedMs);
        sb.append(",\"finishedMs\":").append(progress.finishedMs);
        sb.append(",\"elapsedMs\":").append(elapsedMs);
        sb.append(",\"centerIndex\":").append(progress.centerIndex);
        sb.append(",\"firstScene\":").append(progress.firstScene);
        sb.append(",\"lastScene\":").append(progress.lastScene);
        sb.append(",\"outerIterations\":").append(progress.outerIterations);
        sb.append(",\"innerIterations\":").append(progress.innerIterations);
        sb.append(",\"outer\":").append(progress.outer);
        sb.append(",\"inner\":").append(progress.inner);
        sb.append(",\"cachedPairs\":").append(progress.cachedPairs);
        sb.append(",\"correlationSolved\":").append(progress.correlationSolved);
        sb.append(",\"correlationFailed\":").append(progress.correlationFailed);
        sb.append(",\"tilesPerPair\":").append(progress.tilesPerPair);
        sb.append(",\"solvedScenes\":").append(progress.solvedScenes);
        sb.append(",\"failedScenes\":").append(progress.failedScenes);
        sb.append(",\"solvedPairs\":").append(progress.solvedPairs);
        sb.append(",\"failedPairs\":").append(progress.failedPairs);
        sb.append(",\"pcgIter\":").append(progress.pcgIter);
        sb.append(",\"avgPairRms\":");
        appendJsonNumber(sb, progress.avgPairRms);
        sb.append(",\"avgPairRmsPure\":");
        appendJsonNumber(sb, progress.avgPairRmsPure);
        sb.append(",\"maxDelta\":");
        appendJsonNumber(sb, progress.maxDelta);
        sb.append(",\"lambda\":");
        appendJsonNumber(sb, progress.lambda);
        sb.append(",\"lpfSqSum\":");
        appendJsonNumber(sb, progress.lpfSqSum);
        sb.append(",\"lpfWeightSum\":");
        appendJsonNumber(sb, progress.lpfWeightSum);
        sb.append(",\"lpf\":{");
        sb.append("\"x\":");
        appendJsonNumber(sb, progress.lpfX);
        sb.append(",\"y\":");
        appendJsonNumber(sb, progress.lpfY);
        sb.append(",\"a\":");
        appendJsonNumber(sb, progress.lpfA);
        sb.append(",\"t\":");
        appendJsonNumber(sb, progress.lpfT);
        sb.append("}");
        sb.append("}");
    }

    private static void appendJsonNumber(StringBuilder sb, double value) {
        if (Double.isFinite(value)) {
            sb.append(value);
        } else {
            sb.append("null");
        }
    }

    private String buildDialogJson() {
        McpDialogSession session = McpDialogRegistry.getCurrent();
        if (session == null) {
            return "{\"ok\":true,\"dialog\":null}";
        }
        StringBuilder sb = new StringBuilder();
        sb.append("{\"ok\":true,\"dialog\":{");
        sb.append("\"id\":\"").append(jsonEscape(session.getId())).append("\",");
        sb.append("\"title\":\"").append(jsonEscape(session.getTitle())).append("\",");
        sb.append("\"fields\":[");
        List<McpDialogField> fields = session.getFields();
        for (int i = 0; i < fields.size(); i++) {
            McpDialogField f = fields.get(i);
            if (i > 0) {
                sb.append(",");
            }
            sb.append("{");
            sb.append("\"type\":\"").append(f.type.name().toLowerCase()).append("\"");
            if (f.label != null) {
                sb.append(",\"label\":\"").append(jsonEscape(f.label)).append("\"");
            }
            if (f.tab != null) {
                sb.append(",\"tab\":\"").append(jsonEscape(f.tab)).append("\"");
            }
            if (f.tooltip != null) {
                sb.append(",\"tooltip\":\"").append(jsonEscape(f.tooltip)).append("\"");
            }
            if (f.units != null) {
                sb.append(",\"units\":\"").append(jsonEscape(f.units)).append("\"");
            }
            if (f.defaultValue != null) {
                sb.append(",\"default\":\"").append(jsonEscape(f.defaultValue)).append("\"");
            }
            if (f.choices != null) {
                sb.append(",\"choices\":[");
                for (int c = 0; c < f.choices.length; c++) {
                    if (c > 0) {
                        sb.append(",");
                    }
                    sb.append("\"").append(jsonEscape(f.choices[c])).append("\"");
                }
                sb.append("]");
            }
            sb.append("}");
        }
        sb.append("]}}" );
        return sb.toString();
    }

    private static void sendJson(HttpExchange exchange, int code, String body) throws IOException {
        // codex 2026-01-27: add newline to avoid shell prompt merging with JSON
        if (!body.endsWith("\n")) {
            body = body + "\n";
        }
        byte[] data = body.getBytes(StandardCharsets.UTF_8);
        Headers headers = exchange.getResponseHeaders();
        headers.set("Content-Type", "application/json; charset=utf-8");
        // codex 2026-02-05: allow simple browser-based MCP clients
        headers.set("Access-Control-Allow-Origin", "*");
        headers.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
        headers.set("Access-Control-Allow-Headers", "Content-Type");
        exchange.sendResponseHeaders(code, data.length);
        OutputStream os = exchange.getResponseBody();
        os.write(data);
        os.close();
    }

    private static Map<String, String> parseParams(HttpExchange exchange) throws IOException {
        Map<String, String> params = new HashMap<String, String>();
        URI uri = exchange.getRequestURI();
        if (uri != null && uri.getQuery() != null) {
            parseQueryString(uri.getQuery(), params);
        }
        if ("POST".equalsIgnoreCase(exchange.getRequestMethod())) {
            byte[] body = exchange.getRequestBody().readAllBytes();
            if (body.length > 0) {
                String raw = new String(body, StandardCharsets.UTF_8);
                parseQueryString(raw, params);
            }
        }
        return params;
    }

    private static int parseInt(String value, int fallback) {
        if (value == null) {
            return fallback;
        }
        try {
            return Integer.parseInt(value.trim());
        } catch (NumberFormatException e) {
            return fallback;
        }
    }

    private static long parseLong(String value, long fallback) {
        if (value == null) {
            return fallback;
        }
        try {
            return Long.parseLong(value.trim());
        } catch (NumberFormatException e) {
            return fallback;
        }
    }

    private static void parseQueryString(String raw, Map<String, String> out) {
        if (raw == null || raw.isEmpty()) {
            return;
        }
        String[] pairs = raw.split("&");
        for (String pair : pairs) {
            if (pair.isEmpty()) {
                continue;
            }
            int idx = pair.indexOf('=');
            String key = idx >= 0 ? pair.substring(0, idx) : pair;
            String value = idx >= 0 ? pair.substring(idx + 1) : "";
            out.put(urlDecode(key), urlDecode(value));
        }
    }

    private static boolean parseBool(String value, boolean defaultValue) {
        if (value == null || value.isEmpty()) {
            return defaultValue;
        }
        String v = value.trim().toLowerCase();
        return v.equals("1") || v.equals("true") || v.equals("yes") || v.equals("y");
    }

    private static String urlDecode(String value) {
        try {
            return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
        } catch (Exception e) {
            return value;
        }
    }

    private class CuasTargetsHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            try {
                java.io.InputStream is = exchange.getRequestBody();
                java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
                int nRead;
                byte[] data = new byte[1024];
                while ((nRead = is.read(data, 0, data.length)) != -1) {
                    buffer.write(data, 0, nRead);
                }
                buffer.flush();
                String requestBody = new String(buffer.toByteArray(), StandardCharsets.UTF_8);

                org.json.simple.parser.JSONParser parser = new org.json.simple.parser.JSONParser();
                org.json.simple.JSONObject req = (org.json.simple.JSONObject) parser.parse(requestBody);
                String targetPath = (String) req.get("target_path");
                org.json.simple.JSONArray queries = (org.json.simple.JSONArray) req.get("queries");

                if (targetPath == null || queries == null) {
                    sendJson(exchange, 400, "{\"ok\":false,\"error\":\"Missing target_path or queries\"}");
                    return;
                }

                org.json.simple.JSONArray results = com.elphel.imagej.cuas.CuasMotion.targetsAnalyzeMCP(targetPath, queries);
                org.json.simple.JSONObject response = new org.json.simple.JSONObject();
                response.put("ok", true);
                response.put("results", results);

                sendJson(exchange, 200, response.toJSONString());
            } catch (Exception e) {
                String detail = jsonEscape(e.getMessage());
                sendJson(exchange, 500, "{\"ok\":false,\"error\":\"Failed to process request\",\"detail\":\"" + detail + "\"}");
            }
        }
    }

    private static String jsonEscape(String value) {
        if (value == null) {
            return "";
        }
        StringBuilder sb = new StringBuilder(value.length() + 10);
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            switch (c) {
                case '\\': sb.append("\\\\"); break;
                case '"': sb.append("\\\""); break;
                case '\n': sb.append("\\n"); break;
                case '\r': sb.append("\\r"); break;
                case '\t': sb.append("\\t"); break;
                default: sb.append(c); break;
            }
        }
        return sb.toString();
    }
}
