// wall-e/tools/question-manager.js
// Interactive question system for coding agent sessions.
// Inspired by OpenCode's Question service
// (https://github.com/anomalyco/opencode, packages/opencode/src/question/index.ts)
//
// Allows the LLM to ask the user clarifying questions mid-task and wait
// for answers before continuing. Questions are delivered via the coding
// events bus and answered through the REST API.
'use strict';

const crypto = require('node:crypto');

const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const SAFE_HTML_PREVIEW_TAGS = new Set([
  'a', 'article', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'footer',
  'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'img', 'li',
  'ol', 'p', 'pre', 'section', 'span', 'strong', 'table', 'tbody', 'td',
  'th', 'thead', 'tr', 'ul',
]);
const SAFE_HTML_PREVIEW_ATTRS = new Set([
  'alt', 'aria-label', 'aria-describedby', 'aria-labelledby', 'class', 'colspan',
  'height', 'href', 'role', 'rowspan', 'src', 'title', 'width',
]);

// Shared pending map — all QuestionManager instances share the same questions
// so the API registry and session instances can find the same pending questions.
const _sharedPending = new Map();

/**
 * Manages pending questions during coding sessions.
 * Each question gets a unique ID, is emitted on the events bus, and
 * resolves when the user answers via API (or times out).
 * All instances share a module-level pending map so the API can answer
 * questions from any coding session.
 */
class QuestionManager {
  /**
   * @param {import('./coding-events').CodingEvents} events - coding events bus
   */
  constructor(events) {
    this._events = events;
    this._pending = _sharedPending;
  }

  /**
   * Ask the user a question and wait for a response.
   *
   * Inspired by OpenCode Question.ask() — creates a deferred promise,
   * publishes 'question.asked' on the bus, and waits for reply or timeout.
   *
   * @param {string} sessionId - coding session ID
   * @param {object} question
   * @param {string} question.question - the question text
   * @param {string} [question.header] - short label (max 30 chars)
   * @param {Array<{label:string, description?:string}>} [question.options] - available choices
   * @param {boolean} [question.multiple] - allow multiple selections
   * @param {object} [askOpts]
   * @param {number} [askOpts.timeoutMs] - override default 5min timeout
   * @returns {Promise<string[]|null>} selected option labels, or null on timeout/reject
   */
  ask(sessionId, question, askOpts = {}) {
    const id = `q_${crypto.randomUUID().slice(0, 8)}`;
    const timeoutMs = askOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
    const normalizedQuestion = normalizeQuestion(question || {});

    return new Promise((resolve) => {
      const timer = timeoutMs > 0
        ? setTimeout(() => this._expire(id), timeoutMs)
        : null;

      const entry = { sessionId, question: normalizedQuestion, resolve, timer };
      this._pending.set(id, entry);

      // Publish on bus so UI / SSE subscribers can show the question
      if (this._events) {
        this._events.emit('question.asked', {
          id,
          sessionId,
          question: normalizedQuestion.question,
          header: normalizedQuestion.header || '',
          options: normalizedQuestion.options || [],
          multiple: !!normalizedQuestion.multiple,
          preview: normalizedQuestion.preview || null,
          timestamp: Date.now(),
        });
      }
    });
  }

  /**
   * Submit an answer to a pending question.
   * Inspired by OpenCode Question.reply().
   *
   * @param {string} questionId
   * @param {string[]} answers - selected option labels (or free-text)
   * @returns {boolean} true if question was found and answered
   */
  answer(questionId, answers) {
    const entry = this._pending.get(questionId);
    if (!entry) return false;

    if (entry.timer) clearTimeout(entry.timer);
    this._pending.delete(questionId);

    if (this._events) {
      this._events.emit('question.replied', {
        id: questionId,
        sessionId: entry.sessionId,
        answers,
        timestamp: Date.now(),
      });
    }

    entry.resolve(answers);
    return true;
  }

  /**
   * Reject / dismiss a pending question.
   * Inspired by OpenCode Question.reject().
   *
   * @param {string} questionId
   * @returns {boolean} true if question was found and rejected
   */
  reject(questionId) {
    const entry = this._pending.get(questionId);
    if (!entry) return false;

    if (entry.timer) clearTimeout(entry.timer);
    this._pending.delete(questionId);

    if (this._events) {
      this._events.emit('question.rejected', {
        id: questionId,
        sessionId: entry.sessionId,
        timestamp: Date.now(),
      });
    }

    entry.resolve(null);
    return true;
  }

  /**
   * List all pending questions, optionally filtered by session.
   * Inspired by OpenCode Question.list().
   *
   * @param {string} [sessionId] - optional filter
   * @returns {Array<{id:string, sessionId:string, question:object}>}
   */
  list(sessionId) {
    const result = [];
    for (const [id, entry] of this._pending) {
      if (sessionId && entry.sessionId !== sessionId) continue;
      result.push({ id, sessionId: entry.sessionId, question: entry.question });
    }
    return result;
  }

  /** @returns {number} */
  get pendingCount() { return this._pending.size; }

  /**
   * Expire a question after timeout — resolves with null.
   * @param {string} questionId
   */
  _expire(questionId) {
    const entry = this._pending.get(questionId);
    if (!entry) return;
    this._pending.delete(questionId);

    if (this._events) {
      this._events.emit('question.rejected', {
        id: questionId,
        sessionId: entry.sessionId,
        reason: 'timeout',
        timestamp: Date.now(),
      });
    }

    entry.resolve(null);
  }

  /**
   * Clear all pending questions (e.g. on session teardown).
   * All pending promises resolve with null.
   */
  clear() {
    for (const [id] of this._pending) {
      this._expire(id);
    }
  }
}

/**
 * Tool definition for CODING_TOOLS — allows the LLM to ask the user a question.
 * Integrate by pushing this into CODING_TOOLS and wiring the executor in runAgentLoop.
 */
const ASK_USER_TOOL = {
  name: 'ask_user',
  description: 'Ask the user a clarifying question and wait for their answer. Supports safe markdown previews and sanitized HTML-fragment previews for visual choices. Use when you need more information before proceeding.',
  input_schema: {
    type: 'object',
    properties: {
      question: { type: 'string', description: 'The question to ask' },
      header: { type: 'string', description: 'Short label (max 30 chars)' },
      preview: {
        type: 'object',
        description: 'Optional question-level preview. Use type=markdown for prose, type=html for a sanitized fragment, or type=image/pdf_page with a local path.',
        properties: {
          type: { type: 'string', enum: ['markdown', 'html', 'image', 'pdf_page'] },
          content: { type: 'string', description: 'Markdown or safe HTML fragment.' },
          path: { type: 'string', description: 'Optional local artifact path for image/pdf_page previews.' },
          title: { type: 'string', description: 'Optional preview title.' },
        },
      },
      options: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            label: { type: 'string', description: 'Display text (1-5 words)' },
            description: { type: 'string', description: 'Explanation of this choice' },
            preview: {
              type: 'object',
              description: 'Optional preview for this option. Same shape as question preview.',
              properties: {
                type: { type: 'string', enum: ['markdown', 'html', 'image', 'pdf_page'] },
                content: { type: 'string' },
                path: { type: 'string' },
                title: { type: 'string' },
              },
            },
          },
          required: ['label'],
        },
        description: 'Available choices (optional — omit for free-text)',
      },
      multiple: { type: 'boolean', description: 'Allow selecting multiple choices (default false)' },
    },
    required: ['question'],
  },
};

function normalizeQuestion(question) {
  return {
    question: String(question.question || '').slice(0, 4000),
    header: String(question.header || '').slice(0, 30),
    multiple: !!question.multiple,
    preview: sanitizePreview(question.preview),
    options: Array.isArray(question.options)
      ? question.options.slice(0, 10).map((option) => ({
        label: String(option?.label || '').slice(0, 80),
        description: String(option?.description || '').slice(0, 1000),
        preview: sanitizePreview(option?.preview),
      })).filter((option) => option.label)
      : [],
  };
}

function sanitizePreview(preview) {
  if (!preview) return null;
  if (typeof preview === 'string') {
    return { type: 'markdown', content: preview.slice(0, 12000) };
  }
  if (typeof preview !== 'object') return null;
  const type = String(preview.type || preview.kind || 'markdown').toLowerCase();
  const title = String(preview.title || '').slice(0, 160);
  if (type === 'markdown') {
    return {
      type: 'markdown',
      title,
      content: String(preview.content || '').slice(0, 12000),
    };
  }
  if (type === 'html') {
    const content = String(preview.content || '').slice(0, 12000);
    const error = unsafeHtmlFragmentReason(content);
    if (error) return { type: 'rejected', title, error };
    return { type: 'html', title, content };
  }
  if (type === 'image' || type === 'pdf_page') {
    return {
      type,
      title,
      path: String(preview.path || '').slice(0, 2000),
      mimeType: String(preview.mimeType || preview.mime_type || '').slice(0, 120),
      content: String(preview.content || '').slice(0, 2000),
    };
  }
  return { type: 'rejected', title, error: `Unsupported preview type: ${type}` };
}

function unsafeHtmlFragmentReason(html) {
  if (/<\s*(?:script|style|iframe|object|embed|link|meta|html|head|body)\b/i.test(html)) {
    return 'HTML preview contains a blocked tag.';
  }
  if (/\son[a-z]+\s*=/i.test(html)) return 'HTML preview contains an event handler attribute.';
  if (/\bjavascript\s*:/i.test(html)) return 'HTML preview contains a javascript: URL.';
  const tagRe = /<\s*\/?\s*([a-z][a-z0-9-]*)([^>]*)>/gi;
  for (const match of html.matchAll(tagRe)) {
    const tag = match[1].toLowerCase();
    const attrs = match[2] || '';
    if (!SAFE_HTML_PREVIEW_TAGS.has(tag)) return `HTML preview contains unsupported tag: ${tag}.`;
    if (/<|>/.test(attrs)) return 'HTML preview contains malformed tag attributes.';
    const attrRe = /\s([:@a-zA-Z_][:@a-zA-Z0-9_.-]*)\s*(?:=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
    for (const attrMatch of attrs.matchAll(attrRe)) {
      const attr = attrMatch[1].toLowerCase();
      if (attr.startsWith('data-') || attr.startsWith('aria-')) continue;
      if (!SAFE_HTML_PREVIEW_ATTRS.has(attr)) return `HTML preview contains unsupported attribute: ${attr}.`;
    }
  }
  return '';
}

module.exports = {
  QuestionManager,
  ASK_USER_TOOL,
  DEFAULT_TIMEOUT_MS,
  normalizeQuestion,
  sanitizePreview,
  unsafeHtmlFragmentReason,
};
