// Copyright (c) 2026 defconxt. All rights reserved.
// Licensed under AGPL-3.0 — see LICENSE file for details.
// NERF is a trademark of defconxt.

/**
 * template-renderer.js — Loads report templates and fills them with engagement data.
 *
 * Provides:
 *   renderReport(templateName, engagementData) — load template, fill placeholders, return markdown
 *   parseEngagement(engagementMdPath) — parse ENGAGEMENT.md sections into structured data
 *   listTemplates() — list available template names
 *
 * Templates use Handlebars-style {{placeholders}} for simple values and
 * {{#block}}...{{/block}} / {{^block}}...{{/block}} for conditional/iteration sections.
 *
 * @module export/template-renderer
 */

import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const TEMPLATES_DIR = join(__dirname, 'report-templates');

// ---------------------------------------------------------------------------
// Template loading
// ---------------------------------------------------------------------------

/**
 * List available report template names (without .md extension).
 * @returns {string[]}
 */
export function listTemplates() {
  if (!existsSync(TEMPLATES_DIR)) return [];
  return readdirSync(TEMPLATES_DIR)
    .filter(f => f.endsWith('.md'))
    .map(f => f.replace(/\.md$/, ''));
}

/**
 * Load a template file by name.
 * @param {string} name — template name without extension (e.g. 'pentest')
 * @returns {string} — raw template content
 * @throws {Error} if template not found
 */
function loadTemplate(name) {
  const filePath = join(TEMPLATES_DIR, `${name}.md`);
  if (!existsSync(filePath)) {
    const available = listTemplates();
    throw new Error(
      `Template "${name}" not found. Available: ${available.join(', ') || '(none)'}`
    );
  }
  return readFileSync(filePath, 'utf-8');
}

// ---------------------------------------------------------------------------
// ENGAGEMENT.md parser
// ---------------------------------------------------------------------------

/**
 * Parse an ENGAGEMENT.md file into structured data.
 *
 * Extracts top-level ## sections and converts them into key-value pairs.
 * List items (lines starting with - ) are parsed into arrays.
 * Finding blocks ([FINDING-NNN]) are parsed into structured objects.
 *
 * @param {string} engagementMdPath — path to ENGAGEMENT.md
 * @returns {Record<string, any>} — structured engagement data
 */
export function parseEngagement(engagementMdPath) {
  if (!existsSync(engagementMdPath)) {
    throw new Error(`Engagement file not found: ${engagementMdPath}`);
  }

  const content = readFileSync(engagementMdPath, 'utf-8');
  return parseEngagementContent(content);
}

/**
 * Parse ENGAGEMENT.md content string into structured data.
 * @param {string} content — raw markdown content
 * @returns {Record<string, any>}
 */
export function parseEngagementContent(content) {
  const data = {};
  const lines = content.split('\n');

  // Extract title from H1
  const h1Match = content.match(/^#\s+(.+)$/m);
  if (h1Match) {
    data.engagement_name = h1Match[1].replace(/^Engagement\s+\S+\s*/, '').trim() || h1Match[1].trim();
  }

  // Split into sections by ## headings
  const sections = splitSections(lines);

  for (const [heading, body] of Object.entries(sections)) {
    const key = sectionToKey(heading);
    const trimmed = body.trim();

    if (isPlaceholder(trimmed)) {
      data[key] = '';
      continue;
    }

    // Check if it's a list
    const listItems = parseList(trimmed);
    if (listItems.length > 0 && isListContent(trimmed)) {
      data[key] = listItems;
    } else {
      data[key] = trimmed;
    }
  }

  // Parse findings from the content
  data.findings = parseFindings(content);

  // Derive computed fields
  deriveFields(data);

  return data;
}

/**
 * Split markdown lines into sections keyed by ## heading text.
 * @param {string[]} lines
 * @returns {Record<string, string>}
 */
function splitSections(lines) {
  /** @type {Record<string, string>} */
  const sections = {};
  let currentHeading = '';
  let currentBody = [];

  for (const line of lines) {
    const h2Match = line.match(/^##\s+(.+)$/);
    if (h2Match) {
      if (currentHeading) {
        sections[currentHeading] = currentBody.join('\n');
      }
      currentHeading = h2Match[1].trim();
      currentBody = [];
    } else if (currentHeading) {
      currentBody.push(line);
    }
  }

  if (currentHeading) {
    sections[currentHeading] = currentBody.join('\n');
  }

  return sections;
}

/**
 * Convert a section heading to a snake_case key.
 * @param {string} heading
 * @returns {string}
 */
function sectionToKey(heading) {
  return heading
    .toLowerCase()
    .replace(/[^a-z0-9\s]/g, '')
    .replace(/\s+/g, '_')
    .trim();
}

/**
 * Check if a section body is a placeholder (empty or "_(none)_" style).
 * @param {string} text
 * @returns {boolean}
 */
function isPlaceholder(text) {
  if (!text) return true;
  return /^_\(.*\)_$/.test(text.trim());
}

/**
 * Check if content is primarily a list.
 * @param {string} text
 * @returns {boolean}
 */
function isListContent(text) {
  const lines = text.split('\n').filter(l => l.trim());
  const listLines = lines.filter(l => /^\s*[-*\d+.]\s/.test(l));
  return listLines.length > 0 && listLines.length >= lines.length * 0.5;
}

/**
 * Parse list items from markdown text.
 * @param {string} text
 * @returns {string[]}
 */
function parseList(text) {
  return text
    .split('\n')
    .filter(l => /^\s*[-*]\s/.test(l) || /^\s*\d+\.\s/.test(l))
    .map(l => l.replace(/^\s*[-*]\s+/, '').replace(/^\s*\d+\.\s+/, '').trim())
    .filter(Boolean);
}

/**
 * Parse [FINDING-NNN] blocks from engagement content.
 * @param {string} content
 * @returns {Array<Record<string, string>>}
 */
function parseFindings(content) {
  const findings = [];
  // Match ### [FINDING-NNN] or [FINDING-NNN] at start of line
  const findingRegex = /^###?\s*\[FINDING-(\d+)\]\s*(.*)$/gm;
  let match;

  while ((match = findingRegex.exec(content)) !== null) {
    const id = `FINDING-${match[1]}`;
    const title = match[2].trim();
    const startIdx = match.index + match[0].length;

    // Find end: next finding or next ## heading
    const rest = content.slice(startIdx);
    const endMatch = rest.match(/^###?\s*\[FINDING-|^##\s+/m);
    const block = endMatch ? rest.slice(0, endMatch.index) : rest;

    const finding = { id, title };

    // Parse table fields
    const tableFieldRegex = /\|\s*\*\*(\w+)\*\*\s*\|\s*(.+?)\s*\|/g;
    let fm;
    while ((fm = tableFieldRegex.exec(block)) !== null) {
      const key = fm[1].toLowerCase();
      finding[key] = fm[2].trim();
    }

    // Parse labeled paragraphs
    const labelRegex = /\*\*(Description|Proof|Impact|Remediation|Proof of Concept|Evidence|Observation|Recommendation):\*\*\s*([\s\S]*?)(?=\*\*\w+:\*\*|$)/g;
    let lm;
    while ((lm = labelRegex.exec(block)) !== null) {
      const key = lm[1].toLowerCase().replace(/\s+/g, '_');
      const val = lm[2].trim();
      if (key === 'proof_of_concept') {
        finding.proof = val;
      } else if (key === 'observation') {
        finding.description = val;
      } else if (key === 'recommendation') {
        finding.remediation = val;
      } else {
        finding[key] = val;
      }
    }

    findings.push(finding);
  }

  return findings;
}

/**
 * Derive computed fields from parsed data.
 * @param {Record<string, any>} data
 */
function deriveFields(data) {
  const now = new Date().toISOString().slice(0, 10);
  data.end_date = data.end_date || now;
  data.start_date = data.start_date || data.end_date;
  data.engagement_type = data.type || data.engagement_type || 'Security Assessment';
  data.status = data.status || data.phase || 'Complete';

  // Severity counts from findings
  const findings = data.findings || [];
  const severities = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };

  for (const f of findings) {
    const sev = (f.severity || '').toLowerCase();
    if (sev.includes('critical')) severities.critical++;
    else if (sev.includes('high')) severities.high++;
    else if (sev.includes('medium')) severities.medium++;
    else if (sev.includes('low')) severities.low++;
    else severities.info++;
  }

  data.critical_count = String(severities.critical);
  data.high_count = String(severities.high);
  data.medium_count = String(severities.medium);
  data.low_count = String(severities.low);
  data.info_count = String(severities.info);

  // Map section keys to template-friendly aliases
  if (data.active_hypotheses && !data.hypotheses) data.hypotheses = data.active_hypotheses;
  if (data.resolved_findings && !data.executive_summary) {
    data.executive_summary = typeof data.resolved_findings === 'string'
      ? data.resolved_findings
      : 'See findings below.';
  }
  if (!data.executive_summary) {
    data.executive_summary = data.mission || 'A security engagement was conducted. See findings for details.';
  }

  // Ensure common fields have fallback text
  const fallbacks = {
    targets: 'Not specified',
    constraints: 'Not specified',
    mission: 'Not specified',
    timeline: 'Not recorded',
    coverage: 'Not recorded',
    open_questions: 'None',
    detection_opportunities: 'Not recorded',
    tools_used: 'Not recorded',
    next_actions: 'None',
  };

  for (const [key, fallback] of Object.entries(fallbacks)) {
    if (!data[key] || (Array.isArray(data[key]) && data[key].length === 0)) {
      // Convert arrays to bullet lists for display
      data[key] = fallback;
    } else if (Array.isArray(data[key])) {
      data[key] = data[key].map(item => `- ${item}`).join('\n');
    }
  }
}

// ---------------------------------------------------------------------------
// Template rendering
// ---------------------------------------------------------------------------

/**
 * Render a report by loading a template and filling it with engagement data.
 *
 * Supports:
 *   {{key}} — simple value substitution
 *   {{#key}}...{{/key}} — repeat block for arrays, show block if truthy
 *   {{^key}}...{{/key}} — inverse block (show if falsy/empty)
 *   {{.}} — current item in array iteration
 *   {{property}} — property of current object in array iteration
 *
 * @param {string} templateName — template name without extension
 * @param {Record<string, any>} engagementData — structured engagement data
 * @returns {string} — rendered markdown report
 */
export function renderReport(templateName, engagementData) {
  const template = loadTemplate(templateName);
  return renderTemplate(template, engagementData);
}

/**
 * Render a template string with the given data context.
 * @param {string} template
 * @param {Record<string, any>} data
 * @returns {string}
 */
function renderTemplate(template, data) {
  let result = template;

  // Process block helpers: {{#key}}...{{/key}} and {{^key}}...{{/key}}
  result = processBlocks(result, data);

  // Process simple placeholders: {{key}}
  result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => {
    const value = data[key];
    if (value === undefined || value === null) return 'Not recorded';
    if (Array.isArray(value)) return value.map(v => `- ${v}`).join('\n');
    return String(value);
  });

  return result;
}

/**
 * Process {{#key}}...{{/key}} and {{^key}}...{{/key}} blocks.
 * @param {string} template
 * @param {Record<string, any>} data
 * @returns {string}
 */
function processBlocks(template, data) {
  let result = template;

  // Process positive blocks: {{#key}}...{{/key}}
  // Use a regex that handles multiline content (non-greedy)
  const blockRegex = /\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g;
  result = result.replace(blockRegex, (match, key, body) => {
    const value = data[key];

    if (!value || (Array.isArray(value) && value.length === 0)) {
      return '';
    }

    if (Array.isArray(value)) {
      return value.map(item => {
        if (typeof item === 'object' && item !== null) {
          // Replace {{property}} references within the block
          return body.replace(/\{\{(\w+)\}\}/g, (m, prop) => {
            return item[prop] !== undefined ? String(item[prop]) : '';
          });
        }
        // Scalar array items: replace {{.}}
        return body.replace(/\{\{\.\}\}/g, String(item));
      }).join('');
    }

    // Truthy scalar: render the block once with parent data
    return body;
  });

  // Process inverse blocks: {{^key}}...{{/key}}
  const inverseRegex = /\{\{\^(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g;
  result = result.replace(inverseRegex, (match, key, body) => {
    const value = data[key];
    if (!value || (Array.isArray(value) && value.length === 0)) {
      return body;
    }
    return '';
  });

  return result;
}
