#!/usr/bin/env node
import http from "node:http";
import https from "node:https";
import { createHash, randomUUID } from "node:crypto";
import {
  existsSync,
  mkdirSync,
  readFileSync,
  statSync,
  writeFileSync
} from "node:fs";
import path from "node:path";

const LOCAL_API_BASE_URL = "http://127.0.0.1:6667";
const DEFAULT_API_PORT = "6667";
const DEFAULT_PROJECT_ID = "prj_mvp_topology";
const DEFAULT_RESOURCE_ID = "res_windows_host";
const DEFAULT_CAPABILITY_ID = "cap_windows_cmd_exec";
const DEFAULT_TIMEOUT_MS = 120000;
const MAX_TIMEOUT_MS = 600000;
const DEFAULT_CHUNK_SIZE = 2000;
const MAX_CHUNK_SIZE = 2000;
const REQUEST_TIMEOUT_GRACE_MS = 10000;
const POWERSHELL_PROLOGUE = [
  "$ErrorActionPreference = 'Stop';",
  "$utf8NoBom = [System.Text.UTF8Encoding]::new($false);",
  "$ProgressPreference = 'SilentlyContinue';",
  "[Console]::InputEncoding = $utf8NoBom;",
  "[Console]::OutputEncoding = $utf8NoBom;",
  "$OutputEncoding = $utf8NoBom;",
  "$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8';",
  "$PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8';"
].join("\n");

async function main() {
  const cli = parseCli(process.argv.slice(2));
  if (cli.help) {
    printHelp(cli.exitCode ?? 0);
    return;
  }

  const apiBaseUrl = stripTrailingSlash(cli.options.apiBaseUrl || process.env.HWLAB_GATEWAY_TRAN_API_BASE_URL || process.env.HWLAB_GATEWAY_SHELL_API_BASE_URL || defaultApiBaseUrl());
  const timeoutMs = boundedPositiveInteger(cli.options.timeoutMs ?? process.env.HWLAB_GATEWAY_TRAN_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, { min: 1000, max: MAX_TIMEOUT_MS });
  const requestTimeoutMs = boundedPositiveInteger(cli.options.requestTimeoutMs, timeoutMs + REQUEST_TIMEOUT_GRACE_MS, {
    min: timeoutMs,
    max: timeoutMs + Math.max(REQUEST_TIMEOUT_GRACE_MS, Math.floor(timeoutMs / 2))
  });
  const chunkSize = boundedPositiveInteger(cli.options.chunkSize ?? process.env.HWLAB_GATEWAY_TRAN_CHUNK_SIZE, DEFAULT_CHUNK_SIZE, { min: 128, max: MAX_CHUNK_SIZE });
  const target = parseLocator(cli.locator);
  const context = { apiBaseUrl, timeoutMs, requestTimeoutMs, chunkSize, json: cli.options.json, target };

  if (cli.command === "cmd") {
    await runCmd(context, cli.args);
  } else if (cli.command === "ps") {
    await runPowerShell(context, cli.args);
  } else if (cli.command === "upload") {
    await uploadFile(context, cli.args);
  } else if (cli.command === "download") {
    await downloadFile(context, cli.args);
  } else {
    fail(`unknown command: ${cli.command}. Use cmd, ps, upload, or download.`);
  }
}

function defaultApiBaseUrl() {
  const namespace = readKubernetesNamespace();
  if (process.env.KUBERNETES_SERVICE_HOST && namespace) {
    return `http://hwlab-cloud-api.${namespace}.svc.cluster.local:${DEFAULT_API_PORT}`;
  }
  return LOCAL_API_BASE_URL;
}

function readKubernetesNamespace() {
  try {
    const value = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf8").trim();
    if (/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(value)) return value;
  } catch {
    // Non-Kubernetes hosts keep the local cloud-api default.
  }
  return "";
}

function parseCli(argv) {
  if (argv.length === 0) return { help: true, exitCode: 2 };
  if (argv[0] === "--help" || argv[0] === "-h") return { help: true, exitCode: 0 };
  const [locator, command, ...rest] = argv;
  if (!locator || !command) return { help: true, exitCode: 2 };
  const options = {};
  const args = [];
  for (let index = 0; index < rest.length; index += 1) {
    const item = rest[index];
    if (item === "--") {
      args.push(...rest.slice(index + 1));
      break;
    }
    if (item === "--json") {
      options.json = true;
    } else if (item === "--api-base-url") {
      options.apiBaseUrl = requireOptionValue(rest, index, item);
      index += 1;
    } else if (item.startsWith("--api-base-url=")) {
      options.apiBaseUrl = item.slice("--api-base-url=".length);
    } else if (item === "--timeout-ms") {
      options.timeoutMs = requireOptionValue(rest, index, item);
      index += 1;
    } else if (item.startsWith("--timeout-ms=")) {
      options.timeoutMs = item.slice("--timeout-ms=".length);
    } else if (item === "--request-timeout-ms") {
      options.requestTimeoutMs = requireOptionValue(rest, index, item);
      index += 1;
    } else if (item.startsWith("--request-timeout-ms=")) {
      options.requestTimeoutMs = item.slice("--request-timeout-ms=".length);
    } else if (item === "--chunk-size") {
      options.chunkSize = requireOptionValue(rest, index, item);
      index += 1;
    } else if (item.startsWith("--chunk-size=")) {
      options.chunkSize = item.slice("--chunk-size=".length);
    } else {
      args.push(item);
    }
  }
  return { locator, command, options, args };
}

function requireOptionValue(argv, index, flag) {
  const value = argv[index + 1];
  if (!value) fail(`${flag} requires a value`);
  return value;
}

function parseLocator(locator) {
  const colonIndex = String(locator).indexOf(":");
  if (colonIndex < 1) {
    fail(`invalid locator: ${locator}. Expected gatewaySessionId:/f/work or gatewaySessionId:f:/work.`);
  }
  const gatewaySessionId = locator.slice(0, colonIndex);
  const rawWorkspace = locator.slice(colonIndex + 1);
  if (!gatewaySessionId || !rawWorkspace) {
    fail(`invalid locator: ${locator}. Gateway id and workspace path are required.`);
  }
  return {
    gatewaySessionId,
    rawWorkspace,
    workspace: normalizeGatewayPath(rawWorkspace)
  };
}

function normalizeGatewayPath(value) {
  const input = String(value ?? "").trim();
  if (!input) fail("empty gateway workspace path");
  if (/^[a-zA-Z]:[\\/]/u.test(input)) {
    return `${input[0].toUpperCase()}:${input.slice(2).replace(/[\\/]+/gu, "\\")}`;
  }
  const msysMatch = input.match(/^\/([a-zA-Z])(?:\/|$)(.*)$/u);
  if (msysMatch) {
    const drive = msysMatch[1].toUpperCase();
    const rest = msysMatch[2] ? `\\${msysMatch[2].replace(/[\\/]+/gu, "\\")}` : "\\";
    return `${drive}:${rest}`;
  }
  if (/^\\\\/u.test(input)) return input.replace(/\//gu, "\\");
  return input.replace(/[\\/]+/gu, "\\");
}

async function runCmd(context, args) {
  const command = shellText(args, "cmd requires a command after --");
  const response = await invokeShell(context, {
    command: buildPowerShellCommand(buildCmdPassthroughScript(command), context.target.workspace),
    cwd: context.target.workspace
  });
  if (context.json) printJson(response);
  else printDispatch(response);
  process.exit(isFailed(response) ? 1 : 0);
}

function buildCmdPassthroughScript(command) {
  return [
    `$cmd = '${psQuote(command)}'`,
    "$env:PYTHONUTF8 = '1'",
    "$env:PYTHONIOENCODING = 'utf-8'",
    "$env:PYTHONLEGACYWINDOWSSTDIO = '0'",
    "& cmd.exe /d /s /c \"chcp 65001 >NUL & $cmd\"",
    "exit $LASTEXITCODE"
  ].join("\n");
}

async function runPowerShell(context, args) {
  const script = shellText(args, "ps requires a PowerShell script after --");
  const command = buildPowerShellCommand(script, context.target.workspace);
  const response = await invokeShell(context, { command, cwd: context.target.workspace });
  if (context.json) printJson(response);
  else printDispatch(response);
  process.exit(isFailed(response) ? 1 : 0);
}

async function uploadFile(context, args) {
  if (args.length < 1) fail("upload requires <local_source_path> [remote_target_path]");
  const localPath = args[0];
  if (!existsSync(localPath)) fail(`local file not found: ${localPath}`);
  const data = readFileSync(localPath);
  const stat = statSync(localPath);
  const localHash = sha256(data);
  const remotePath = resolveRemotePath(context.target.workspace, args[1] || path.basename(localPath));
  const tempPath = `${remotePath}.hwlab-upload-${randomUUID()}.b64`;
  const dataTempPath = `${remotePath}.hwlab-upload-${randomUUID()}.tmp`;
  const chunks = splitText(data.toString("base64"), context.chunkSize);

  process.stderr.write(`upload ${localPath} -> ${remotePath} (${stat.size} bytes, ${chunks.length} chunks)\n`);
  await expectOk(await invokePowerShell(context, [
    `$remotePath = '${psQuote(remotePath)}'`,
    `$tempPath = '${psQuote(tempPath)}'`,
    "$dir = [System.IO.Path]::GetDirectoryName($remotePath)",
    "if (-not [string]::IsNullOrWhiteSpace($dir)) { [System.IO.Directory]::CreateDirectory($dir) | Out-Null }",
    "[System.IO.File]::WriteAllText($tempPath, '', [System.Text.Encoding]::ASCII)",
    `Write-Output 'upload-init:${chunks.length}'`
  ].join("\n")), "upload init");

  for (let index = 0; index < chunks.length; index += 1) {
    await expectOk(await invokePowerShell(context, [
      `$tempPath = '${psQuote(tempPath)}'`,
      `[System.IO.File]::AppendAllText($tempPath, '${chunks[index]}', [System.Text.Encoding]::ASCII)`,
      `Write-Output 'upload-chunk:${index + 1}/${chunks.length}'`
    ].join("\n")), `upload chunk ${index + 1}/${chunks.length}`);
  }

  const finalResponse = await invokePowerShell(context, [
    `$remotePath = '${psQuote(remotePath)}'`,
    `$tempPath = '${psQuote(tempPath)}'`,
    `$dataTempPath = '${psQuote(dataTempPath)}'`,
    `$expectedBytes = ${stat.size}`,
    `$expectedHash = '${localHash}'`,
    "$b64 = [System.IO.File]::ReadAllText($tempPath, [System.Text.Encoding]::ASCII)",
    "$bytes = [System.Convert]::FromBase64String($b64)",
    "[System.IO.File]::WriteAllBytes($dataTempPath, $bytes)",
    "$item = Get-Item -LiteralPath $dataTempPath",
    "$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $dataTempPath).Hash.ToLowerInvariant()",
    "if ($item.Length -ne $expectedBytes) { throw \"upload byte mismatch: $($item.Length) != $expectedBytes\" }",
    "if ($hash -ne $expectedHash) { throw \"upload sha256 mismatch: $hash != $expectedHash\" }",
    "Move-Item -LiteralPath $dataTempPath -Destination $remotePath -Force",
    "Remove-Item -LiteralPath $tempPath -Force",
    "$item = Get-Item -LiteralPath $remotePath",
    "$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $remotePath).Hash.ToLowerInvariant()",
    "if ($item.Length -ne $expectedBytes) { throw \"upload final byte mismatch: $($item.Length) != $expectedBytes\" }",
    "if ($hash -ne $expectedHash) { throw \"upload final sha256 mismatch: $hash != $expectedHash\" }",
    "Write-Output (\"upload-ok path={0} bytes={1} sha256={2}\" -f $remotePath, $item.Length, $hash)"
  ].join("\n"));

  if (context.json) printJson(finalResponse);
  else printDispatch(finalResponse);
  process.exit(isFailed(finalResponse) ? 1 : 0);
}

async function downloadFile(context, args) {
  if (args.length < 1) fail("download requires <remote_source_path> [local_target_path]");
  const remotePath = resolveRemotePath(context.target.workspace, args[0]);
  const localPath = args[1] || path.basename(remotePath.replace(/\\/gu, "/"));
  const statResponse = await invokePowerShell(context, [
    `$remotePath = '${psQuote(remotePath)}'`,
    "if (-not (Test-Path -LiteralPath $remotePath -PathType Leaf)) { throw \"remote file not found: $remotePath\" }",
    "$item = Get-Item -LiteralPath $remotePath",
    "$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $remotePath).Hash.ToLowerInvariant()",
    "$b64Length = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($remotePath)).Length",
    "[pscustomobject]@{ path = $remotePath; bytes = $item.Length; sha256 = $hash; base64Length = $b64Length } | ConvertTo-Json -Compress"
  ].join("\n"));
  expectOk(statResponse, "download stat");
  const info = parseJsonLine(dispatchOf(statResponse).stdout, "download stat JSON");
  const chunks = [];
  process.stderr.write(`download ${remotePath} -> ${localPath} (${info.bytes} bytes)\n`);
  for (let offset = 0; offset < info.base64Length; offset += context.chunkSize) {
    const length = Math.min(context.chunkSize, info.base64Length - offset);
    const response = await invokePowerShell(context, [
      `$remotePath = '${psQuote(remotePath)}'`,
      `$offset = ${offset}`,
      `$length = ${length}`,
      "$b64 = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($remotePath))",
      "Write-Output $b64.Substring($offset, $length)"
    ].join("\n"));
    expectOk(response, `download chunk ${Math.floor(offset / context.chunkSize) + 1}`);
    chunks.push(String(dispatchOf(response).stdout ?? "").replace(/\s+/gu, ""));
  }
  const data = Buffer.from(chunks.join(""), "base64");
  const actualHash = sha256(data);
  if (data.length !== info.bytes) fail(`download byte mismatch: ${data.length} != ${info.bytes}`);
  if (actualHash !== String(info.sha256).toLowerCase()) fail(`download sha256 mismatch: ${actualHash} != ${info.sha256}`);
  mkdirSync(path.dirname(localPath), { recursive: true });
  writeFileSync(localPath, data);
  if (context.json) printJson({ status: "downloaded", remotePath, localPath, bytes: data.length, sha256: actualHash });
  else process.stderr.write(`download-ok path=${localPath} bytes=${data.length} sha256=${actualHash}\n`);
}

function resolveRemotePath(workspace, candidate) {
  const text = String(candidate ?? "").trim();
  if (!text) fail("remote path is empty");
  if (/^[a-zA-Z]:[\\/]/u.test(text) || /^\/([a-zA-Z])(?:\/|$)/u.test(text) || /^\\\\/u.test(text)) {
    return normalizeGatewayPath(text);
  }
  return `${workspace.replace(/\\+$/u, "")}\\${text.replace(/[\\/]+/gu, "\\")}`;
}

function shellText(args, message) {
  if (!Array.isArray(args) || args.length === 0) fail(message);
  return args.join(" ");
}

function buildPowerShellCommand(script, cwd) {
  const source = [
    POWERSHELL_PROLOGUE,
    cwd ? `Set-Location -LiteralPath '${psQuote(cwd)}';` : "",
    script
  ].filter(Boolean).join("\n");
  const encoded = Buffer.from(source, "utf16le").toString("base64");
  return `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
}

function invokePowerShell(context, script) {
  return invokeShell(context, {
    command: buildPowerShellCommand(script, context.target.workspace),
    cwd: context.target.workspace
  });
}

async function invokeShell(context, { command, cwd }) {
  const requestId = `req_gateway_tran_${randomUUID()}`;
  const operationId = `op_gateway_tran_${randomUUID()}`;
  const response = await requestJson(`${context.apiBaseUrl}/v1/rpc/hardware.invoke.shell`, {
    method: "POST",
    timeoutMs: context.requestTimeoutMs,
    headers: {
      "content-type": "application/json",
      "x-trace-id": `trc_gateway_tran_${Date.now()}`,
      "x-request-id": requestId,
      "x-actor-id": "usr_code_agent",
      "x-source-service-id": "hwlab-cloud-api"
    },
    body: {
      projectId: DEFAULT_PROJECT_ID,
      operationId,
      gatewaySessionId: context.target.gatewaySessionId,
      resourceId: DEFAULT_RESOURCE_ID,
      capabilityId: DEFAULT_CAPABILITY_ID,
      input: {
        command,
        cwd,
        timeoutMs: context.timeoutMs
      }
    }
  });
  return response.body;
}

function dispatchOf(response) {
  const result = response?.result ?? {};
  return result.dispatch ?? result;
}

function printDispatch(response) {
  const dispatch = dispatchOf(response);
  if (dispatch.stdout) process.stdout.write(String(dispatch.stdout));
  if (dispatch.stderr) process.stderr.write(String(dispatch.stderr));
  if (dispatch.stdout && !String(dispatch.stdout).endsWith("\n")) process.stdout.write("\n");
  if (dispatch.stderr && !String(dispatch.stderr).endsWith("\n")) process.stderr.write("\n");
  if (response?.error) process.stderr.write(`error=${response.error.message ?? response.error.code}\n`);
  if (dispatch.dispatchStatus && dispatch.dispatchStatus !== "succeeded") process.stderr.write(`dispatch=${dispatch.dispatchStatus}\n`);
  if (Number.isInteger(dispatch.exitCode) && dispatch.exitCode !== 0) process.stderr.write(`exit=${dispatch.exitCode}\n`);
}

function isFailed(response) {
  if (!response || response.error) return true;
  const result = response.result ?? {};
  const dispatch = dispatchOf(response);
  if (["failed", "rejected", "timed_out"].includes(result.status) || ["failed", "rejected", "timed_out", "not_connected"].includes(dispatch.dispatchStatus)) return true;
  if (Number.isInteger(dispatch.exitCode) && dispatch.exitCode !== 0) return true;
  if (result.accepted === true && dispatch.shellExecuted === false) return true;
  return false;
}

function expectOk(response, label) {
  if (!isFailed(response)) return;
  printDispatch(response);
  fail(`${label} failed`);
}

function requestJson(url, { method, headers, body, timeoutMs }) {
  return new Promise((resolve, reject) => {
    const target = new URL(url);
    const client = target.protocol === "https:" ? https : http;
    const payload = JSON.stringify(body ?? {});
    const request = client.request(target, {
      method,
      headers: { ...headers, "content-length": Buffer.byteLength(payload) },
      timeout: timeoutMs
    }, (response) => {
      let text = "";
      response.setEncoding("utf8");
      response.on("data", (chunk) => { text += chunk; });
      response.on("end", () => {
        try {
          resolve({ statusCode: response.statusCode ?? 0, body: text ? JSON.parse(text) : null });
        } catch (error) {
          reject(new Error(`Cloud API returned non-JSON response: ${error.message}`));
        }
      });
    });
    request.on("timeout", () => request.destroy(new Error(`Gateway tran request timed out after ${timeoutMs}ms`)));
    request.on("error", reject);
    request.end(payload);
  });
}

function parseJsonLine(text, label) {
  const line = String(text ?? "").split(/\r?\n/u).find((item) => item.trim().startsWith("{") || item.trim().startsWith("["));
  if (!line) fail(`${label} was not present`);
  try {
    return JSON.parse(line);
  } catch (error) {
    fail(`${label} parse failed: ${error.message}`);
  }
}

function splitText(text, size) {
  const chunks = [];
  for (let index = 0; index < text.length; index += size) chunks.push(text.slice(index, index + size));
  return chunks.length ? chunks : [""];
}

function psQuote(value) {
  return String(value).replace(/'/gu, "''");
}

function sha256(buffer) {
  return createHash("sha256").update(buffer).digest("hex");
}

function printJson(value) {
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}

function boundedPositiveInteger(value, fallback, { min, max } = {}) {
  const parsed = Number.parseInt(value ?? "", 10);
  const selected = Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
  return Math.min(Math.max(selected, min ?? 1), max ?? selected);
}

function stripTrailingSlash(value) {
  return String(value).replace(/\/+$/u, "");
}

function printHelp(exitCode = 0) {
  const stream = exitCode === 0 ? process.stdout : process.stderr;
  stream.write([
    "Usage: node /app/tools/hwlab-gateway-tran.mjs <gateway:path> <cmd|ps|upload|download> [options] -- <args>",
    "",
    "Locator examples:",
    "  gws_DESKTOP-1MHOD9I:/f/work",
    "  gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp",
    "",
    "Commands:",
    "  cmd -- <command>                  Run a Windows cmd command in the gateway workspace.",
    "  ps -- <script>                    Run a UTF-8 PowerShell script in the gateway workspace.",
    "  upload <local> [remote]           Upload a local file using verified base64 chunks.",
    "  download <remote> [local]         Download a remote file using verified base64 chunks.",
    "",
    "Options:",
    "  --api-base-url URL                Default env override, in-cluster hwlab-cloud-api service, or http://127.0.0.1:6667.",
    "  --timeout-ms N                    Gateway command timeout, default 120000ms.",
    "  --request-timeout-ms N            HTTP wait timeout; defaults to command timeout plus grace.",
    "  --chunk-size N                    Upload/download base64 chunk size, default 2000, max 2000.",
    "  --json                            Print the full JSON response for cmd/ps or transfer summary.",
    "",
    "Examples:",
    "  node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- pwd",
    "  node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work ps -- 'Get-ChildItem'",
    "  node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp upload ./hwlab-gateway-tran.mjs",
    "  node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work download hwlab/.tmp/hwlab-gateway-tran.mjs ./hwlab-gateway-tran.mjs"
  ].join("\n") + "\n");
  process.exit(exitCode);
}

function fail(message) {
  process.stderr.write(`error: ${message}\n`);
  process.exit(1);
}

main().catch((error) => {
  process.stderr.write(`fatal: ${error.message}\n`);
  process.exit(1);
});
