const { chromium } = require('playwright');
const path = require('fs') && require('path');
const fs   = require('fs');
const db   = require('./db');

const MAX_PAGES     = 30;
const SCREENSHOTS_DIR = path.join(__dirname, '..', 'screenshots');
if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });

// ── Common misspellings & placeholder text ────────────────────────────────────
const PLACEHOLDER_PATTERNS = [
  /lorem\s+ipsum/i, /dolor\s+sit\s+amet/i, /consectetur\s+adipiscing/i,
  /your\s+text\s+here/i, /placeholder\s+text/i, /coming\s+soon/i,
  /under\s+construction/i, /insert\s+text/i, /sample\s+text/i,
  /dummy\s+text/i, /test\s+content/i, /\[your\s+name\]/i,
  /\[company\s+name\]/i, /\[phone\]/i, /\[email\]/i, /\[address\]/i,
  /click\s+here\s+to\s+edit/i, /edit\s+this\s+text/i
];

const COMMON_MISSPELLINGS = {
  'recieve': 'receive', 'occured': 'occurred', 'seperate': 'separate',
  'definately': 'definitely', 'accomodate': 'accommodate', 'aquire': 'acquire',
  'begining': 'beginning', 'beleive': 'believe', 'calender': 'calendar',
  'catagory': 'category', 'collegue': 'colleague', 'comming': 'coming',
  'commitee': 'committee', 'completly': 'completely', 'concious': 'conscious',
  'dependant': 'dependent', 'disatisfied': 'dissatisfied', 'embarass': 'embarrass',
  'enviroment': 'environment', 'existance': 'existence', 'experiance': 'experience',
  'familar': 'familiar', 'finaly': 'finally', 'foriegn': 'foreign',
  'freind': 'friend', 'goverment': 'government', 'grammer': 'grammar',
  'garantee': 'guarantee', 'happend': 'happened', 'harrassment': 'harassment',
  'hieght': 'height', 'immedietly': 'immediately', 'independant': 'independent',
  'intresting': 'interesting', 'knowlege': 'knowledge', 'languague': 'language',
  'liason': 'liaison', 'maintainance': 'maintenance', 'managment': 'management',
  'medecine': 'medicine', 'millenia': 'millennia', 'mispell': 'misspell',
  'momento': 'memento', 'neccessary': 'necessary', 'nieghbour': 'neighbour',
  'noticable': 'noticeable', 'occassion': 'occasion', 'occurence': 'occurrence',
  'oppurtunity': 'opportunity', 'organisaton': 'organisation', 'paralell': 'parallel',
  'parliment': 'parliament', 'persistant': 'persistent', 'personel': 'personnel',
  'politican': 'politician', 'posession': 'possession', 'potatos': 'potatoes',
  'practise': 'practice', 'prefered': 'preferred', 'preperation': 'preparation',
  'presense': 'presence', 'privelege': 'privilege', 'probly': 'probably',
  'proffesional': 'professional', 'promiss': 'promise', 'pronounciation': 'pronunciation',
  'publically': 'publicly', 'reccommend': 'recommend', 'refered': 'referred',
  'relevent': 'relevant', 'religous': 'religious', 'remembrance': 'remembrance',
  'repitition': 'repetition', 'resistence': 'resistance', 'rythm': 'rhythm',
  'sacrafice': 'sacrifice', 'sargent': 'sergeant', 'similer': 'similar',
  'sincerely': 'sincerely', 'sofware': 'software', 'speach': 'speech',
  'succesful': 'successful', 'suprise': 'surprise', 'temperture': 'temperature',
  'tendancy': 'tendency', 'tommorow': 'tomorrow', 'tounge': 'tongue',
  'truely': 'truly', 'unexpectadly': 'unexpectedly', 'unfortunatly': 'unfortunately',
  'untill': 'until', 'usualy': 'usually', 'vaccum': 'vacuum',
  'visable': 'visible', 'wierd': 'weird', 'wellcome': 'welcome',
  'writting': 'writing', 'yeild': 'yield'
};

// ── Main Entry ────────────────────────────────────────────────────────────────
async function runTests(runId, startUrl) {
  const browser = await chromium.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
  });

  try {
    const baseOrigin  = new URL(startUrl).origin;
    const crawledUrls = new Set();
    const urlQueue    = [normalize(startUrl)];
    const allLinks    = new Map(); // url -> Set of pages that link to it
    let   processed   = 0;

    db.updateRun(runId, { status: 'running' });

    while (urlQueue.length > 0 && processed < MAX_PAGES) {
      const url = urlQueue.shift();
      if (crawledUrls.has(url)) continue;
      crawledUrls.add(url);
      processed++;
      db.updateRun(runId, { pages_found: processed });

      const result = await testPage(browser, runId, url, baseOrigin, allLinks);
      for (const link of result.links) {
        if (!crawledUrls.has(link) && !urlQueue.includes(link)) urlQueue.push(link);
      }
    }

    // Check external links in batch
    await checkExternalLinks(runId, allLinks, baseOrigin);

    const issues   = db.getIssues(runId);
    const critical = issues.filter(i => i.severity === 'critical').length;
    const warnings = issues.filter(i => i.severity === 'warning').length;
    const info     = issues.filter(i => i.severity === 'info').length;

    db.updateRun(runId, {
      status: 'completed', completed_at: new Date().toISOString(),
      pages_tested: processed, total_issues: issues.length,
      critical_issues: critical, warning_issues: warnings, info_issues: info
    });

    const run = db.getRun(runId);
    if (run && run.email) sendReport(run, issues.length, critical, warnings);

  } catch (err) {
    db.updateRun(runId, { status: 'failed', error: err.message, completed_at: new Date().toISOString() });
    throw err;
  } finally {
    await browser.close();
  }
}

// ── Test a single page ────────────────────────────────────────────────────────
async function testPage(browser, runId, url, baseOrigin, allLinks) {
  const context = await browser.newContext({
    viewport: { width: 1280, height: 800 },
    userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 QATool/1.0'
  });

  const page          = await context.newPage();
  const consoleErrors = [];
  const jsErrors      = [];
  const failedReqs    = [];
  const links         = new Set();

  page.on('console',       msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
  page.on('pageerror',     err => jsErrors.push(err.message));
  page.on('requestfailed', req => {
    if (['image','stylesheet','script'].includes(req.resourceType()))
      failedReqs.push({ url: req.url(), type: req.resourceType() });
  });

  const slug       = Buffer.from(url).toString('base64').replace(/[^a-zA-Z0-9]/g,'').slice(0,20);
  const deskFile   = `${runId}_${slug}_d.png`;
  const mobileFile = `${runId}_${slug}_m.png`;

  try {
    const t0       = Date.now();
    const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => null);
    const loadTime = Date.now() - t0;
    const status   = response ? response.status() : 0;

    await page.screenshot({ path: path.join(SCREENSHOTS_DIR, deskFile), fullPage: true });

    // Collect all links (internal + external)
    const hrefs = await page.$$eval('a[href]', els => els.map(e => ({ href: e.href, text: e.textContent.trim() }))).catch(() => []);
    for (const { href, text } of hrefs) {
      try {
        const u = new URL(href);
        if (u.origin === baseOrigin) {
          if (!href.includes('#') && !href.match(/\.(pdf|jpg|jpeg|png|gif|zip|mp4|mp3|svg|ico)$/i))
            links.add(normalize(href));
        } else if (href.startsWith('http')) {
          // Track external links
          if (!allLinks.has(href)) allLinks.set(href, new Set());
          allLinks.get(href).add(url);
        }
      } catch {}
    }

    db.upsertPage(runId, url, status, loadTime, `/screenshots/${deskFile}`, `/screenshots/${mobileFile}`);

    // ── 1. HTTP errors ─────────────────────────────────────────────────────
    if      (status >= 500) addIssue(runId, url, 'critical', 'server-error',    `Page returned HTTP ${status} — server error`, deskFile);
    else if (status === 404) addIssue(runId, url, 'critical', 'not-found',       'Page not found — HTTP 404', deskFile);
    else if (status >= 400)  addIssue(runId, url, 'critical', 'http-error',      `Page returned HTTP ${status}`, deskFile);

    // ── 2. Load time ────────────────────────────────────────────────────────
    if      (loadTime > 6000) addIssue(runId, url, 'critical', 'very-slow-load', `Page took ${(loadTime/1000).toFixed(1)}s — critically slow (>6s)`, deskFile);
    else if (loadTime > 3000) addIssue(runId, url, 'warning',  'slow-load',      `Page took ${(loadTime/1000).toFixed(1)}s — slow (>3s)`, deskFile);

    // ── 3. Console errors ───────────────────────────────────────────────────
    for (const err of [...new Set(consoleErrors)].slice(0,5))
      addIssue(runId, url, 'warning', 'console-error', `Console error: ${err.slice(0,250)}`, deskFile);

    // ── 4. JS crashes ───────────────────────────────────────────────────────
    for (const err of [...new Set(jsErrors)].slice(0,3))
      addIssue(runId, url, 'critical', 'js-error', `JavaScript crash: ${err.slice(0,250)}`, deskFile);

    // ── 5. Failed resources ─────────────────────────────────────────────────
    for (const r of failedReqs.slice(0,5))
      addIssue(runId, url, 'warning', 'failed-resource', `Failed to load ${r.type}: ${r.url.slice(0,200)}`, deskFile);

    // ── 6. Broken images ────────────────────────────────────────────────────
    const brokenImgs = await page.evaluate(() =>
      Array.from(document.querySelectorAll('img'))
        .filter(img => !img.complete || img.naturalWidth === 0)
        .map(img => img.src).filter(s => s.startsWith('http'))
    ).catch(() => []);
    for (const src of brokenImgs.slice(0,5))
      addIssue(runId, url, 'critical', 'broken-image', `Broken image: ${src.slice(0,200)}`, deskFile);

    // ── 7. Missing title ────────────────────────────────────────────────────
    const title = await page.title().catch(() => '');
    if (!title.trim()) addIssue(runId, url, 'warning', 'missing-title', 'Page has no <title> tag', deskFile);

    // ── 8. Missing meta description ─────────────────────────────────────────
    const hasMeta = await page.$('meta[name="description"]').catch(() => null);
    if (!hasMeta) addIssue(runId, url, 'info', 'missing-meta-desc', 'Missing <meta name="description">', deskFile);

    // ── 9. Missing viewport ─────────────────────────────────────────────────
    const hasVP = await page.$('meta[name="viewport"]').catch(() => null);
    if (!hasVP) addIssue(runId, url, 'warning', 'missing-viewport', 'Missing <meta name="viewport"> — page won\'t render well on mobile', deskFile);

    // ── 10. Desktop overflow ────────────────────────────────────────────────
    const deskOverflow = await page.evaluate(() => document.body.scrollWidth > window.innerWidth).catch(() => false);
    if (deskOverflow) addIssue(runId, url, 'warning', 'desktop-overflow', 'Horizontal scrollbar on desktop — possible layout glitch', deskFile);

    // ── 11. Forms ───────────────────────────────────────────────────────────
    const formCount = await page.$$eval('form', f => f.length).catch(() => 0);
    for (let i = 0; i < formCount; i++) await checkForm(page, runId, url, i, deskFile);

    // ── 12. Functionality testing ───────────────────────────────────────────
    await checkFunctionality(page, runId, url, deskFile, browser);

    // ── 13. Spelling & placeholder check ────────────────────────────────────
    await checkSpelling(page, runId, url, deskFile);

    // ── 14. Empty sections ──────────────────────────────────────────────────
    const emptySections = await page.evaluate(() => {
      return ['section','article','main','aside'].flatMap(tag =>
        Array.from(document.querySelectorAll(tag))
          .filter(el => el.offsetHeight > 80 && el.textContent.trim().length < 5)
          .map(el => `<${tag}> .${el.className.split(' ')[0]||'no-class'}`)
      ).slice(0,3);
    }).catch(() => []);
    for (const el of emptySections)
      addIssue(runId, url, 'info', 'empty-section', `Empty visible section: ${el}`, deskFile);

    // ── 15. Mobile check ────────────────────────────────────────────────────
    await page.setViewportSize({ width: 375, height: 812 });
    await page.waitForTimeout(600);
    await page.screenshot({ path: path.join(SCREENSHOTS_DIR, mobileFile), fullPage: true });

    const mobileOverflow = await page.evaluate(() => document.body.scrollWidth > window.innerWidth).catch(() => false);
    if (mobileOverflow) addIssue(runId, url, 'warning', 'mobile-overflow', 'Horizontal scroll on mobile (375px) — layout breaks on phones', mobileFile);

    // ── 16. Mixed content ───────────────────────────────────────────────────
    if (url.startsWith('https://')) {
      const mixed = await page.evaluate(() =>
        [...document.querySelectorAll('img[src^="http:"],script[src^="http:"]')].map(el => el.src||el.getAttribute('src')).slice(0,3)
      ).catch(() => []);
      for (const src of mixed)
        addIssue(runId, url, 'warning', 'mixed-content', `Mixed content on HTTPS: ${src.slice(0,200)}`, deskFile);
    }

  } catch (err) {
    addIssue(runId, url, 'critical', 'load-failed', `Could not load page: ${err.message.slice(0,200)}`, null);
    db.upsertPage(runId, url, 0, 0, null, null);
  } finally {
    await context.close();
  }

  return { links: Array.from(links) };
}

// ── Functionality Testing ─────────────────────────────────────────────────────
async function checkFunctionality(page, runId, url, deskFile, browser) {
  try {
    // Check all buttons are clickable and visible
    const buttonIssues = await page.evaluate(() => {
      const btns = Array.from(document.querySelectorAll('button, [type="submit"], .btn, .button'));
      return btns
        .filter(btn => {
          const rect = btn.getBoundingClientRect();
          const style = window.getComputedStyle(btn);
          return rect.width === 0 || rect.height === 0 ||
                 style.display === 'none' || style.visibility === 'hidden' ||
                 style.opacity === '0';
        })
        .map(btn => btn.textContent.trim().slice(0,50) || btn.className.slice(0,50))
        .filter(t => t)
        .slice(0,3);
    }).catch(() => []);

    for (const btn of buttonIssues)
      addIssue(runId, url, 'warning', 'invisible-button', `Button is hidden or has zero size: "${btn}"`, deskFile);

    // Check for dead links (href="#" or href="javascript:void")
    const deadLinks = await page.evaluate(() =>
      Array.from(document.querySelectorAll('a[href="#"], a[href="javascript:void(0)"], a[href="javascript:;"]'))
        .map(a => a.textContent.trim().slice(0,60))
        .filter(t => t && t.length > 1)
        .slice(0,5)
    ).catch(() => []);
    for (const link of deadLinks)
      addIssue(runId, url, 'info', 'dead-link', `Dead link (href="#") found: "${link}"`, deskFile);

    // Check nav links work (not broken)
    const navLinks = await page.evaluate(() =>
      Array.from(document.querySelectorAll('nav a[href], header a[href]'))
        .map(a => a.href).filter(h => h && h.startsWith('http')).slice(0,5)
    ).catch(() => []);

    for (const navUrl of navLinks) {
      try {
        const resp = await page.evaluate(async (u) => {
          const r = await fetch(u, { method: 'HEAD', mode: 'no-cors' });
          return r.status;
        }, navUrl).catch(() => null);
      } catch {}
    }

    // Check if phone numbers are clickable
    const phoneIssues = await page.evaluate(() => {
      const text = document.body.innerText;
      const phoneRegex = /(\+?[\d\s\-\(\)]{10,15})/g;
      const phones = text.match(phoneRegex) || [];
      const clickablePhones = Array.from(document.querySelectorAll('a[href^="tel:"]')).length;
      if (phones.length > 0 && clickablePhones === 0) {
        return phones.slice(0,2).map(p => p.trim());
      }
      return [];
    }).catch(() => []);
    for (const phone of phoneIssues)
      addIssue(runId, url, 'info', 'non-clickable-phone', `Phone number not clickable (missing tel: link): ${phone}`, deskFile);

    // Check email links
    const emailIssues = await page.evaluate(() => {
      const text = document.body.innerText;
      const emailRegex = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g;
      const emails = text.match(emailRegex) || [];
      const clickableEmails = Array.from(document.querySelectorAll('a[href^="mailto:"]')).length;
      if (emails.length > 0 && clickableEmails === 0) {
        return emails.slice(0,2);
      }
      return [];
    }).catch(() => []);
    for (const email of emailIssues)
      addIssue(runId, url, 'info', 'non-clickable-email', `Email address not clickable (missing mailto: link): ${email}`, deskFile);

    // Check for broken images with alt text missing
    const missingAlt = await page.evaluate(() =>
      Array.from(document.querySelectorAll('img:not([alt])'))
        .map(img => img.src).filter(s => s).slice(0,3)
    ).catch(() => []);
    for (const src of missingAlt)
      addIssue(runId, url, 'info', 'missing-alt', `Image missing alt text: ${src.slice(0,150)}`, deskFile);

    // Check for duplicate IDs
    const dupIds = await page.evaluate(() => {
      const ids = Array.from(document.querySelectorAll('[id]')).map(el => el.id);
      const seen = new Set(), dupes = new Set();
      ids.forEach(id => { if (seen.has(id)) dupes.add(id); else seen.add(id); });
      return Array.from(dupes).slice(0,3);
    }).catch(() => []);
    for (const id of dupIds)
      addIssue(runId, url, 'warning', 'duplicate-id', `Duplicate element ID found: #${id} — breaks JavaScript and accessibility`, deskFile);

  } catch(err) {
    console.error('Functionality check error:', err.message);
  }
}

// ── Spelling & Content Checker ─────────────────────────────────────────────────
async function checkSpelling(page, runId, url, deskFile) {
  try {
    const text = await page.evaluate(() => document.body.innerText || '').catch(() => '');

    // Check for placeholder text
    for (const pattern of PLACEHOLDER_PATTERNS) {
      if (pattern.test(text)) {
        const match = text.match(pattern);
        addIssue(runId, url, 'critical', 'placeholder-text',
          `Placeholder/dummy text found on live page: "${match[0]}"`, deskFile);
      }
    }

    // Check for common misspellings
    const words = text.toLowerCase().split(/\s+/);
    const found = new Set();
    for (const word of words) {
      const clean = word.replace(/[^a-z]/g, '');
      if (COMMON_MISSPELLINGS[clean] && !found.has(clean)) {
        found.add(clean);
        addIssue(runId, url, 'warning', 'spelling-error',
          `Possible spelling error: "${clean}" (did you mean "${COMMON_MISSPELLINGS[clean]}"?)`, deskFile);
      }
      if (found.size >= 3) break; // Max 3 spelling issues per page
    }

    // Check for broken/test email addresses
    const testEmails = await page.evaluate(() => {
      const text = document.body.innerText;
      const matches = text.match(/[\w.]+@(test|example|sample|dummy|placeholder|yourdomain|domain)\.\w+/gi) || [];
      return matches.slice(0,3);
    }).catch(() => []);
    for (const email of testEmails)
      addIssue(runId, url, 'critical', 'test-email', `Test/placeholder email found on live page: ${email}`, deskFile);

    // Check for test/placeholder phone numbers
    const testPhones = await page.evaluate(() => {
      const text = document.body.innerText;
      const testPatterns = [/000-000-0000/, /123-456-7890/, /555-\d{4}/, /\+1234567890/];
      const found = [];
      testPatterns.forEach(p => { const m = text.match(p); if (m) found.push(m[0]); });
      return found.slice(0,2);
    }).catch(() => []);
    for (const phone of testPhones)
      addIssue(runId, url, 'critical', 'test-phone', `Test/placeholder phone number found on live page: ${phone}`, deskFile);

  } catch(err) {
    console.error('Spelling check error:', err.message);
  }
}

// ── External Link Checker ──────────────────────────────────────────────────────
async function checkExternalLinks(runId, allLinks, baseOrigin) {
  const entries = Array.from(allLinks.entries()).slice(0, 50); // Check max 50 external links
  for (const [linkUrl, sourcePages] of entries) {
    try {
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), 8000);
      const response = await fetch(linkUrl, {
        method: 'HEAD',
        signal: controller.signal,
        headers: { 'User-Agent': 'Mozilla/5.0 QATool LinkChecker/1.0' }
      }).catch(() => fetch(linkUrl, { method: 'GET', signal: controller.signal,
        headers: { 'User-Agent': 'Mozilla/5.0 QATool LinkChecker/1.0' }
      }));
      clearTimeout(timeout);

      const sourcePage = Array.from(sourcePages)[0] || baseOrigin;
      if (response.status === 404) {
        addIssue(runId, sourcePage, 'critical', 'broken-external-link',
          `Broken external link (404): ${linkUrl.slice(0,200)}`, null);
      } else if (response.status >= 400 && response.status !== 403 && response.status !== 401) {
        addIssue(runId, sourcePage, 'warning', 'external-link-error',
          `External link returned HTTP ${response.status}: ${linkUrl.slice(0,200)}`, null);
      }
    } catch (err) {
      if (err.name !== 'AbortError') {
        const sourcePage = Array.from(allLinks.get(linkUrl))[0] || baseOrigin;
        addIssue(runId, sourcePage, 'warning', 'external-link-unreachable',
          `External link unreachable: ${linkUrl.slice(0,200)}`, null);
      }
    }
  }
}

// ── Form checker ──────────────────────────────────────────────────────────────
async function checkForm(page, runId, url, idx, deskFile) {
  try {
    const info = await page.evaluate((i) => {
      const form = document.querySelectorAll('form')[i];
      if (!form) return null;
      const fields = Array.from(form.querySelectorAll('input,textarea,select')).map(el => ({
        type: (el.type||el.tagName).toLowerCase(),
        name: el.name||'', required: el.required,
        hasLabel: !!document.querySelector(`label[for="${el.id}"]`)||!!el.closest('label'),
        placeholder: el.placeholder||''
      }));
      return {
        fields,
        hasSubmit: !!form.querySelector('[type="submit"],button[type="submit"],button:not([type])'),
        isHttps: location.protocol === 'https:'
      };
    }, idx);

    if (!info) return;
    if (!info.hasSubmit)
      addIssue(runId, url, 'warning', 'form-no-submit', `Form ${idx+1}: no submit button found`, deskFile);

    const unlabeled = info.fields.filter(f => f.required && !f.hasLabel && !f.placeholder && f.type !== 'hidden');
    if (unlabeled.length > 0)
      addIssue(runId, url, 'warning', 'form-accessibility', `Form ${idx+1}: ${unlabeled.length} required field(s) have no label or placeholder`, deskFile);

    const hasPwd = info.fields.some(f => f.type === 'password');
    if (hasPwd && !info.isHttps)
      addIssue(runId, url, 'critical', 'insecure-form', `Form ${idx+1}: password field on non-HTTPS page — security risk`, deskFile);
  } catch {}
}

// ── Helpers ───────────────────────────────────────────────────────────────────
function addIssue(runId, url, severity, type, message, screenshotFile) {
  db.addIssue(runId, url, severity, type, message, screenshotFile ? `/screenshots/${screenshotFile}` : null);
}

function normalize(url) {
  try { const u = new URL(url); u.hash = ''; return u.toString().replace(/\/$/, '') || url; } catch { return url; }
}

// ── Email ─────────────────────────────────────────────────────────────────────
async function sendReport(run, total, critical, warnings) {
  if (!process.env.SMTP_HOST) return;
  try {
    const nodemailer = require('nodemailer');
    const t = nodemailer.createTransporter({
      host: process.env.SMTP_HOST, port: parseInt(process.env.SMTP_PORT||'587'),
      secure: process.env.SMTP_SECURE==='true',
      auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
    });
    await t.sendMail({
      from: process.env.SMTP_FROM||process.env.SMTP_USER, to: run.email,
      subject: `QA Report: ${run.project_name} — ${critical} critical, ${warnings} warnings`,
      html: `<h2>QA Report</h2><p><b>Project:</b> ${run.project_name}</p><p><b>URL:</b> <a href="${run.url}">${run.url}</a></p><p><b>Issues:</b> ${total} total (${critical} critical, ${warnings} warnings)</p><p><a href="${process.env.APP_URL||'http://localhost:3000'}/report/${run.id}">View full report</a></p>`
    });
  } catch (err) { console.error('Email failed:', err.message); }
}

module.exports = { runTests };
