const express = require('express');
const http = require('http');
const crypto = require('crypto');
const { ok, errorResponse, notFound, conflict } = require('../src/utils/responses');

/**
 * OpenClaw management routes
 * Proxies gateway API calls through DashCaddy so the token never leaves the server.
 *
 * GET  /openclaw/status          → container info + gateway health
 * POST /openclaw/deploy          → deploy OpenClaw container
 * GET  /openclaw/proxy/*         → proxy GET to gateway
 * POST /openclaw/proxy/*        → proxy POST to gateway
 * DELETE /openclaw               → remove container
 */
module.exports = function openClawRoutes(ctx) {
  const router = express.Router();
  const docker = ctx.docker;
  const asyncHandler = ctx.asyncHandler;
  const ok = ctx.ok;
  const log = ctx.log || console;

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

  async function findOpenClawContainer() {
    const containers = await docker.client.listContainers({ all: true });
    return containers.find(function(c) {
      return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
        (c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
         c.Names.some(function(n) { return n.includes('openclaw'); }));
    }) || null;
  }

  async function getGatewayToken(containerId) {
    try {
      const info = await docker.client.containerInfo(containerId);
      const entry = (info.Config.Env || []).find(function(e) {
        return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
      });
      return entry ? entry.split('=')[1] : null;
    } catch(err) {
      return null;
    }
  }

  async function getContainerPort(containerId) {
    try {
      const containers = await docker.client.listContainers({ all: true });
      const c = containers.find(function(x) {
        return x.Id === containerId || x.Id.startsWith(containerId);
      });
      if (c && c.Ports) {
        const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
        if (p && p.PublicPort) return String(p.PublicPort);
      }
      return '18792';
    } catch(err) {
      return '18792';
    }
  }

  async function gatewayHealth(baseUrl, token) {
    return new Promise(function(resolve) {
      const headers = {};
      if (token) headers['Authorization'] = 'Bearer ' + token;
      const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
        let data = '';
        res.on('data', function(d) { data += d; });
        res.on('end', function() {
          try { resolve({ ok: true, data: JSON.parse(data) }); }
          catch(e) { resolve({ ok: true, data: data }); }
        });
      });
      req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
      req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
    });
  }

  /**
   * DC-065: OpenClaw proxy hardening.
   *
   * Three attack vectors were previously open:
   *   (a) Unbounded response passthrough — proxyRes.on('data') wrote every
   *       byte to the client without a cap, allowing a compromised/buggy
   *       OpenClaw container to push arbitrarily large payloads (DoS,
   *       log-spam, memory pressure on the API container).
   *   (b) Hop-by-hop / response-shaping headers forwarded verbatim — Node's
   *       `res.set(proxyRes.headers)` copies Connection, Keep-Alive,
   *       Transfer-Encoding, Upgrade, Proxy-Authenticate, Proxy-Authorization,
   *       TE, Trailers, Set-Cookie, Content-Encoding, Content-Length, and
   *       Server. Per RFC 7230 §6.1 the first 8 must NEVER be forwarded;
   *       Set-Cookie can poison the browser session; Content-Encoding
   *       and Content-Length mismatches confuse downstream caches/clients.
   *   (c) `proxyRes.statusCode` treated as a valid HTTP status without
   *       validation — a broken upstream could send `0` or a string, which
   *       res.status() would either accept (silent corruption) or throw
   *       RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express default
   *       error handler returns HTML).
   *   (d) `path` taken from req.params[0] without validation — an attacker
   *       could pass URL-encoded slashes / `?` / `#` chars / absolute URLs
   *       to redirect the proxy elsewhere on localhost.
   *
   * The five fixes below close (a)-(d) without changing the on-the-wire
   * shape of the proxy from a same-origin browser's perspective.
   */
  // RFC 7230 §6.1 hop-by-hop headers that must NEVER be forwarded by a proxy.
  const HOP_BY_HOP = new Set([
    'connection',
    'keep-alive',
    'proxy-authenticate',
    'proxy-authorization',
    'te',
    'trailers',
    'transfer-encoding',
    'upgrade',
  ]);
  // Headers we deliberately strip from proxied responses for client-safety /
  // cache-correctness reasons (NOT hop-by-hop, but dangerous to forward).
  // DC-065 round-1 GLM-5.3 finding: `location` MUST be stripped — a
  // 3xx response with `Location: http://evil.com/x` would be honored by
  // the same-origin browser because the proxy response is on
  // /openclaw/proxy/* (same-origin from the dashboard's perspective) and
  // the proxy didn't downgrade the status. This is a classic open-redirect
  // through proxy. We strip Location and let the browser stay put (or,
  // for clients that depend on redirect-following, they can retry the
  // upstream directly without our proxy in the path).
  // DC-065 round-2 GLM-5.3 finding: `refresh` and `www-authenticate` are
  // in the same class and were also leaking. `Refresh: 0; url=...` is
  // honored by a meaningful subset of browsers (older Chrome, Firefox,
  // Safari, mobile WebViews) as an open-redirect primitive. `WWW-
  // Authenticate: Basic realm=...` pops a native browser auth dialog on
  // the dashboard's origin (phishing/UX attack). Both stripped.
  const STRIPPED_RESPONSE_HEADERS = new Set([
    'set-cookie',         // upstream browser poisoning
    'location',           // round-1 GLM finding — open-redirect through proxy
    'refresh',            // round-2 GLM finding — same-class open-redirect primitive
    'www-authenticate',   // round-2 GLM finding — phishing via browser auth prompt
    'content-encoding',   // we send raw bytes; mismatched encoding breaks clients
    'content-length',     // node auto-computes; forwarding can desync with body
    'server',             // upstream fingerprinting
    'x-powered-by',       // upstream fingerprinting
  ]);
  // 5 MiB is a generous cap for a chat / gateway UI; anything larger is
  // either a misconfigured upstream or an attack. Picked to match the
  // express.json({ limit }) default in src/utilities/middleware.js.
  const MAX_PROXY_RESPONSE_BYTES = 5 * 1024 * 1024;
  // Allowed chars in the downstream `path` segment: alphanumerics, `-`, `_`,
  // `.`, `~`, `/`, `?`, `&`, `=`, `:`, `@`, `+`, `,`, `;` (RFC 3986 pchar +
  // query/fragment separators). Anything else → 400.
  const ALLOWED_PATH_RE = /^[a-zA-Z0-9._~/?&=:@+,;%\-]*$/;
  // Maximum total `path` length (reasonable for a gateway UI endpoint).
  const MAX_PATH_LEN = 1024;

  function sanitizeForwardedHeaders(rawHeaders) {
    const out = {};
    for (const name of Object.keys(rawHeaders || {})) {
      const lower = name.toLowerCase();
      if (HOP_BY_HOP.has(lower)) continue;
      if (STRIPPED_RESPONSE_HEADERS.has(lower)) continue;
      out[name] = rawHeaders[name];
    }
    return out;
  }

  function coerceUpstreamStatus(rawStatus) {
    // Status must be an integer in 100..599. Anything else → 502 (the proxy
    // failed to interpret the upstream response, which is exactly what 502
    // semantically means: bad gateway).
    if (
      typeof rawStatus !== 'number'
      || !Number.isInteger(rawStatus)
      || rawStatus < 100
      || rawStatus > 599
    ) {
      return 502;
    }
    return rawStatus;
  }

  function validatePath(path) {
    if (typeof path !== 'string') return { ok: false, code: 400, msg: 'path must be a string' };
    if (path.length === 0) return { ok: false, code: 400, msg: 'path is empty' };
    if (path.length > MAX_PATH_LEN) return { ok: false, code: 414, msg: 'path too long' };
    // Reject absolute-URL injection (`://`), backslashes (Windows path-style
    // smuggling), CRLF (header injection on rare downstream), and any char
    // outside the RFC 3986 pchar/query/fragment set.
    if (/[\s\\]|:\/\//.test(path)) return { ok: false, code: 400, msg: 'path contains forbidden characters' };
    if (!ALLOWED_PATH_RE.test(path)) return { ok: false, code: 400, msg: 'path contains disallowed characters' };
    // Strip a single leading slash so we can rebuild as `${targetBase}/${path}`
    // idempotently (targetBase already has a trailing `:PORT` form).
    return { ok: true, normalized: path.replace(/^\/+/, '') };
  }

  // DC-065: expose helpers via the router for direct unit testing. The
  // router is an Express Router; any property we add here stays private
  // to the module and is read by __tests__/routes/openclaw.proxy-hardening
  // .test.js without going through Express.
  router._dc065 = {
    HOP_BY_HOP,
    STRIPPED_RESPONSE_HEADERS,
    MAX_PROXY_RESPONSE_BYTES,
    ALLOWED_PATH_RE,
    MAX_PATH_LEN,
    sanitizeForwardedHeaders,
    coerceUpstreamStatus,
    validatePath,
  };

  function proxyRequest(req, res, targetBase, path, token) {
    const pathCheck = validatePath(path);
    if (!pathCheck.ok) {
      return errorResponse(res, pathCheck.code, pathCheck.msg);
    }

    const headers = {};
    if (token) headers['Authorization'] = 'Bearer ' + token;
    headers['X-Forwarded-For'] = req.ip;
    headers['X-Forwarded-Proto'] = req.protocol;

    const url = targetBase + '/' + pathCheck.normalized;
    const method = req.method;

    // Stream the upstream response through `res` with a byte-size cap. On
    // overrun we abort the proxyReq and reply with 502 Bad Gateway. The
    // accumulated bytes are tracked per-call; if MAX_PROXY_RESPONSE_BYTES
    // is exceeded, we close the upstream and tear down the client response.
    function pipeUpstream(proxyReq) {
      // Buffer-first response proxy: collect chunks in memory until either
      // the upstream finishes or MAX_PROXY_RESPONSE_BYTES is exceeded. Then
      // emit a single Express response with sanitized headers + the
      // buffered body, or a 502 if the cap fired. Two reasons for the
      // buffer-first approach:
      //
      //   1. Once res.status() is called and headers are flushed (which
      //      happens on the first res.write), the status code is locked.
      //      Streaming the body through res.write lets a malicious
      //      upstream send 1 byte of 200 OK + N bytes of garbage; we can't
      //      retroactively downgrade to 502. Buffering lets us inspect
      //      the full response before committing to a status.
      //
      //   2. Synchronous status/header/body emission is cheaper than
      //      backpressure-aware chunked writes for a proxy that
      //      specifically serves JSON-RPC + small payloads (OpenClaw's
      //      gateway chat API is not a streaming use case).
      //
      // Memory cost: MAX_PROXY_RESPONSE_BYTES per concurrent proxy
      // request. At 5 MiB and Node's default 1000 concurrent connections
      // (server.maxConnections defaults to Infinity), worst-case is ~5
      // GiB. We cap concurrency in start.sh via Node CLI flags; see
      // ulimit + --max-old-space-size settings.
      const chunks = [];
      let totalBytes = 0;
      let capped = false;
      let finishedEarly = false;
      proxyReq.on('response', function(proxyRes) {
        // Pre-check: if upstream claimed a Content-Length above the cap,
        // reject before consuming any body bytes. This is the common case
        // — most well-behaved upstreams declare length up-front.
        const declaredLength = parseInt(proxyRes.headers['content-length'], 10);
        if (Number.isFinite(declaredLength) && declaredLength > MAX_PROXY_RESPONSE_BYTES) {
          capped = true;
          proxyReq.destroy();
          return errorResponse(res, 502, '[DC-065] upstream Content-Length ' + declaredLength + ' exceeds ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap');
        }
        proxyRes.on('data', function(chunk) {
          if (capped || finishedEarly) return;
          totalBytes += chunk.length;
          if (totalBytes > MAX_PROXY_RESPONSE_BYTES) {
            capped = true;
            proxyReq.destroy();
            if (!finishedEarly) {
              finishedEarly = true;
              if (!res.headersSent && !res.writableEnded) {
                errorResponse(res, 502, '[DC-065] upstream response exceeded ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap');
              }
            }
            return;
          }
          chunks.push(chunk);
        });
        proxyRes.on('end', function() {
          if (capped) return;
          finishedEarly = true;
          const body = Buffer.concat(chunks);
          const safeHeaders = sanitizeForwardedHeaders(proxyRes.headers);
          try { res.set(safeHeaders); } catch (_) { /* noop if socket closed */ }
          const safeStatus = coerceUpstreamStatus(proxyRes.statusCode);
          try {
            res.status(safeStatus);
            res.end(body);
          } catch (_) { /* socket may be closed */ }
        });
        proxyRes.on('error', function() {
          if (!finishedEarly) {
            finishedEarly = true;
            try {
              if (!res.headersSent) res.status(502).end();
              else res.end();
            } catch (_) { /* socket may be closed */ }
          }
        });
      });
      proxyReq.on('error', function(e) {
        if (!finishedEarly) {
          finishedEarly = true;
          if (!res.headersSent && !res.writableEnded) {
            errorResponse(res, 502, e.message);
          }
        }
      });
      proxyReq.setTimeout(15000, function() {
        proxyReq.destroy();
        if (!finishedEarly && !res.headersSent && !res.writableEnded) {
          finishedEarly = true;
          errorResponse(res, 504, 'gateway timeout');
        }
      });
    }

    if (['POST', 'PUT', 'PATCH'].includes(method)) {
      const body = JSON.stringify(req.body);
      headers['Content-Type'] = 'application/json';
      headers['Content-Length'] = Buffer.byteLength(body);

      const proxyReq = http.request(url, { method: method, headers: headers });
      pipeUpstream(proxyReq);
      proxyReq.on('error', function() { /* surface handled in pipeUpstream */ });
      proxyReq.write(body);
      proxyReq.end();
    } else {
      const proxyReq = http.get(url, { headers: headers });
      pipeUpstream(proxyReq);
      proxyReq.on('error', function() { /* surface handled in pipeUpstream */ });
    }
  }

  // ── GET /openclaw/status ────────────────────────────────────────────────

  router.get('/status', asyncHandler(async function(req, res) {
    const container = await findOpenClawContainer();

    if (!container) {
      return ok(res, { deployed: false });
    }

    const token = await getGatewayToken(container.Id);
    const port = await getContainerPort(container.Id);
    const baseUrl = 'http://localhost:' + port;
    const health = await gatewayHealth(baseUrl, token);

    ok(res, {
      deployed: true,
      container: {
        id: container.Id.slice(0, 12),
        name: container.Name,
        state: container.State,
        status: container.Status,
        created: container.Created,
        image: container.Image
      },
      gateway: {
        url: baseUrl,
        port: port,
        healthy: health.ok,
        healthData: health.data || null,
        tokenSet: !!token
      }
    });
  }));

  // ── POST /openclaw/deploy ───────────────────────────────────────────────

  router.post('/deploy', asyncHandler(async function(req, res) {
    const existing = await findOpenClawContainer();
    if (existing) {
      return conflict(res, 'OpenClaw is already deployed');
    }

    const image = 'ghcr.io/nousresearch/openclaw:latest';
    const name = 'openclaw-' + Date.now();
    const gatewayToken = generateToken();

    // Pull image
    log.info('Pulling ' + image + '...');
    try {
      await new Promise(function(resolve, reject) {
        docker.client.pull(image, function(err, stream) {
          if (err) return reject(err);
          docker.client.modem.followProgress(stream, function(err2) {
            if (err2) return reject(err2);
            resolve();
          });
        });
      });
    } catch(e) {
      log.error('OpenClaw pull failed: ' + e.message);
      return errorResponse(res, 500, 'Failed to pull image: ' + e.message);
    }

    // Create + start container
    try {
      const container = await docker.client.createContainer({
        name: name,
        Image: image,
        Env: [
          'OPENCLAW_GATEWAY_MODE=local',
          'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
        ],
        HostConfig: {
          PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
          RestartPolicy: { Name: 'unless-stopped' },
          Labels: {
            'dashcaddy.managed': 'true',
            'dashcaddy.app': 'openclaw'
          }
        },
        ExposedPorts: { '18792/tcp': {} }
      });

      await container.start();
      log.info('OpenClaw deployed: ' + container.id.slice(0, 12));

      ok(res, {
        deployed: true,
        container: { id: container.id.slice(0, 12), name: name },
        gateway: {
          url: 'http://localhost:18792',
          token: gatewayToken
        }
      });
    } catch(e) {
      log.error('OpenClaw deploy failed: ' + e.message);
      errorResponse(res, 500, 'Deploy failed: ' + e.message);
    }
  }));

  // ── GET /openclaw/proxy/* ───────────────────────────────────────────────

  router.get('/proxy/*', asyncHandler(async function(req, res) {
    const container = await findOpenClawContainer();
    if (!container) return notFound(res, 'OpenClaw not deployed');

    const token = await getGatewayToken(container.Id);
    const port = await getContainerPort(container.Id);
    const baseUrl = 'http://localhost:' + port;
    const path = req.params[0];

    proxyRequest(req, res, baseUrl, path, token);
  }));

  // ── POST /openclaw/proxy/* ──────────────────────────────────────────────

  router.post('/proxy/*', asyncHandler(async function(req, res) {
    const container = await findOpenClawContainer();
    if (!container) return notFound(res, 'OpenClaw not deployed');

    const token = await getGatewayToken(container.Id);
    const port = await getContainerPort(container.Id);
    const baseUrl = 'http://localhost:' + port;
    const path = req.params[0];

    proxyRequest(req, res, baseUrl, path, token);
  }));

  // ── DELETE /openclaw ───────────────────────────────────────────────────

  router.delete('/', asyncHandler(async function(req, res) {
    const container = await findOpenClawContainer();
    if (!container) return notFound(res, 'OpenClaw not deployed');

    try {
      const c = docker.client.container(container.Id);
      await c.stop().catch(function() {});
      await c.remove({ force: true });
      log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
      ok(res, { message: 'OpenClaw removed' });
    } catch(e) {
      log.error('Failed to remove OpenClaw: ' + e.message);
      errorResponse(res, 500, e.message);
    }
  }));

  return router;
};

// ── token generator ──────────────────────────────────────────────────────────

function generateToken() {
  return crypto.randomBytes(24).toString('base64url');
}
