/**
 * Ollama vision service: given an image and a list of sat_questions (with ai_fill),
 * calls the local Ollama model (e.g. llava) and returns suggested sat_answers as
 * [{ question_id, answer_value }].
 */

const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || 'http://127.0.0.1:11434';
const OLLAMA_VISION_MODEL = process.env.OLLAMA_VISION_MODEL || 'llava';
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_FILL_TIMEOUT_MS) || 180000;

/**
 * Strip data URL prefix if present, return raw base64 string for Ollama.
 * @param {string} imageBase64 - Either "data:image/png;base64,xxxx" or raw base64
 * @returns {string} Raw base64
 */
function toRawBase64(imageBase64) {
  if (!imageBase64 || typeof imageBase64 !== 'string') return '';
  const match = imageBase64.match(/^data:image\/[^;]+;base64,(.+)$/);
  return match ? match[1].trim() : imageBase64.trim();
}

/**
 * Build the prompt so the model returns a JSON array with one object per question.
 * @param {Array<{question_id: number, question: string, question_type_id: number, options?: string[]}>} sat_questions
 * @returns {string}
 */
function buildPrompt(sat_questions) {
  const ids = sat_questions.map((q) => q.question_id).join(', ');
  const lines = [
    'You are an assistant that fills audit observation form fields from a single image.',
    'Look at the image and answer EVERY question listed below. You must return one answer per question.',
    'Reply with ONLY a JSON array, no other text, no markdown, no explanation.',
    'Format: [{"question_id": 2, "answer_value": "your answer"}, {"question_id": 5, "answer_value": "your answer"}]',
    `Use these exact question_id numbers: ${ids}. Use short, factual strings for answer_value.`,
    'If you cannot determine an answer for a question, use "" for that answer_value.',
    '',
    'Questions to answer:'
  ];

  sat_questions.forEach((q) => {
    const opts = Array.isArray(q.options) && q.options.length > 0
      ? ` (choose one of: ${q.options.join(', ')})`
      : '';
    lines.push(`- question_id ${q.question_id}: ${(q.question || '').trim()}${opts}`);
  });

  lines.push('', 'Reply with only the JSON array:');
  return lines.join('\n');
}

/**
 * Find the first complete JSON object {...} or array [...] in a string (brace-matched).
 */
function extractJsonBlock(str) {
  const startObj = str.indexOf('{');
  const startArr = str.indexOf('[');
  let start = -1;
  let openChar;
  let closeChar;
  if (startObj >= 0 && (startArr < 0 || startObj < startArr)) {
    start = startObj;
    openChar = '{';
    closeChar = '}';
  } else if (startArr >= 0) {
    start = startArr;
    openChar = '[';
    closeChar = ']';
  }
  if (start < 0) return null;
  let depth = 1;
  let i = start + 1;
  while (i < str.length && depth > 0) {
    const c = str[i];
    if (c === openChar) depth++;
    else if (c === closeChar) depth--;
    i++;
  }
  return depth === 0 ? str.slice(start, i) : null;
}

/**
 * Parse the model reply to extract JSON. Handles markdown code block, surrounding text, and trailing commas.
 * @param {string} content
 * @returns {object|array|null}
 */
function parseJsonReply(content) {
  if (!content || typeof content !== 'string') return null;
  let raw = content.trim();
  // Normalize LLM output: escaped underscores (e.g. question\_id) are invalid in JSON; fix before parse
  raw = raw.replace(/\\_/g, '_');
  const candidates = [];
  const codeMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (codeMatch) candidates.push(codeMatch[1].trim());
  candidates.push(raw);
  for (const text of candidates) {
    const block = extractJsonBlock(text) || text;
    const fixed = block.replace(/,\s*([}\]])/g, '$1');
    try {
      const obj = JSON.parse(fixed);
      if (obj && typeof obj === 'object') return obj;
    } catch (_) {
      /* try next candidate */
    }
  }
  return null;
}

/**
 * Extract question_id from object key. Handles: "5", "question_id_2", "question_2", "question_5", etc.
 */
function keyToQuestionId(key) {
  if (key == null) return NaN;
  const n = Number(key);
  if (!Number.isNaN(n)) return n;
  const str = String(key);
  const match = str.match(/question[_]?id[_\s]*(\d+)/i) || str.match(/question[_\s](\d+)/i);
  return match ? Number(match[1]) : NaN;
}

/**
 * Convert parsed LLM output to [{ question_id, answer_value }].
 * Handles: single object {question_id, answer_value}, array of same, object keyed by question {"5":"val"} or {"question_2":"val"}.
 */
function parsedToAnswers(parsed, questionIds) {
  const idSet = new Set(questionIds);
  const results = [];

  if (Array.isArray(parsed)) {
    for (const item of parsed) {
      if (item == null || typeof item !== 'object') continue;
      const qId = Number(item.question_id ?? item.questionId ?? item.id);
      if (!idSet.has(qId)) continue;
      const val = item.answer_value ?? item.answer ?? item.value ?? '';
      results.push({ question_id: qId, answer_value: val != null ? String(val).trim() : '' });
    }
    return results;
  }

  if (parsed != null && typeof parsed === 'object') {
    const qId = Number(parsed.question_id ?? parsed.questionId);
    if (!Number.isNaN(qId) && idSet.has(qId) && ('answer_value' in parsed || 'answer' in parsed)) {
      const val = parsed.answer_value ?? parsed.answer ?? '';
      results.push({ question_id: qId, answer_value: val != null ? String(val).trim() : '' });
    }
  }

  if (results.length > 0) return results;

  for (const [key, value] of Object.entries(parsed)) {
    const qId = keyToQuestionId(key);
    if (Number.isNaN(qId) || !idSet.has(qId)) continue;
    results.push({
      question_id: qId,
      answer_value: value != null ? String(value).trim() : ''
    });
  }
  return results;
}

/**
 * Call Ollama /api/chat with the image and prompt; return suggested sat_answers.
 * @param {string} imageBase64 - Data URL or raw base64
 * @param {Array<{question_id: number, question: string, question_type_id?: number, options?: string[]}>} sat_questions
 * @returns {Promise<Array<{question_id: number, answer_value: string}>>}
 */
async function getAnswersFromImage(imageBase64, sat_questions) {
  const rawBase64 = toRawBase64(imageBase64);
  if (!rawBase64) {
    throw new Error('No image data provided');
  }
  if (!sat_questions || sat_questions.length === 0) {
    return [];
  }

  const prompt = buildPrompt(sat_questions);
  const url = `${OLLAMA_BASE_URL.replace(/\/$/, '')}/api/chat`;
  const body = {
    model: OLLAMA_VISION_MODEL,
    stream: false,
    messages: [
      {
        role: 'user',
        content: prompt,
        images: [rawBase64]
      }
    ]
  };

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);

  try {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      signal: controller.signal
    });
    clearTimeout(timeoutId);

    if (!res.ok) {
      const errText = await res.text();
      throw new Error(`Ollama request failed: ${res.status} ${errText}`);
    }

    const data = await res.json();
    const content = data.message?.content;
    const parsed = parseJsonReply(content);
    if (!parsed) {
      console.error('[AI fill] Could not parse LLM reply as JSON. Raw content (first 800 chars):', content ? content.slice(0, 800) : 'empty');
      throw new Error('Could not parse AI response as JSON');
    }

    const questionIds = sat_questions.map((q) => Number(q.question_id));
    const results = parsedToAnswers(parsed, questionIds);
    if (results.length === 0) {
      console.warn('[AI fill] Parsed JSON but no matching question_ids. Parsed keys/sample:', Array.isArray(parsed) ? `array[${parsed.length}]` : Object.keys(parsed).slice(0, 5));
    } else {
      console.log('[AI fill] Returning', results.length, 'sat_answers for question_ids:', results.map((r) => r.question_id).join(', '));
    }
    return results;
  } catch (err) {
    clearTimeout(timeoutId);
    if (err.name === 'AbortError') {
      throw new Error('AI request timed out. Try again or use a smaller image.');
    }
    const isConnectionError = err.cause?.code === 'ECONNREFUSED' || err.code === 'ECONNREFUSED' || err.message === 'fetch failed';
    if (isConnectionError) {
      throw new Error(
        `Cannot reach Ollama at ${url}. Is Ollama running? Start it with "ollama serve" and ensure a vision model is installed (e.g. "ollama pull llava"). ` +
        (process.env.OLLAMA_BASE_URL ? `Configured OLLAMA_BASE_URL=${process.env.OLLAMA_BASE_URL}` : 'Using default http://127.0.0.1:11434.')
      );
    }
    throw err;
  }
}

/**
 * Get sat_answers for multiple sat_questions by calling the model once per question.
 * Ensures each ai_fill question gets an answer (llava often returns only one in a single request).
 * @param {string} imageBase64
 * @param {Array} sat_questions
 * @returns {Promise<Array<{question_id: number, answer_value: string}>>}
 */
async function getAnswersFromImageOnePerQuestion(imageBase64, sat_questions) {
  if (!sat_questions || sat_questions.length === 0) return [];
  if (sat_questions.length === 1) return getAnswersFromImage(imageBase64, sat_questions);

  const results = [];
  for (const q of sat_questions) {
    const one = await getAnswersFromImage(imageBase64, [q]);
    if (one.length > 0) results.push(one[0]);
    else results.push({ question_id: Number(q.question_id), answer_value: '' });
  }
  return results;
}

module.exports = {
  getAnswersFromImage,
  getAnswersFromImageOnePerQuestion,
  toRawBase64,
  buildPrompt,
  parseJsonReply
};
