// lib/engine-author.js
//
// authorJson — engine-abstracted structured-output helper.
//
// The mines pipeline has exactly two genuine LLM steps (fact extraction and
// number-free prose authoring). Both need a validated JSON object back. This
// helper lets EITHER engine satisfy that contract so `engine` is a pure
// cost/scale tier (personal = Claude Code CLI subscription, production =
// Anthropic API key), never a capability gate:
//
//   - anthropic  → Messages API forced-tool call (tool_choice emit). The API
//                  guarantees the tool input matches the schema; we re-validate
//                  defensively.
//   - claude-code / ollama / codex → free-text completion instructed to return
//                  ONLY a ```json fence. We parse, validate against the schema,
//                  and re-prompt up to MAX_CLI_RETRIES times on malformed or
//                  schema-invalid output. On exhaustion we THROW — never return a
//                  half-authored object (no silent degrade). This bounded-retry +
//                  strict-validation loop is the reliability control for the
//                  personal tier where output is not a forced tool call.
//
// Returns a validated object, or throws. `engine` keeps codex/ollama extensible.
//
// Scope note: the CLI path calls runClaudeCodeAgent with NO extra --add-dir and
// the default cwd — deliberately NARROWER than the agent tick path. This helper
// does no file work; it only asks the model for JSON. The `.claude/settings.json`
// deny-list remains the boundary; nothing here widens bypassPermissions scope.

import {
  normalizeAgentEngine,
  defaultModelForEngine,
  runClaudeCodeAgent,
  runOllamaAgent,
  runCodexAgent,
} from './agent-engines.js';
import { chatWithClaude } from './claude.js';
import { validateShape, normalizeSchema } from './schema-validate.js';

const MAX_CLI_RETRIES = 2; // total attempts = MAX_CLI_RETRIES + 1

/**
 * @param {object} opts
 * @param {string} [opts.engine]        Agent engine id (normalized). Defaults to claude-code.
 * @param {string} [opts.model]         Model override; defaults to the engine default.
 * @param {object} opts.schema          Property map or full object schema (see schema-validate).
 * @param {string} opts.system          System instruction.
 * @param {string} opts.user            User message.
 * @param {string} [opts.label]         Human label for errors/logging.
 * @param {number} [opts.temperature=0.3]
 * @param {number} [opts.max_tokens=2048]
 * @param {object} [deps]               Test seams: { chat, runClaudeCode, runOllama, runCodex }.
 * @returns {Promise<object>}           Validated object. Throws on failure.
 */
export async function authorJson(
  { engine, model, schema, system, user, label = 'structured output', temperature = 0.3, max_tokens = 2048 } = {},
  deps = {},
) {
  const norm = normalizeSchema(schema);
  const eng = normalizeAgentEngine(engine);
  const resolvedModel = model || defaultModelForEngine(eng) || undefined;

  if (eng === 'anthropic') {
    const chat = deps.chat || chatWithClaude;
    return authorViaApi({ model: resolvedModel, norm, system, user, temperature, max_tokens, label }, chat);
  }

  return authorViaCli(
    { engine: eng, model: resolvedModel, norm, system, user, max_tokens, label },
    {
      runClaudeCode: deps.runClaudeCode || runClaudeCodeAgent,
      runOllama: deps.runOllama || runOllamaAgent,
      runCodex: deps.runCodex || runCodexAgent,
    },
  );
}

// ── API path: forced-tool emit ──────────────────────────────────────────────
async function authorViaApi({ model, norm, system, user, temperature, max_tokens, label }, chat) {
  const tool = {
    name: 'emit',
    description: `Emit the structured ${label}. Call exactly once with every required field populated.`,
    input_schema: norm,
  };
  const res = await chat(
    [{ role: 'user', content: user }],
    system,
    [tool],
    { model, max_tokens, temperature, tool_choice: { type: 'tool', name: 'emit' } },
  );
  const tu = (res?.content || []).find((b) => b?.type === 'tool_use' && b.name === 'emit');
  const out = tu?.input;
  if (!out || typeof out !== 'object') {
    throw new Error(`authorJson(${label}): API returned no emit tool_use`);
  }
  const { ok, errors } = validateShape(out, norm);
  if (!ok) {
    throw new Error(`authorJson(${label}): API output failed schema validation: ${errors.join('; ')}`);
  }
  return out;
}

// ── CLI / local-engine path: fenced JSON + bounded corrective retries ────────
async function authorViaCli({ engine, model, norm, system, user, max_tokens, label }, runners) {
  const systemPrompt = [
    system,
    '',
    'OUTPUT FORMAT — STRICT:',
    'Respond with ONLY a single JSON object inside a ```json fenced code block. No prose before or after the fence.',
    'The object must match this schema exactly — every required key present, with the stated type:',
    schemaHint(norm),
  ].join('\n');

  let lastErr = '';
  for (let attempt = 0; attempt <= MAX_CLI_RETRIES; attempt += 1) {
    const userMessage = attempt === 0
      ? user
      : `${user}\n\nYOUR PREVIOUS RESPONSE WAS REJECTED: ${lastErr}\nReturn ONLY corrected JSON in a \`\`\`json fence that matches the schema. No other text.`;

    let raw;
    try {
      raw = await runEngineText({ engine, model, systemPrompt, userMessage, max_tokens }, runners);
    } catch (e) {
      lastErr = `engine error: ${e?.message || String(e)}`;
      continue;
    }

    const parsed = extractJson(raw);
    if (!parsed) {
      lastErr = 'no parseable JSON object found in the response';
      continue;
    }
    const { ok, errors } = validateShape(parsed, norm);
    if (!ok) {
      lastErr = `schema validation failed: ${errors.join('; ')}`;
      continue;
    }
    return parsed;
  }

  throw new Error(
    `authorJson(${label}): ${engine} failed to produce valid JSON after ${MAX_CLI_RETRIES + 1} attempts. Last error: ${lastErr}`,
  );
}

async function runEngineText({ engine, model, systemPrompt, userMessage, max_tokens }, runners) {
  if (engine === 'ollama') return runners.runOllama({ model, systemPrompt, userMessage, maxTokens: max_tokens });
  if (engine === 'codex') return runners.runCodex({ systemPrompt, userMessage });
  // claude-code and any other local engine → the Claude Code subprocess.
  // No addDirs, default cwd: this helper only authors JSON, never touches files.
  return runners.runClaudeCode({ model, systemPrompt, userMessage });
}

// Compact, human-readable schema description for the CLI prompt.
function schemaHint(norm) {
  const lines = Object.entries(norm.properties || {}).map(([key, sub]) => {
    let t;
    if (sub?.type === 'array') t = sub.items?.type === 'string' ? 'array of strings' : 'array';
    else t = sub?.type || 'string';
    const req = (norm.required || []).includes(key) ? ' (required)' : '';
    const desc = sub?.description ? ` — ${sub.description}` : '';
    return `  "${key}": <${t}>${req}${desc}`;
  });
  return `{\n${lines.join('\n')}\n}`;
}

// Pull a JSON object out of a free-text response. Prefers a ```json fence, then
// any ``` fence, then the outermost {...} span. Returns the parsed value or null.
function extractJson(text) {
  const s = String(text || '');
  if (!s.trim()) return null;

  const fenced =
    s.match(/```json\s*([\s\S]*?)```/i) ||
    s.match(/```\s*([\s\S]*?)```/);
  let candidate = fenced ? fenced[1] : null;

  if (!candidate) {
    const start = s.indexOf('{');
    const end = s.lastIndexOf('}');
    if (start >= 0 && end > start) candidate = s.slice(start, end + 1);
  }
  if (!candidate) return null;

  try {
    const parsed = JSON.parse(candidate.trim());
    return parsed && typeof parsed === 'object' ? parsed : null;
  } catch {
    return null;
  }
}
