const crypto = require('crypto');
const http = require('http');
const readline = require('readline');
const { spawn } = require('child_process');

const {
  activeConnection,
  defaultMemoryApiUrl,
  defaultWorldModelApiUrl,
  normalizeBaseUrl,
  readConfig,
  upsertConnection,
  writeConfig,
} = require('./config');

const CLIENT_ID = 'cielara-connector';
const REFRESH_SKEW_MS = 5 * 60 * 1000;
const LOCAL_FRONTEND_PORTS = new Set(['3000', '3100']);
const LOCAL_ADMIN_PORT = '8100';
const LOCAL_BACKEND_PORT = '8080';
const LOCAL_MEMORY_PORT = '8001';

function base64url(buffer) {
  return Buffer.from(buffer)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

function makePKCE() {
  const verifier = base64url(crypto.randomBytes(32));
  const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge };
}

function prompt(question) {
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      rl.close();
      resolve(answer.trim());
    });
  });
}

function openBrowser(url) {
  const platform = process.platform;
  let command;
  let args;
  if (platform === 'darwin') {
    command = 'open';
    args = [url];
  } else if (platform === 'win32') {
    command = 'cmd';
    args = ['/c', 'start', '', url.replace(/&/g, '^&')];
  } else {
    command = 'xdg-open';
    args = [url];
  }
  try {
    const child = spawn(command, args, { detached: true, stdio: 'ignore' });
    // ENOENT (e.g. xdg-open missing on headless Linux) fires async via 'error',
    // not as a thrown exception — without this handler Node crashes.
    child.on('error', () => {});
    child.unref();
    return true;
  } catch (_) {
    return false;
  }
}

function startCallbackServer(expectedState, frontendUrl) {
  let settle;
  let closed = false;
  const result = new Promise((resolve, reject) => {
    settle = { resolve, reject };
  });
  const close = () => {
    if (closed) {
      return Promise.resolve();
    }
    closed = true;
    // Resolve only after the server reports closed. Defer one tick so the
    // in-flight 302 redirect drains. Don't call server.closeAllConnections()
    // — on Windows libuv asserts (!(handle->flags & UV_HANDLE_CLOSING) in
    // async.c) when forcible socket teardown races the redirect's async
    // write. Clients receive `Connection: close`, so server.close() returns
    // promptly anyway.
    return new Promise((resolveClose) => {
      setImmediate(() => {
        try {
          server.close(() => resolveClose());
        } catch (_) {
          resolveClose();
        }
      });
    });
  };

  const server = http.createServer((req, res) => {
    const reqUrl = new URL(req.url || '/', 'http://127.0.0.1');
    if (reqUrl.pathname !== '/callback') {
      res.writeHead(404, { 'content-type': 'text/plain', connection: 'close' });
      res.end('Not found');
      return;
    }

    const state = reqUrl.searchParams.get('state') || '';
    const error = reqUrl.searchParams.get('error');
    const code = reqUrl.searchParams.get('code');
    const errorPage = `${frontendUrl}/oauth/error`;
    const successPage = `${frontendUrl}/oauth/success`;

    const redirect = (status, location) => {
      res.writeHead(status, { location, connection: 'close' });
      res.end();
    };

    if (state !== expectedState) {
      redirect(302, `${errorPage}?error=State+mismatch`);
      settle.reject(new Error('OAuth state mismatch'));
      return;
    }
    if (error) {
      redirect(302, `${errorPage}?error=${encodeURIComponent(error)}`);
      settle.reject(new Error(`OAuth authorization failed: ${error}`));
      return;
    }
    if (!code) {
      redirect(302, `${errorPage}?error=No+authorization+code+returned`);
      settle.reject(new Error('No authorization code returned'));
      return;
    }

    redirect(302, successPage);
    settle.resolve(code);
  });

  const listening = new Promise((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, '127.0.0.1', () => {
      const address = server.address();
      resolve({
        redirectUri: `http://127.0.0.1:${address.port}/callback`,
        waitForCode: result.finally(close),
        close,
      });
    });
  });

  return listening;
}

function escapeHTML(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

async function postForm(url, body) {
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      accept: 'application/json',
      'content-type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams(body),
  });
  const text = await res.text();
  let data;
  try {
    data = text ? JSON.parse(text) : {};
  } catch (_) {
    data = { error_description: text };
  }
  if (!res.ok) {
    const message = data.error_description || data.message || data.error || `HTTP ${res.status}`;
    throw new Error(message);
  }
  return data;
}

async function fetchJSON(url) {
  const res = await fetch(url, {
    headers: { accept: 'application/json' },
  });
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }
  return res.json();
}

function isLoopbackHost(hostname) {
  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
}

function localSiblingUrl(baseUrl, port) {
  const url = new URL(baseUrl);
  if (!isLoopbackHost(url.hostname)) {
    return '';
  }
  url.port = port;
  url.pathname = '';
  url.search = '';
  url.hash = '';
  return url.toString().replace(/\/$/, '');
}

function localSiblingUrls(baseUrl, port) {
  const url = new URL(baseUrl);
  if (!isLoopbackHost(url.hostname)) {
    return [];
  }
  const hosts = url.hostname === 'localhost' ? ['localhost', '127.0.0.1'] : [url.hostname];
  return hosts.map((host) => {
    const next = new URL(baseUrl);
    next.hostname = host;
    next.port = port;
    next.pathname = '';
    next.search = '';
    next.hash = '';
    return next.toString().replace(/\/$/, '');
  });
}

function isLocalFrontendUrl(baseUrl) {
  const url = new URL(baseUrl);
  return isLoopbackHost(url.hostname) && LOCAL_FRONTEND_PORTS.has(url.port);
}

function defaultBackendUrl(baseUrl) {
  return isLocalFrontendUrl(baseUrl) ? localSiblingUrls(baseUrl, LOCAL_BACKEND_PORT)[0] : baseUrl;
}

function defaultLocalMemoryUrl(baseUrl) {
  if (!isLocalFrontendUrl(baseUrl)) {
    return '';
  }
  return localSiblingUrls(baseUrl, LOCAL_MEMORY_PORT)[0];
}

/**
 * Agent Memory URL: direct localhost:8001 for local frontend login, otherwise backend `/api/agent-memory`.
 * Override with options.memoryUrl or CIELARA_MEMORY_API_URL (empty string falls back to defaults).
 */
function resolveMemoryUrl(options, baseUrl, backendUrl) {
  if (options.memoryUrl !== undefined) {
    return options.memoryUrl
      ? normalizeBaseUrl(options.memoryUrl)
      : defaultLocalMemoryUrl(baseUrl) || defaultMemoryApiUrl(backendUrl);
  }
  const envMem = process.env.CIELARA_MEMORY_API_URL;
  if (envMem !== undefined) {
    const trimmed = String(envMem).trim();
    return trimmed === ''
      ? defaultLocalMemoryUrl(baseUrl) || defaultMemoryApiUrl(backendUrl)
      : normalizeBaseUrl(trimmed);
  }
  return defaultLocalMemoryUrl(baseUrl) || defaultMemoryApiUrl(backendUrl);
}

/**
 * World model URL: insights backend `/api/world-model` reverse proxy (local + deployed).
 * Override with options.worldModelUrl or CIELARA_WORLD_MODEL_URL (empty string disables the tool).
 */
function resolveWorldModelUrl(options, backendUrl) {
  if (options.worldModelUrl !== undefined) {
    return options.worldModelUrl ? normalizeBaseUrl(options.worldModelUrl) : '';
  }
  const envWm = process.env.CIELARA_WORLD_MODEL_URL;
  if (envWm !== undefined) {
    const trimmed = String(envWm).trim();
    return trimmed === '' ? '' : normalizeBaseUrl(trimmed);
  }
  return defaultWorldModelApiUrl(backendUrl);
}

function metadataEndpoint(baseUrl) {
  return `${baseUrl.replace(/\/$/, '')}/api/iam/.well-known/oauth-authorization-server`;
}

async function resolveOAuthEndpoints(baseUrl, explicitAuthUrl) {
  const candidates = [];
  if (explicitAuthUrl) {
    candidates.push(normalizeBaseUrl(explicitAuthUrl));
  }
  candidates.push(baseUrl);
  if (isLoopbackHost(new URL(baseUrl).hostname)) {
    candidates.push(...localSiblingUrls(baseUrl, LOCAL_ADMIN_PORT));
  }

  const unique = [...new Set(candidates)];
  const failures = [];
  for (const candidate of unique) {
    try {
      const metadata = await fetchJSON(metadataEndpoint(candidate));
      if (metadata.authorization_endpoint && metadata.token_endpoint) {
        return {
          authBaseUrl: candidate,
          authorizationEndpoint: metadata.authorization_endpoint,
          tokenEndpoint: metadata.token_endpoint,
        };
      }
      failures.push(`${candidate}: missing OAuth endpoints`);
    } catch (error) {
      failures.push(`${candidate}: ${error.message}`);
    }
  }

  throw new Error(
    `Could not find Cielara OAuth endpoints. Tried ${unique.join(', ')}. ${failures.join('; ')}`
  );
}

function tokenEndpoint(connectionOrBaseUrl) {
  if (typeof connectionOrBaseUrl === 'object') {
    const base = connectionOrBaseUrl.authBaseUrl || connectionOrBaseUrl.baseUrl || connectionOrBaseUrl.backendUrl;
    return `${base.replace(/\/$/, '')}/api/iam/oauth/token`;
  }
  return `${connectionOrBaseUrl.replace(/\/$/, '')}/api/iam/oauth/token`;
}

function authorizeEndpoint(baseUrl) {
  return `${baseUrl.replace(/\/$/, '')}/api/iam/oauth/authorize`;
}

async function login(options = {}) {
  const name = options.name || 'default';
  const rawUrl = options.url || process.env.CIELARA_URL || await prompt('Cielara URL: ');
  const baseUrl = normalizeBaseUrl(rawUrl);
  const backendUrl = options.backendUrl ? normalizeBaseUrl(options.backendUrl) : defaultBackendUrl(baseUrl);
  const memoryApiUrl = resolveMemoryUrl(options, baseUrl, backendUrl);
  const worldModelUrl = resolveWorldModelUrl(options, backendUrl);
  const oauth = await resolveOAuthEndpoints(baseUrl, options.authUrl || process.env.CIELARA_AUTH_URL);

  const state = base64url(crypto.randomBytes(24));
  const { verifier, challenge } = makePKCE();
  const callback = await startCallbackServer(state, baseUrl);
  const authUrl = new URL(oauth.authorizationEndpoint || authorizeEndpoint(oauth.authBaseUrl));
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', callback.redirectUri);
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', challenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');
  authUrl.searchParams.set('scope', 'mcp');

  process.stderr.write(`Opening browser for Cielara login:\n${authUrl.toString()}\n\n`);
  if (!openBrowser(authUrl.toString())) {
    process.stderr.write('Could not open a browser automatically. Open the URL above.\n');
  }

  let timeoutHandle;
  const timeout = new Promise((_, reject) => {
    timeoutHandle = setTimeout(() => reject(new Error('Timed out waiting for browser login')), 5 * 60 * 1000);
  });
  let code;
  try {
    code = await Promise.race([callback.waitForCode, timeout]);
  } finally {
    // Clear the 5-minute timer so it doesn't keep the event loop alive (and
    // doesn't race the server-close on Windows, where libuv asserts when
    // active handles get torn down at process exit).
    clearTimeout(timeoutHandle);
    await callback.close();
  }
  const token = await postForm(oauth.tokenEndpoint, {
    grant_type: 'authorization_code',
    client_id: CLIENT_ID,
    redirect_uri: callback.redirectUri,
    code,
    code_verifier: verifier,
  });

  const config = readConfig();
  const next = upsertConnection(config, name, {
    baseUrl,
    authBaseUrl: oauth.authBaseUrl,
    backendUrl,
    memoryApiUrl,
    worldModelUrl,
    accessToken: token.access_token,
    refreshToken: token.refresh_token,
    tokenType: token.token_type || 'Bearer',
    expiresAt: token.expires_at,
    scope: token.scope || 'mcp',
    updatedAt: new Date().toISOString(),
  });
  writeConfig(next);
  return { name, connection: next.connections[name] };
}

function needsRefresh(connection) {
  if (!connection.accessToken || !connection.expiresAt) {
    return true;
  }
  const expires = Date.parse(connection.expiresAt);
  if (Number.isNaN(expires)) {
    return true;
  }
  return expires - Date.now() < REFRESH_SKEW_MS;
}

async function refreshConnection(name, connection, options = {}) {
  if (!options.force && !needsRefresh(connection)) {
    return { connection, refreshed: false };
  }
  if (!connection.refreshToken) {
    throw new Error('No refresh token stored. Run `cielara login` again.');
  }
  const token = await postForm(tokenEndpoint(connection), {
    grant_type: 'refresh_token',
    client_id: CLIENT_ID,
    refresh_token: connection.refreshToken,
  });
  const next = {
    ...connection,
    accessToken: token.access_token,
    refreshToken: token.refresh_token || connection.refreshToken,
    tokenType: token.token_type || 'Bearer',
    expiresAt: token.expires_at,
    updatedAt: new Date().toISOString(),
  };
  return { connection: next, refreshed: true };
}

async function ensureFreshConnection(options = {}) {
  const config = readConfig();
  const { name, connection } = activeConnection(config);
  const result = await refreshConnection(name, connection, options);
  if (result.refreshed) {
    const nextConfig = upsertConnection(config, name, result.connection);
    writeConfig(nextConfig);
  }
  return { name, connection: result.connection };
}

async function postJSON(url, body) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { accept: 'application/json', 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  const text = await res.text();
  let data;
  try {
    data = text ? JSON.parse(text) : {};
  } catch (_) {
    data = { message: text };
  }
  if (!res.ok) {
    const message = data.message || data.error || `HTTP ${res.status}`;
    throw new Error(message);
  }
  return data;
}

async function loginWithToken(options = {}) {
  const name = options.name || 'default';
  const rawUrl = options.url || process.env.CIELARA_URL || await prompt('Cielara URL: ');
  const baseUrl = normalizeBaseUrl(rawUrl);
  const backendUrl = options.backendUrl ? normalizeBaseUrl(options.backendUrl) : defaultBackendUrl(baseUrl);
  const memoryApiUrl = resolveMemoryUrl(options, baseUrl, backendUrl);
  const worldModelUrl = resolveWorldModelUrl(options, backendUrl);

  const exchangeUrl = `${baseUrl.replace(/\/$/, '')}/api/iam/install-token/exchange`;
  const result = await postJSON(exchangeUrl, { token: options.token });
  const tokenData = result.data || result;

  const config = readConfig();
  const next = upsertConnection(config, name, {
    baseUrl,
    backendUrl,
    memoryApiUrl,
    worldModelUrl,
    accessToken: tokenData.access_token,
    refreshToken: tokenData.refresh_token,
    tokenType: tokenData.token_type || 'Bearer',
    expiresAt: tokenData.expires_at,
    scope: 'mcp',
    updatedAt: new Date().toISOString(),
  });
  writeConfig(next);
  return { name, connection: next.connections[name] };
}

function logout(name) {
  const config = readConfig();
  const key = name || config.active || 'default';
  if (config.connections) {
    delete config.connections[key];
    if (config.active === key) {
      config.active = Object.keys(config.connections)[0] || 'default';
    }
  }
  writeConfig(config);
}

module.exports = {
  CLIENT_ID,
  defaultBackendUrl,
  ensureFreshConnection,
  login,
  loginWithToken,
  logout,
  needsRefresh,
  refreshConnection,
  resolveOAuthEndpoints,
};
