const { WebSocketServer } = require('ws');
const Docker = require('dockerode');
const url = require('url');

const docker = new Docker();

/**
 * DC-072: WebSocket scope authorization — admin-only by default.
 *
 * Container exec is full root-equivalent access inside the target
 * container. Granting it to a key whose scope is `['read']` violates
 * least privilege. The validScopes list (`['read','write','admin']`)
 * is defined in routes/auth/keys.js; exec requires `admin`.
 *
 * Defensive: the scope field is coerced via `Array.isArray(...) ? ... : []`
 * so a malformed payload (string, object, null, undefined) cannot reach
 * `.includes('admin')` and accidentally grant access. Every malformed
 * shape falls into the rejection branch with the same 403 envelope.
 *
 * Tests should call `__test.assertExecScope(auth)` directly rather
 * than spinning up a WebSocket server.
 */
function assertExecScope(auth) {
  const scope = Array.isArray(auth && auth.scope) ? auth.scope : [];
  if (!scope.includes('admin')) {
    const err = new Error('Container exec requires admin scope');
    err.code = 'DC-072_INSUFFICIENT_SCOPE';
    err.statusCode = 403;
    err.requiredScope = 'admin';
    err.actualScope = scope;
    throw err;
  }
}

/**
 * DC-072: Tighten containerId validation.
 *
 * Docker container IDs are exactly 64 lowercase hex chars (or 12-char
 * short form). The pre-fix regex accepted `_`, `-`, `.`, mixed case,
 * and up to 128 chars — Docker would then 404 the inspect call and
 * the rejection would surface as a generic 500 in the WS error
 * envelope. Pre-validate at the upgrade layer so the rejection is
 * fast and the log line discriminates "malformed" from "unknown".
 */
function isValidContainerId(id) {
  if (typeof id !== 'string') return false;
  // Full 64-char hex, or 12-char short hex
  return /^[0-9a-f]{64}$/.test(id) || /^[0-9a-f]{12}$/.test(id);
}

/**
 * Attach WebSocket server for container exec/shell
 * Route: ws://host/ws/exec/:containerId
 * @param {http.Server} server - The HTTP server instance
 * @param {Object} log - Logger
 */
module.exports = function attachExecWS(server, log, authManager) {
  const wss = new WebSocketServer({ noServer: true });

  // Authenticate WebSocket upgrade request before accepting it
  server.on('upgrade', async (req, socket, head) => {
    const parsed = url.parse(req.url, true);
    const match = parsed.pathname.match(/^\/ws\/exec\/([a-zA-Z0-9_.-]+)$/);
    if (!match) return; // Not our route — let other handlers deal with it

    const containerId = decodeURIComponent(match[1]);

    // DC-072: Tighten containerId charset (64-char / 12-char lowercase hex)
    if (!isValidContainerId(containerId)) {
      log.warn('exec', 'Invalid container ID in WebSocket path', { containerId });
      socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
      socket.destroy();
      return;
    }

    // Check auth — require valid JWT or API key from the upgrade request
    let auth = null;
    const authHeader = req.headers.authorization;
    if (authHeader && authHeader.startsWith('Bearer ')) {
      const token = authHeader.substring(7);
      const payload = await authManager.verifyJWT(token);
      if (payload) {
        auth = { type: 'jwt', userId: payload.userId, scope: payload.scope || [] };
      }
    } else {
      const apiKey = req.headers['x-api-key'];
      if (apiKey) {
        const keyData = await authManager.verifyAPIKey(apiKey);
        if (keyData) {
          auth = { type: 'apikey', keyId: keyData.keyId, scope: keyData.scopes || [] };
        }
      }
    }

    if (!auth) {
      log.warn('exec', 'Unauthenticated WebSocket exec attempt', { containerId, ip: req.socket.remoteAddress });
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
      socket.destroy();
      return;
    }

    // DC-072: Container exec is root-equivalent — require admin scope.
    // Pre-fix, a key issued with scope `['read']` (e.g., for monitoring)
    // would get a full PTY shell inside any running container. The
    // `auth.scope` was captured at lines 39/46 but never checked.
    try {
      assertExecScope(auth);
    } catch (err) {
      log.warn('exec', 'Insufficient scope for exec attempt', {
        containerId,
        authType: auth.type,
        authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
        actualScope: err.actualScope,
        requiredScope: err.requiredScope,
        ip: req.socket.remoteAddress,
      });
      // 403 with a JSON error envelope over the upgrade socket so the
      // dashboard can display "admin required" instead of guessing.
      socket.write('HTTP/1.1 403 Forbidden\r\n');
      socket.write('Content-Type: application/json\r\n');
      socket.write('\r\n');
      socket.end(JSON.stringify({
        error: err.message,
        code: err.code,
        requiredScope: err.requiredScope,
        actualScope: err.actualScope,
      }));
      return;
    }

    // Auth passed — proceed with WebSocket upgrade
    wss.handleUpgrade(req, socket, head, (ws) => {
      handleExec(ws, containerId, log, auth);
    });
  });

  return wss;
};

async function handleExec(ws, containerId, log, auth) {
  let execStream = null;
  let execInstance = null;
  const sessionStart = Date.now();

  try {
    const container = docker.getContainer(containerId);
    // Verify container exists and is running
    const info = await container.inspect();
    if (!info.State.Running) {
      ws.send(JSON.stringify({ type: 'error', message: 'Container is not running' }));
      ws.close();
      return;
    }

    // DC-072: Audit-log the exec session start. Pairs with the end-log
    // below so the operator can correlate who opened which shell.
    log.info('exec', 'Authenticated exec session started', {
      containerId,
      authType: auth.type,
      authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
      containerName: info.Name,
    });

    // Detect available shell
    let shell = '/bin/sh';
    try {
      const bashCheck = await container.exec({ Cmd: ['which', 'bash'], AttachStdout: true });
      const bashStream = await bashCheck.start();
      const chunks = [];
      await new Promise((resolve) => {
        bashStream.on('data', (chunk) => chunks.push(chunk));
        bashStream.on('end', resolve);
      });
      if (chunks.length > 0 && Buffer.concat(chunks).toString().includes('/bash')) {
        shell = '/bin/bash';
      }
    } catch (_) {
      // Fall back to /bin/sh when bash detection fails
    }

    execInstance = await container.exec({
      Cmd: [shell],
      AttachStdin: true,
      AttachStdout: true,
      AttachStderr: true,
      Tty: true,
    });

    execStream = await execInstance.start({ hijack: true, stdin: true, Tty: true });

    ws.send(JSON.stringify({ type: 'connected', shell, containerId }));

    // Docker → WebSocket
    execStream.on('data', (chunk) => {
      if (ws.readyState === ws.OPEN) {
        ws.send(chunk);
      }
    });

    // DC-072: Track whether the end-log has fired so we don't double-log
    // when both execStream 'end' and ws 'close' fire (Docker stream end
    // closes the WS, which then fires 'close' too — without the flag
    // we'd emit the same audit line twice with the same durationMs).
    let ended = false;
    const logSessionEnd = (reason) => {
      if (ended) return;
      ended = true;
      log.info('exec', 'Exec session ended', {
        containerId,
        authType: auth.type,
        authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
        durationMs: Date.now() - sessionStart,
        reason,
      });
    };

    execStream.on('end', () => {
      // DC-072: Audit-log the session end (duration + container) so a
      // long-running session is observable in the error log. Normal
      // shutdown path: Docker exec stream closes → log + tell client.
      logSessionEnd('exec-stream-end');
      if (ws.readyState === ws.OPEN) {
        ws.send(JSON.stringify({ type: 'exit' }));
        ws.close();
      }
    });

    // WebSocket → Docker
    ws.on('message', (data) => {
      if (!execStream.writable) return;
      try {
        // Check for control messages (JSON)
        const str = data.toString();
        if (str.startsWith('{"type":')) {
          const msg = JSON.parse(str);
          if (msg.type === 'resize' && execInstance && msg.cols && msg.rows) {
            execInstance.resize({ h: msg.rows, w: msg.cols }).catch(() => {});
            return;
          }
        }
      } catch (_) {
        // Treat message as raw terminal input if JSON parsing fails
      }
      // Regular terminal input
      execStream.write(data);
    });

    ws.on('close', () => {
      // DC-072: Fallback audit-log for abnormal close (browser tab
      // closed, network drop, container killed mid-session) where the
      // execStream 'end' event never fires. The ended-flag guard makes
      // this idempotent with the normal path above.
      logSessionEnd('ws-close');
      if (execStream) {
        try { execStream.destroy(); } catch (_) {
          // Ignore stream teardown errors on socket close
        }
      }
    });

    ws.on('error', (err) => {
      log.warn('exec', 'WebSocket error', { containerId, error: err.message });
      if (execStream) {
        try { execStream.destroy(); } catch (_) {
          // Ignore stream teardown errors after websocket errors
        }
      }
    });

  } catch (err) {
    log.error('exec', err, null, { note: 'Failed to start exec session', containerId });
    if (ws.readyState === ws.OPEN) {
      ws.send(JSON.stringify({ type: 'error', message: err.message }));
      ws.close();
    }
  }
}

// Internal-only export for unit tests. Stripped from the public
// surface; tests import this via the destructure form
// `const { __test } = require('./routes/exec')`.
module.exports.__test = {
  assertExecScope,
  isValidContainerId,
};
