import { Command } from "@cliffy/command";
import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import { yamlParseFile } from "../../utils/yaml.ts";
import { stringify as yamlStringify } from "yaml";
import { WebSocket, WebSocketServer } from "ws";

import * as http from "node:http";
import * as https from "node:https";
import * as open from "open";
import { access, readdir, realpath, stat, unlink, writeFile } from "node:fs/promises";
import { readTextFile } from "../../utils/utils.ts";
import { watch } from "node:fs";
import { getTypeStrFromPath, GlobalOptions } from "../../types.ts";
import { ignoreF } from "../sync/sync.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace } from "../../core/context.ts";
import {
  GLOBAL_CONFIG_OPT,
  SyncOptions,
  mergeConfigWithConfigFile,
} from "../../core/conf.ts";
import { exts, removeExtensionToPath } from "../script/script.ts";
import { inferContentTypeFromFilePath } from "../../utils/script_common.ts";
import { OpenFlow } from "../../../gen/types.gen.ts";
import { FlowFile } from "../flow/flow.ts";
import { replaceInlineScripts, replaceAllPathScriptsWithLocal } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { extractInlineScripts, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
import { parseMetadataFile } from "../../utils/metadata.ts";
import {
  getMetadataFileName,
  extractFolderPath,
  getNonDottedPaths,
  loadNonDottedPathsSetting,
} from "../../utils/resource_folders.ts";
import * as path from "node:path";
import * as fs from "node:fs";
import { listSyncCodebases } from "../../utils/codebase.ts";
import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts";
import { resolveBindPort, BIND_HOST } from "../../utils/port-probe.ts";
import {
  snapshotPathScripts,
  tagReplacedPathScripts,
  restorePathScripts,
} from "./pathscript-restore.ts";

const PORT = 3001;

type WmPathItem = {
  path: string;
  kind: "flow" | "script" | "raw_app";
  summary?: string;
};

const FLOW_SUFFIXES = [".flow", "__flow"] as const;
const APP_SUFFIXES = [".app", "__app", ".raw_app", "__raw_app"] as const;

// Extensions the dev round-trip might have written into a flow folder as
// inline scripts. Derived from script.ts's `exts` so adding a new language
// there auto-extends orphan cleanup; otherwise stale inline scripts of that
// language would silently linger. Excludes `.yml` — user fixtures commonly
// use it in flow folders, and leaving a stale `.playbook.yml` inline script
// is preferable to deleting a fixture. `.js` is added explicitly for
// hand-written flows that aren't in the `exts` list.
//
// Anything else (README.md, fixtures, .env*, TODO.md…) is preserved during
// orphan cleanup so we don't trample user-added files.
const INLINE_SCRIPT_EXTS = new Set([
  // path.extname(".py") === "" (Node treats ".py" as a hidden filename, not
  // an extension), so prefix with a dummy character before extracting.
  ...exts.map((e) => path.extname("x" + e)).filter((e) => e !== ".yml"),
  ".js",
]);

function stripFolderSuffix(rel: string, suffixes: readonly string[]): string {
  for (const s of suffixes) {
    if (rel.endsWith(s)) return rel.slice(0, -s.length);
  }
  return rel;
}

function isFlowFolderName(name: string): boolean {
  return FLOW_SUFFIXES.some((s) => name.endsWith(s));
}

// Normalize a windmill path: strip trailing slash and any flow folder suffix
// so f/foo, f/foo/, f/foo.flow, and f/foo.flow/ all compare equal.
function normalizeWmPath(p: string): string {
  return stripFolderSuffix(p.replace(/\/$/, ""), FLOW_SUFFIXES);
}

// Anchor on path segments — substring matches like cpath.includes(".flow/")
// also fire on innocent names like "notes_about__flow_design/readme.md".
function isInsideFlowFolder(cpath: string): boolean {
  return cpath.split("/").some(isFlowFolderName);
}

// Return the path prefix up to and including the first flow-folder segment,
// with a trailing slash. Returns undefined if no segment matches.
function findFlowFolderPrefix(cpath: string): string | undefined {
  const segs = cpath.split("/");
  for (let i = 0; i < segs.length; i++) {
    if (isFlowFolderName(segs[i])) {
      return segs.slice(0, i + 1).join("/") + "/";
    }
  }
  return undefined;
}

async function listWorkspacePaths(): Promise<WmPathItem[]> {
  // Walk first, capturing each item's metadata file path. Then read summaries in
  // parallel — one tree pass plus N file reads is faster than a serialized walk.
  const items: (WmPathItem & { _metaPath?: string })[] = [];
  async function walk(dir: string, rel: string) {
    let entries;
    try {
      entries = await readdir(dir, { withFileTypes: true });
    } catch {
      return;
    }
    for (const entry of entries) {
      if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
      const childRel = rel ? `${rel}/${entry.name}` : entry.name;
      const childAbs = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        if (isFlowFolderName(entry.name)) {
          items.push({
            path: stripFolderSuffix(childRel, FLOW_SUFFIXES),
            kind: "flow",
            _metaPath: path.join(childAbs, "flow.yaml"),
          });
          continue;
        }
        if (APP_SUFFIXES.some((s) => entry.name.endsWith(s))) {
          items.push({ path: stripFolderSuffix(childRel, APP_SUFFIXES), kind: "raw_app" });
          continue;
        }
        await walk(childAbs, childRel);
      } else if (entry.isFile()) {
        const matchedExt = exts.find((ext) => entry.name.endsWith(ext));
        if (matchedExt) {
          const noExtAbs = childAbs.slice(0, -matchedExt.length);
          items.push({
            path: childRel.slice(0, -matchedExt.length),
            kind: "script",
            _metaPath: noExtAbs + ".script.yaml",
          });
        }
      }
    }
  }
  await walk(process.cwd(), "");

  await Promise.all(
    items.map(async (item) => {
      if (!item._metaPath) return;
      try {
        const meta: any = await yamlParseFile(item._metaPath);
        if (typeof meta?.summary === "string" && meta.summary.length > 0) {
          item.summary = meta.summary;
        }
      } catch {
        // No metadata file or unparseable — leave summary undefined
      }
    })
  );

  items.sort((a, b) => a.path.localeCompare(b.path));
  return items.map(({ _metaPath, ...item }) => item);
}

export interface DevOpts {
  proxyPort?: number;
  path?: string;
  open?: boolean;
}

export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
  // Auto-detect flow folder: if no --path and cwd is a flow folder, resolve path and chdir to workspace root
  if (!opts.path) {
    const cwd = process.cwd();
    const cwdBasename = path.basename(cwd);

    // Need to init nonDottedPaths before checking suffix
    await loadNonDottedPathsSetting();

    if (isFlowFolderName(cwdBasename)) {
      GLOBAL_CONFIG_OPT.noCdToRoot = true;

      // Find workspace root
      let searchDir = cwd;
      let workspaceRoot: string | undefined;
      while (true) {
        const wmillYaml = path.join(searchDir, "wmill.yaml");
        if (fs.existsSync(wmillYaml)) {
          workspaceRoot = searchDir;
          break;
        }
        const parentDir = path.dirname(searchDir);
        if (parentDir === searchDir) break;
        searchDir = parentDir;
      }

      if (workspaceRoot) {
        const relPath = path.relative(workspaceRoot, cwd).replaceAll("\\", "/");
        opts.path = stripFolderSuffix(relPath, FLOW_SUFFIXES);
        log.info(`Detected flow folder, path: ${opts.path}`);
        process.chdir(workspaceRoot);
      }
    }
  }

  opts = await mergeConfigWithConfigFile(opts);
  // Normalize once so broadcastChanges' equality check survives user input
  // like --path f/foo/ or --path f/foo.flow (and the same set via wmill.yaml).
  if (opts.path) {
    opts.path = normalizeWmPath(opts.path);
  }
  const workspace = await resolveWorkspace(opts);
  await requireLogin(opts);

  log.info("Started dev mode");
  let currentLastEdit: LastEditScript | LastEditFlow | undefined = undefined;

  const fsWatcher = watch(".", { recursive: true });
  const base = await realpath(".");
  const ignore = await ignoreF(opts);
  const codebases = await listSyncCodebases(opts);

  // Resolve relative imports from local (not-yet-deployed) content so dev-page
  // previews use locally-edited workspace scripts instead of the deployed
  // versions. Diverged local scripts are uploaded to temp storage once here
  // and the resulting path -> hash refs ride along on every preview run.
  // Computed as a startup snapshot, like `wmill app dev`; restart `wmill dev`
  // to pick up later edits to imported workspace scripts. Uses the "all"
  // target because the previewed item can change at runtime (picker /
  // loadWmPath), so there is no single anchor node. Degrades gracefully
  // (undefined) on older backends without the /raw_temp endpoints.
  let tempScriptRefs: Record<string, string> | undefined = undefined;
  {
    const { buildPreviewTempScriptRefs } = await import(
      "../generate-metadata/generate-metadata.ts"
    );
    tempScriptRefs = await buildPreviewTempScriptRefs(
      workspace,
      opts,
      codebases,
      { kind: "all" },
    );
    if (tempScriptRefs) {
      log.info(
        `Resolved ${Object.keys(tempScriptRefs).length} locally-edited script(s) for preview relative imports (snapshot — restart wmill dev to refresh)`
      );
    }
  }

  const changesTimeouts: Record<string, ReturnType<typeof setTimeout>> = {};
  function watchChanges() {
    return new Promise<void>((_resolve, _reject) => {
      fsWatcher.on("change", (_eventType, filename) => {
        if (!filename) return;
        const filePath = typeof filename === "string" ? filename : filename.toString();
        const key = filePath;
        if (changesTimeouts[key]) {
          clearTimeout(changesTimeouts[key]);
        }
        changesTimeouts[key] = setTimeout(async () => {
          delete changesTimeouts[key];
          await loadPaths([filePath]).catch((error) => {
            log.error(
              `Failed to reload ${filePath}: ${error instanceof Error ? error.message : error}`
            );
          });
        }, 100);
      });
      fsWatcher.on("error", (err) => {
        _reject(err);
      });
    });
  }

  const flowMetadataFile = getMetadataFileName("flow", "yaml");
  async function loadPaths(pathsToLoad: string[]) {
    const paths = pathsToLoad.filter((p) =>
      exts.some(
        (ext) => p.endsWith(ext)
          || p.endsWith(".flow/" + flowMetadataFile)
          || p.endsWith("__flow/" + flowMetadataFile)
      )
    );
    if (paths.length == 0) {
      return;
    }
    const nativePath = (await realpath(paths[0])).replace(base + SEP, "");
    const cpath = nativePath.replaceAll("\\", "/");
    // Bypass ignore for paths inside flow folders — ignore() only checks the configured
    // suffix (dotted or non-dotted), but the workspace may contain both kinds
    const insideFlow = isInsideFlowFolder(cpath);
    if (insideFlow || !ignore(nativePath, false)) {
      let typ: string;
      if (insideFlow) {
        // Force flow type for any file inside a flow folder — getTypeStrFromPath
        // only recognises the configured suffix (dotted or non-dotted) and would
        // mis-classify or throw for the other variant
        typ = "flow";
      } else {
        typ = getTypeStrFromPath(cpath);
      }
      log.info("Detected change in " + cpath + " (" + typ + ")");
      if (typ == "flow") {
        // Try extractFolderPath, fallback to segment-anchored extraction for
        // mixed suffix cases (extractFolderPath only checks the configured suffix).
        let localPath = extractFolderPath(cpath, "flow") ?? findFlowFolderPrefix(cpath);
        if (!localPath) return;
        const wmFlowPath = stripFolderSuffix(localPath.replace(/\/$/, ""), FLOW_SUFFIXES);
        const localFlow = (await yamlParseFile(
          localPath + "flow.yaml"
        )) as FlowFile;
        await replaceInlineScripts(
          localFlow.value.modules,
          async (path: string) => await readTextFile(localPath + path),
          log,
          localPath,
          SEP,
          undefined,
        );
        // Snapshot PathScript modules before replacement, then tag after.
        // Helpers walk `flowValue` (modules/failure_module/preprocessor_module),
        // so pass `.value`, not the FlowFile wrapper.
        snapshotPathScripts(localFlow.value);
        const localScriptReader = createPreviewLocalScriptReader({
          exts,
          defaultTs: opts.defaultTs,
          codebases,
        });
        await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
        tagReplacedPathScripts(localFlow.value);
        currentLastEdit = {
          type: "flow",
          flow: localFlow,
          uriPath: localPath,
          path: wmFlowPath,
          temp_script_refs: tempScriptRefs,
        };
        log.info("Updated " + wmFlowPath);
        broadcastChanges(currentLastEdit);
      } else if (typ == "script") {
        const splitted = cpath.split(".");
        const wmPath = splitted[0];
        const content = await readTextFile(cpath);
        const lang = inferContentTypeFromFilePath(cpath, opts.defaultTs);
        const typed =
          (await parseMetadataFile(
            removeExtensionToPath(cpath),
            undefined,
          )
          )?.payload


        currentLastEdit = {
          type: "script",
          content,
          path: wmPath,
          language: lang,
          tag: typed?.tag,
          lock: typed?.lock,
          temp_script_refs: tempScriptRefs,
        };
        log.info("Updated " + wmPath);
        broadcastChanges(currentLastEdit);
      }
    }
  }
  type LastEditScript = {
    type: "script";
    content: string;
    path: string;
    language: string;
    tag?: string;
    lock?: string;
    temp_script_refs?: Record<string, string>;
  };

  type LastEditFlow = {
    type: "flow";
    flow: OpenFlow;
    uriPath: string;
    path: string;
    temp_script_refs?: Record<string, string>;
  };

  // Load a resource by its windmill path (e.g., "u/admin/my_script" or "f/my_flow")
  async function loadWmPath(wmPath: string): Promise<LastEditScript | LastEditFlow | undefined> {
    wmPath = normalizeWmPath(wmPath);
    // Try as flow — check both dotted and non-dotted suffixes
    let flowDir: string | undefined;
    let flowYaml: string | undefined;
    for (const suffix of [".flow", "__flow"]) {
      const candidate = wmPath + suffix + "/";
      try {
        await access(candidate + "flow.yaml");
        flowDir = candidate;
        flowYaml = candidate + "flow.yaml";
        break;
      } catch {}
    }
    try {
      if (!flowDir || !flowYaml) throw new Error("not a flow");
      const localFlow = (await yamlParseFile(flowYaml)) as FlowFile;
      await replaceInlineScripts(
        localFlow.value.modules,
        async (p: string) => await readTextFile(flowDir + p),
        log,
        flowDir,
        SEP,
        undefined,
      );
      snapshotPathScripts(localFlow.value);
      const localScriptReader = createPreviewLocalScriptReader({
        exts,
        defaultTs: opts.defaultTs,
        codebases,
      });
      await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
      tagReplacedPathScripts(localFlow.value);
      const edit: LastEditFlow = {
        type: "flow",
        flow: localFlow,
        uriPath: flowDir,
        path: wmPath,
        temp_script_refs: tempScriptRefs,
      };
      currentLastEdit = edit;
      return edit;
    } catch {
      // Not a flow, try as script
    }

    // Try as script
    for (const ext of exts) {
      const filePath = wmPath + ext;
      try {
        await access(filePath);
        const content = await readTextFile(filePath);
        const lang = inferContentTypeFromFilePath(filePath, opts.defaultTs);
        const typed = (await parseMetadataFile(removeExtensionToPath(filePath), undefined))?.payload;
        const edit: LastEditScript = {
          type: "script",
          content,
          path: wmPath,
          language: lang,
          tag: typed?.tag,
          lock: typed?.lock,
          temp_script_refs: tempScriptRefs,
        };
        currentLastEdit = edit;
        return edit;
      } catch {
        continue;
      }
    }

    log.error(`Could not find file for path: ${wmPath}`);
    return undefined;
  }

  // Handle flow edits from the dev UI — write changes back to disk
  // Mirrors the windmill-vscode extension's `processFlowMessage`
  // (src/extension.ts). Keep them in step — the dev page is the same code in
  // both contexts, and divergence here means the same flow round-trips
  // differently between VS Code and the local dev preview.
  //
  // Deliberate divergence: the orphan-cleanup pass is restricted to
  // INLINE_SCRIPT_EXTS so unrelated files (README.md, fixtures, etc.) are
  // not deleted. The extension's version doesn't filter and would delete
  // them — that's a known issue tracked separately.
  async function handleFlowRoundTrip(data: { flow: any; uriPath: string }) {
    if (!data.uriPath || !data.flow?.value) return;

    let flowDir = data.uriPath;
    if (!flowDir.endsWith("/")) flowDir += "/";
    if (flowDir.includes("://")) {
      flowDir = new URL(flowDir).pathname;
    }

    // Restore PathScripts BEFORE extracting so we don't write a file for the
    // inlined body of a `type: 'script'` reference.
    restorePathScripts(data.flow.value);

    const flowYamlPath = flowDir + "flow.yaml";
    let currentLoadedFlow: any[] | undefined;
    let currentLoadedFailureModule: any | undefined;
    let currentLoadedPreprocessorModule: any | undefined;
    try {
      const currentFlow = (await yamlParseFile(flowYamlPath)) as FlowFile;
      currentLoadedFlow = currentFlow.value?.modules;
      currentLoadedFailureModule = currentFlow.value?.failure_module;
      currentLoadedPreprocessorModule = currentFlow.value?.preprocessor_module;
    } catch {
      // flow.yaml doesn't exist yet or is invalid
    }

    const inlineScriptMapping: Record<string, string> = {};
    extractCurrentMapping(
      currentLoadedFlow,
      inlineScriptMapping,
      currentLoadedFailureModule,
      currentLoadedPreprocessorModule,
    );

    // Share one pathAssigner across all extraction calls so failure /
    // preprocessor modules don't collide on filenames with main modules.
    const extractOptions = { skipInlineScriptSuffix: getNonDottedPaths() };
    const pathAssigner = newPathAssigner(opts.defaultTs ?? "bun", extractOptions);

    const allExtracted = extractInlineScripts(
      data.flow.value.modules ?? [],
      inlineScriptMapping,
      "/",
      opts.defaultTs ?? "bun",
      pathAssigner,
      extractOptions,
    );
    if (data.flow.value.failure_module?.value?.type === "rawscript") {
      allExtracted.push(...extractInlineScripts(
        [data.flow.value.failure_module],
        inlineScriptMapping,
        "/",
        opts.defaultTs ?? "bun",
        pathAssigner,
        extractOptions,
      ));
    }
    if (data.flow.value.preprocessor_module?.value?.type === "rawscript") {
      allExtracted.push(...extractInlineScripts(
        [data.flow.value.preprocessor_module],
        inlineScriptMapping,
        "/",
        opts.defaultTs ?? "bun",
        pathAssigner,
        extractOptions,
      ));
    }

    for (const s of allExtracted) {
      const filePath = flowDir + s.path;
      // `!inline foo.ts` is a YAML directive that points at another file —
      // treat it as a placeholder, not as content to overwrite.
      if (s.content.startsWith("!inline ")) {
        try {
          await stat(filePath);
        } catch {
          await writeFile(filePath, "", "utf-8");
        }
        continue;
      }
      let needsWrite = true;
      try {
        const existing = await readTextFile(filePath);
        if (existing === s.content) needsWrite = false;
      } catch {
        // File doesn't exist
      }
      if (needsWrite) {
        await writeFile(filePath, s.content, "utf-8");
        log.info(`Wrote inline script: ${filePath}`);
      }
    }

    // Only rewrite flow.yaml when the serialized YAML actually differs from
    // what's on disk. Avoids noisy mtime updates that re-trigger the watcher.
    const flowYaml = yamlStringify(data.flow);
    let currentYaml: string | undefined;
    try {
      currentYaml = await readTextFile(flowYamlPath);
    } catch {
      // File doesn't exist
    }
    if (currentYaml?.trimEnd() !== flowYaml.trimEnd()) {
      await writeFile(flowYamlPath, flowYaml, "utf-8");
      log.info(`Wrote flow: ${flowYamlPath}`);
    }

    // Orphan cleanup: extension does this unconditionally and overshoots,
    // deleting README.md / fixtures / .env.local. We restrict to known
    // inline-script extensions.
    const extractedPaths = new Set(allExtracted.map((s) => s.path));
    try {
      const dirFiles = await readdir(flowDir);
      for (const file of dirFiles) {
        if (file === "flow.yaml" || file === "flow.json" || file.startsWith(".")) continue;
        if (!INLINE_SCRIPT_EXTS.has(path.extname(file))) continue;
        if (!extractedPaths.has(file)) {
          await unlink(flowDir + file);
          log.info(`Removed orphaned file: ${flowDir + file}`);
        }
      }
    } catch {
      // Directory read failed
    }
  }

  const connectedClients: Set<WebSocket> = new Set();

  // Send a message to all connected clients, gated by --path when set so we
  // don't spam clients (or risk yanking their view) with edits to unrelated files.
  function broadcastChanges(lastEdit: LastEditScript | LastEditFlow) {
    if (opts.path && normalizeWmPath(lastEdit.path) !== opts.path) {
      return;
    }
    for (const client of connectedClients.values()) {
      client.send(JSON.stringify(lastEdit));
    }
  }

  function setupDevWs(wss: WebSocketServer) {
    wss.on("connection", (ws: WebSocket) => {
      connectedClients.add(ws);
      console.log("New dev client connected");

      // Push the currently loaded edit so the page renders immediately on
      // page load, without waiting for a file change to trigger a broadcast.
      if (currentLastEdit && ws.readyState === WebSocket.OPEN) {
        try {
          ws.send(JSON.stringify(currentLastEdit));
        } catch (e) {
          console.error("Failed to push initial state to new client:", e);
        }
      }

      // Debounce flow round-trip writes per-client. Each keystroke in the
      // dev page produces a `flow` message; without this, every keystroke
      // races on the same flow.yaml + inline scripts, and the resulting
      // fsWatcher echoes engage the client's lockChanges mid-typing,
      // dropping subsequent edits. Mirrors the windmill-vscode extension's
      // 200ms debounce in src/extension.ts.
      let pendingFlowMessage: { flow: any; uriPath: string } | undefined;
      let flowDebounceTimer: ReturnType<typeof setTimeout> | undefined;

      ws.on("close", () => {
        connectedClients.delete(ws);
        if (flowDebounceTimer) {
          clearTimeout(flowDebounceTimer);
          flowDebounceTimer = undefined;
          pendingFlowMessage = undefined;
        }
        console.log("Dev client disconnected");
      });

      ws.on("message", (message: WebSocket.RawData) => {
        let data;
        try {
          data = JSON.parse(message.toString());
        } catch (e) {
          console.log("Received invalid JSON: " + message + " " + e);
          return;
        }

        if (data.type === "load") {
          loadPaths([data.path]);
        } else if (data.type === "flow") {
          pendingFlowMessage = data;
          if (flowDebounceTimer) {
            clearTimeout(flowDebounceTimer);
          }
          flowDebounceTimer = setTimeout(() => {
            const msg = pendingFlowMessage;
            pendingFlowMessage = undefined;
            flowDebounceTimer = undefined;
            if (msg) {
              handleFlowRoundTrip(msg).catch((err) => {
                log.error(`Failed to write flow changes: ${err}`);
              });
            }
          }, 200);
        } else if (data.type === "loadWmPath") {
          loadWmPath(data.path).then((edit) => {
            if (edit && ws.readyState === WebSocket.OPEN) {
              ws.send(JSON.stringify(edit));
            }
          }).catch((err) => {
            log.error(`Failed to load path ${data.path}: ${err}`);
          });
        } else if (data.type === "listPaths") {
          listWorkspacePaths().then((items) => {
            if (ws.readyState === WebSocket.OPEN) {
              ws.send(JSON.stringify({ type: "paths", items }));
            }
          }).catch((err) => {
            log.error(`Failed to list paths: ${err}`);
          });
        }
      });
    });
  }

  function maybeOpenBrowser(url: string) {
    if (opts.open === false) return;
    try {
      open.default(url).catch((error) => {
        console.error(
          `Failed to open browser, please navigate to ${url}, error: ${error}`
        );
      });
      console.log(`Opened browser at ${url}`);
    } catch (error) {
      console.error(
        `Failed to open browser, please navigate to ${url}, ${error}`
      );
    }
  }

  // --- Proxy mode (when --proxy-port is set) ---
  //
  // Runs a localhost HTTP server that:
  //   - serves the dev page from `http://localhost:<proxyPort>/` (forwarded
  //     to the remote workspace), so embedders that need a localhost origin
  //     can render it (e.g. Claude Code's port-detection preview), and
  //   - upgrades local /ws connections back to this same process for the
  //     live-reload channel.
  //
  // The simpler "direct" mode below works for standalone browser tabs and the
  // VS Code extension's iframe — only embedders that demand a localhost origin
  // need this proxy.

  async function startProxyServer(requestedPort: number) {
    // Probe both IPv4 and IPv6 stacks before binding. If the requested port is
    // taken on either, walk upward to the next free one so we don't silently
    // collide with a leftover dev server (see cli/src/utils/port-probe.ts).
    const proxyPort = await resolveBindPort(requestedPort, "--proxy-port", {
      info: (m) => console.log(m),
      warn: (m) => console.warn(m),
    });

    const remote = new URL(workspace.remote);
    const isHttps = remote.protocol === "https:";
    const remoteHost = remote.hostname;
    const remotePort = remote.port ? parseInt(remote.port) : (isHttps ? 443 : 80);
    const httpModule = isHttps ? https : http;

    const devWss = new WebSocketServer({ noServer: true });
    setupDevWs(devWss);

    const proxyWss = new WebSocketServer({ noServer: true });

    const proxyServer = http.createServer((clientReq, clientRes) => {
      const parsedUrl = new URL(clientReq.url ?? "/", `http://localhost`);
      if (parsedUrl.pathname === "/" || parsedUrl.pathname === "") {
        let devUrl = `/dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}&port=${proxyPort}`;
        if (opts.path) {
          devUrl += `&path=${opts.path}`;
        }
        clientRes.writeHead(302, { Location: devUrl });
        clientRes.end();
        return;
      }

      const fwdHeaders: Record<string, string | string[] | undefined> = {
        ...clientReq.headers,
        host: remote.host,
      };
      delete fwdHeaders["connection"];
      delete fwdHeaders["keep-alive"];
      delete fwdHeaders["transfer-encoding"];
      delete fwdHeaders["accept-encoding"];

      const proxyOpts: http.RequestOptions = {
        hostname: remoteHost,
        port: remotePort,
        path: clientReq.url,
        method: clientReq.method,
        headers: fwdHeaders,
      };

      const proxyReq = httpModule.request(proxyOpts, (proxyRes) => {
        const setCookie = proxyRes.headers["set-cookie"];
        if (setCookie) {
          proxyRes.headers["set-cookie"] = setCookie.map((cookie) =>
            cookie.replace(/domain=[^;]+/gi, "domain=localhost")
          );
        }
        clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
        proxyRes.pipe(clientRes, { end: true });
      });

      proxyReq.on("error", (err) => {
        console.error("Proxy error:", err.message);
        clientRes.writeHead(502);
        clientRes.end("Bad Gateway");
      });

      clientReq.pipe(proxyReq, { end: true });
    });

    // WebSocket upgrades
    proxyServer.on("upgrade", (req, socket, head) => {
      const pathname = req.url?.split("?")[0] ?? "";

      if (pathname === "/ws_dev" || pathname === "/ws") {
        devWss.handleUpgrade(req, socket, head, (ws) => {
          devWss.emit("connection", ws, req);
        });
        return;
      }

      if (pathname.startsWith("/ws/") || pathname.startsWith("/ws_mp/") || pathname.startsWith("/ws_debug/")) {
        const wsProtocol = isHttps ? "wss" : "ws";
        const remoteWsUrl = `${wsProtocol}://${remote.host}${req.url}`;
        const remoteWs = new WebSocket(remoteWsUrl, {
          headers: {
            ...req.headers,
            host: remote.host,
          },
        });

        remoteWs.on("open", () => {
          proxyWss.handleUpgrade(req, socket, head, (clientWs) => {
            clientWs.on("message", (data) => {
              if (remoteWs.readyState === WebSocket.OPEN) {
                remoteWs.send(data);
              }
            });
            remoteWs.on("message", (data) => {
              if (clientWs.readyState === WebSocket.OPEN) {
                clientWs.send(data);
              }
            });
            clientWs.on("close", () => remoteWs.close());
            remoteWs.on("close", () => clientWs.close());
          });
        });

        remoteWs.on("error", (err) => {
          console.error("WebSocket proxy error:", err.message);
          socket.destroy();
        });
        return;
      }

      socket.destroy();
    });

    return new Promise<void>((resolve) => {
      proxyServer.listen(proxyPort, BIND_HOST, () => {
        console.log(`Dev proxy listening on http://localhost:${proxyPort}`);
        if (opts.path) {
          console.log(`Watching ${opts.path} — edits will live-reload in the dev page`);
        } else {
          console.log(
            "Open the dev page and pick a flow or script to preview — edits will live-reload"
          );
          console.log("(pass --path <path> to skip the picker)");
        }
        maybeOpenBrowser(`http://localhost:${proxyPort}/`);
        resolve();
      });
    });
  }

  // --- Direct mode (no localhost HTTP proxy) ---
  //
  // The browser loads the dev page from the remote workspace URL and opens a
  // WebSocket directly to this localhost server. Used when:
  //   - the user runs `wmill dev` and opens a regular browser tab, or
  //   - the VS Code extension iframe loads the dev page (its iframe URL omits
  //     `local=true`, so it never opens this WS, but everything else still
  //     functions through the existing remote workspace connection).
  //
  // This is the simplest topology: a bare WebSocket server. The reverse-proxy
  // mode (above) is only needed when something needs to embed the dev UI on a
  // localhost origin (Claude Code's port-detection preview).

  async function startDirectServer() {
    const server = http.createServer((_req, res) => {
      res.writeHead(200);
      res.end();
    });
    const wss = new WebSocketServer({ server });
    setupDevWs(wss);

    // Probe both IPv4 and IPv6 stacks before binding. Same dual-stack
    // collision risk as proxy mode: a leftover wmill dev on [::]:3001 would
    // silently steer localhost:3001 traffic to the wrong process if we only
    // probed one stack (which is what getPort does).
    const port = await resolveBindPort(PORT, "wmill dev", {
      info: (m) => console.log(m),
      warn: (m) => console.warn(m),
    });
    const url =
      `${workspace.remote}dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}` +
      (port === PORT ? "" : `&port=${port}`) +
      (opts.path ? `&path=${opts.path}` : "");

    if (opts.open === false) {
      console.log(`Go to ${url}`);
    }
    maybeOpenBrowser(url);

    if (opts.path) {
      console.log(`Watching ${opts.path} — edits will live-reload in the dev page`);
    } else {
      console.log(
        "Open the dev page and pick a flow or script to preview — edits will live-reload"
      );
    }

    server.listen(port, BIND_HOST, () => {
      console.log(`Dev WebSocket listening on ws://localhost:${port}/ws`);
    });
  }

  // --- Start ---

  // If --path is set, load it immediately
  if (opts.path) {
    await loadWmPath(opts.path);
  }

  const startServer = opts.proxyPort
    ? () => startProxyServer(opts.proxyPort!)
    : () => startDirectServer();

  await Promise.all([startServer(), watchChanges()]);
  console.log("Stopped dev mode");
}

const command = new Command()
  .description("Watch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace — use wmill sync push for that.")
  .option(
    "--includes <pattern...:string>",
    "Filter paths given a glob pattern or path"
  )
  .option(
    "--proxy-port <port:number>",
    "Port for a localhost reverse proxy to the remote Windmill server"
  )
  .option(
    "--path <path:string>",
    "Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)"
  )
  .option(
    "--no-open",
    "Do not open the browser automatically"
  )
  .action(dev as any);

export default command;
