// v1.12 Track B.2: shared HTTP client for the codecontext sidecar. The 8
// per-tool wrappers under tools/codecontext/ all funnel through callCodecontext
// — they're thin adapters that supply toolName + args + projectPath. The
// client owns:
//
//   1. target_dir validation. Codecontext's HTTP shim is naive and forwards
//      any target_dir to codecontext, so without this layer a model that
//      hallucinated a target_dir could read /opt/anything-on-disk. The
//      project root is realpath'd and the requested target_dir is constrained
//      to it (same invariant as path_guard.ts but for the codecontext path).
//   2. Inline truncation at 32 kB. Codecontext outputs are markdown reports
//      that can balloon on large projects; the model can re-narrow via
//      file_path / file_type / limit. Matches the "inline truncation, no
//      opaque-id retrieval" decision locked in the 2026-05-21 recon.
//   3. Friendly mapping of codecontext's known failure modes — the empty-
//      file parser bug (upstream issue #37) returns a generic error string,
//      which we re-surface with a hint to add the file to .codecontextignore.

import { access, copyFile, realpath } from 'node:fs/promises';
import { isAbsolute, join, resolve, sep } from 'node:path';
import { truncateIfNeeded } from './truncate.js';

// v1.13.12 fix: codecontext crashes on empty source files (upstream issue #37)
// when it can't ignore them. The .codecontextignore.template ships with the
// project at /opt/boocode/codecontext/.codecontextignore.template (path inside
// the container; the host's /opt is bind-mounted). On the first call to any
// project, copy the template in if no per-project ignore exists yet. The user
// can subsequently edit the file to customize. Idempotent — once any file is
// at the project root we never overwrite.
const IGNORE_TEMPLATE_PATH = '/opt/boocode/codecontext/.codecontextignore.template';
const ensuredIgnoreProjects = new Set<string>();

async function ensureIgnoreFile(projectRoot: string): Promise<void> {
  if (ensuredIgnoreProjects.has(projectRoot)) return;
  const ignorePath = join(projectRoot, '.codecontextignore');
  try {
    await access(ignorePath);
    ensuredIgnoreProjects.add(projectRoot);
    return;
  } catch {
    // missing — install the default
  }
  try {
    await copyFile(IGNORE_TEMPLATE_PATH, ignorePath);
    ensuredIgnoreProjects.add(projectRoot);
  } catch {
    // Template missing or project root read-only — proceed without it. The
    // codecontext call may still crash on empty source files; the model gets
    // the existing hint-message via the catch below telling it to add to
    // .codecontextignore manually.
  }
}

// v1.13.18: resolve a `file_path` arg to an absolute path anchored within
// the (already realpath'd) projectRoot. Contract:
//   - empty/whitespace-only → INVALID_FILE_PATH error
//   - relative path → resolve(projectRoot, rawPath) (normalises dot-segments)
//   - absolute path → resolve(rawPath) (also normalises — e.g. /root/../etc
//     becomes /etc so the prefix-check below rejects it even in the ENOENT
//     fallthrough where realpath couldn't canonicalise)
//   - try realpath; on ENOENT fall through with the (normalised) absolute
//     (the sidecar issues its own "File not found in graph" that the model
//     can self-correct on; re-implementing the check here would diverge)
//   - if the final path doesn't sit inside projectRoot → escape error
//     (same shape as target_dir escape, only the field name differs)
async function resolveProjectPath(
  projectRoot: string,
  rawPath: string,
): Promise<string> {
  if (rawPath.trim() === '') {
    throw new Error('INVALID_FILE_PATH: file_path must not be empty');
  }
  const candidate = isAbsolute(rawPath) ? resolve(rawPath) : resolve(projectRoot, rawPath);
  let resolved: string;
  try {
    resolved = await realpath(candidate);
  } catch (err: unknown) {
    if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
      // File doesn't exist yet (or was deleted). Forward the absolute path;
      // codecontext will return "File not found in graph" which the model
      // can self-correct on.
      resolved = candidate;
    } else {
      throw err;
    }
  }
  if (resolved !== projectRoot && !resolved.startsWith(projectRoot + sep)) {
    throw new Error(`file_path ${rawPath} escapes project root ${projectRoot}`);
  }
  return resolved;
}

export interface CodecontextRequest {
  toolName: string;
  args: Record<string, unknown>;
  projectPath: string;
}

export interface CodecontextResponse {
  result: string;
  truncated: boolean;
  // v1.13.5: optional opaque id pointing at the full pre-slice content on
  // tmpfs. Set when truncated=true and storage succeeded.
  outputPath?: string;
}

const CODECONTEXT_BASE_URL = process.env['CODECONTEXT_URL'] ?? 'http://codecontext:8080';
const TRUNCATION_LIMIT = 32_000;
const REQUEST_TIMEOUT_MS = 30_000;

export async function callCodecontext(
  req: CodecontextRequest,
  fetcher: typeof fetch = fetch,
): Promise<CodecontextResponse> {
  // Step 1: realpath the project root, then realpath the requested target_dir
  // (defaulting to projectPath when the caller didn't pass one — the 12 wrappers
  // never pass target_dir; tests can override). A non-existent target_dir
  // throws before we hit the network so the model gets a sharp error.
  const resolvedProject = await realpath(req.projectPath);
  // v1.13.12 fix: install the default .codecontextignore on first call to any
  // project so codecontext doesn't crash on empty node_modules files. One file
  // written per project, idempotent (set-membership check inside).
  await ensureIgnoreFile(resolvedProject);
  const requestedTarget = req.args['target_dir'];
  const targetDir = typeof requestedTarget === 'string' && requestedTarget.length > 0
    ? requestedTarget
    : req.projectPath;
  const resolvedTarget = await realpath(targetDir).catch(() => null);
  if (resolvedTarget === null) {
    throw new Error(`target_dir does not exist: ${targetDir}`);
  }
  if (resolvedTarget !== resolvedProject && !resolvedTarget.startsWith(resolvedProject + '/')) {
    throw new Error(`target_dir ${targetDir} escapes project root ${resolvedProject}`);
  }

  // Step 2: re-build args with the resolved target_dir so codecontext sees
  // the real absolute path, not a symlink or relative form.
  // v1.13.18: also resolve file_path when present — the sidecar index is keyed
  // on absolute paths, so a relative path from the model yields "File not found
  // in graph". Same escape check as target_dir; ENOENT falls through so the
  // sidecar produces the canonical "File not found in graph" the model can fix.
  const argsToSend: Record<string, unknown> = { ...req.args, target_dir: resolvedTarget };
  if (typeof req.args['file_path'] === 'string' && req.args['file_path'].trim() !== '') {
    argsToSend['file_path'] = await resolveProjectPath(resolvedProject, req.args['file_path']);
  }

  // Step 3: POST with a hard timeout. AbortController + setTimeout pattern
  // matches web_fetch.ts; nothing fancier needed.
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  let response: Response;
  try {
    response = await fetcher(`${CODECONTEXT_BASE_URL}/v1/${req.toolName}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(argsToSend),
      signal: controller.signal,
    });
  } catch (err) {
    clearTimeout(timer);
    if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) {
      throw new Error(`codecontext request timed out after ${REQUEST_TIMEOUT_MS}ms`);
    }
    throw new Error(
      `codecontext network error: ${err instanceof Error ? err.message : String(err)}`,
    );
  }
  clearTimeout(timer);

  if (!response.ok) {
    const text = await response.text().catch(() => '');
    throw new Error(`codecontext HTTP ${response.status}: ${text.slice(0, 200)}`);
  }

  const body = (await response.json()) as { result: string | null; error: string | null };
  if (body.error) {
    // Upstream issue #37: empty source files crash codecontext's parser. The
    // error message reliably contains "content is empty"; surface an
    // actionable hint instead of the bare codecontext message.
    if (body.error.includes('content is empty')) {
      throw new Error(
        `codecontext parse failure: ${body.error}. ` +
          `Add the offending path to .codecontextignore in the project root and retry.`,
      );
    }
    throw new Error(`codecontext error: ${body.error}`);
  }
  if (body.result === null) {
    return { result: '', truncated: false };
  }

  // Step 4: inline truncation. The model gets a clear hint about how to
  // narrow the next call rather than a silent cut. Mirrors web_fetch.ts.
  // v1.13.5: stash the full body on tmpfs when truncating so the model can
  // retrieve more via view_truncated_output(id).
  if (body.result.length > TRUNCATION_LIMIT) {
    const truncated = body.result.slice(0, TRUNCATION_LIMIT);
    const omitted = body.result.length - TRUNCATION_LIMIT;
    const slicedWithMarker =
      `${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`;
    const wrapped = await truncateIfNeeded({
      fullContent: body.result,
      slicedContent: slicedWithMarker,
      wasTruncated: true,
    });
    return {
      result: wrapped.content,
      truncated: wrapped.truncated,
      ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
    };
  }
  return { result: body.result, truncated: false };
}
