package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"
)

type mraidIssue struct {
	ID        string `json:"id"`
	Severity  string `json:"severity"`
	Title     string `json:"title"`
	Detail    string `json:"detail"`
	Line      int    `json:"line,omitempty"`
	Reference string `json:"reference"`
}

type mraidResult struct {
	File   string       `json:"file"`
	OK     bool         `json:"ok"`
	Issues []mraidIssue `json:"issues"`
}

type mraidRule struct {
	ID        string
	Severity  string
	Title     string
	Detail    string
	Reference string
	Test      func(mraidContext, mraidRule) *mraidIssue
}

type mraidContext struct {
	FilePath       string
	HTML           string
	ScriptText     string
	MraidCallLines map[string]int
}

func MraidPage(w http.ResponseWriter, r *http.Request) {
	body := `
<header class="tool-header">
  <h2 class="tool-title">MRAID Checker</h2>
  <p class="tool-description">Static checks for AppLovin, ironSource, and Unity HTML builds based on GUIDE.md plus the MRAID 3.0 specification and Best Practices Guide.</p>
</header>
<section class="tool-panel">
  <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
  <div class="panel-body">
    <div class="control-group">
      <div id="dropZone" class="drop-zone">
        <div class="file-row">
          <button id="pickFolder" class="secondary">Select Folder</button>
          <button id="pickFiles" class="secondary">Select File(s)</button>
          <button id="clear" class="secondary">Clear</button>
        </div>
        <div class="drop-zone-text">or drop HTML files or folders here</div>
      </div>
      <div id="selection" class="selected-list"></div>
    </div>
    <div class="action-row"><button id="scan">Scan</button></div>
    <div id="status" class="status-panel"></div>
  </div>
</section>
<div id="results" class="results-panel"></div>

<style>
  .row-result { padding:8px 0; border-bottom:1px solid #333; }
  .file-head { display:flex; gap:8px; align-items:baseline; font-family:Consolas, monospace; font-size:12px; }
  .mark { width:20px; flex-shrink:0; font-weight:bold; }
  .mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
  .file-name { word-break:break-all; }
  .counts { opacity:0.7; margin-left:auto; white-space:nowrap; }
  .issues { margin:6px 0 0 28px; padding:0; }
  .issue { list-style:none; margin:6px 0; }
  .badge { display:inline-block; min-width:82px; margin-right:6px; padding:1px 5px; border-radius:3px; font-size:11px; text-align:center; }
  .requirement { color:#ffb4ad; background:rgba(248,81,73,0.18); }
  .best-practice { color:#ffd580; background:rgba(210,153,34,0.18); }
  .issue-title { font-weight:600; }
  .issue-detail { opacity:0.72; margin-top:2px; }
  .summary { opacity:0.85; margin-bottom:6px; }
</style>

<script>
const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const selectionEl = document.getElementById('selection');
const PAGE_SIZE = 5;
let pickedFiles = [];
let selectedPage = 0;
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); }
function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
function renderSelection() {
  const oldPager = document.getElementById('selectedPager');
  if (oldPager) oldPager.remove();
  if (!pickedFiles.length) {
    selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
    return;
  }
  const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
  selectedPage = Math.min(selectedPage, maxPage);
  const start = selectedPage * PAGE_SIZE;
  const visible = pickedFiles.slice(start, start + PAGE_SIZE);
  selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
    '<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
    visible.map((p, offset) => {
      const i = start + offset;
      return '<tr><td class="mono wrap">' + escapeText(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
    }).join('') + '</tbody></table></div>';
  selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
    btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
  });
  if (pickedFiles.length > PAGE_SIZE) {
    const pager = document.createElement('div');
    pager.id = 'selectedPager';
    pager.className = 'pager';
    pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
      '<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
      '<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
    selectionEl.appendChild(pager);
    pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
    pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
  }
}
function addPaths(paths) {
  const existing = new Set(pickedFiles);
  pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
  selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
  renderSelection();
}
async function readJSONStream(response, handle) {
  if (!response.ok || !response.body) throw new Error('Request failed.');
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    let newline;
    while ((newline = buffer.indexOf('\n')) >= 0) {
      const line = buffer.slice(0, newline);
      buffer = buffer.slice(newline + 1);
      if (line.trim()) handle(JSON.parse(line));
    }
  }
  if (buffer.trim()) handle(JSON.parse(buffer));
}
renderSelection();
setupDropZone('dropZone', statusEl, (j) => {
  addPaths(j.paths || []);
  if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});

document.getElementById('pickFolder').addEventListener('click', async () => {
  const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
  const j = await r.json();
  addPaths(j.paths || []);
});
document.getElementById('pickFiles').addEventListener('click', async () => {
  const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
  const j = await r.json();
  addPaths(j.paths || []);
});
document.getElementById('clear').addEventListener('click', () => {
  pickedFiles = [];
  selectedPage = 0;
  statusEl.textContent = '';
  statusEl.classList.remove('is-busy');
  resultsEl.innerHTML = '';
  renderSelection();
});
document.getElementById('scan').addEventListener('click', async () => {
  statusEl.textContent = 'Scanning...';
  statusEl.classList.add('is-busy');
  resultsEl.innerHTML = '';
  if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
  const payload = { files: pickedFiles };
  let j = null;
  try {
    const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
    await readJSONStream(r, (msg) => {
      if (msg.type === 'status') {
        statusEl.textContent = msg.message;
        statusEl.classList.add('is-busy');
      } else if (msg.type === 'done') {
        j = msg;
      } else if (msg.type === 'error') {
        j = { error: msg.message, results: msg.results || [], skipped: msg.skipped, skippedReason: msg.skippedReason };
      }
    });
  } catch (e) {
    statusEl.classList.remove('is-busy');
    statusEl.textContent = 'Error: ' + (e.message || e);
    return;
  }
  if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
  if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
  const total = j.results.length;
  const withIssues = j.results.filter(r => !r.ok).length;
  const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
  const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
  const skipped = Number(j.skipped || 0);
  const skippedReason = j.skippedReason || 'file(s).';
  const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
  statusEl.classList.remove('is-busy');
  statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
  if (!total) { resultsEl.innerHTML = ''; return; }
  resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>';
  const tbody = resultsEl.querySelector('tbody');
  for (const rr of j.results) {
    const row = document.createElement('tr');
    const file = document.createElement('td');
    file.className = 'mono wrap';
    file.textContent = rr.file;
    const result = document.createElement('td');
    result.className = 'wrap';
    if (!rr.ok) {
      const list = document.createElement('ul');
      list.className = 'issues';
      for (const i of rr.issues) {
        const item = document.createElement('li');
        item.className = 'issue';
        const line = i.line ? ' line ' + i.line + ':' : '';
        item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
        list.appendChild(item);
      }
      result.appendChild(list);
    } else {
      result.innerHTML = '<span class="badge ok">OK</span>';
    }
    row.append(file, result);
    tbody.appendChild(row);
  }
});
</script>
`
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_, _ = w.Write([]byte(Page("/mraid", "MRAID Checker", body)))
}

func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
	p := PickFolder("Select folder to scan", "")
	if p == "" {
		writeJSON(w, map[string]any{"paths": []string{}})
		return
	}
	files := []string{}
	for _, f := range collectHTMLFiles(p) {
		if isInSupportedMraidFolderPath(f) {
			files = append(files, f)
		}
	}
	writeJSON(w, map[string]any{"paths": files})
}

func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
	paths := PickFiles("Select HTML files", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, true, "")
	writeJSON(w, map[string]any{"paths": paths})
}

func MraidScan(w http.ResponseWriter, r *http.Request) {
	send := newJSONStream(w)
	var req struct {
		Folder string   `json:"folder"`
		Files  []string `json:"files"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		send(map[string]any{"type": "error", "message": err.Error(), "results": []mraidResult{}})
		return
	}

	var targets []string
	var baseDir string
	skipped := 0
	skippedReason := ""
	noTargetsError := "No HTML files were found."
	if len(req.Files) > 0 {
		var existing []string
		for _, f := range req.Files {
			if fileExists(f) {
				existing = append(existing, f)
			}
		}
		targets = existing
		skipped = len(req.Files) - len(existing)
		skippedReason = "missing file(s)."
		noTargetsError = "No selected HTML files were found."
		if len(targets) > 0 {
			baseDir = commonBaseDir(targets)
		}
	} else if req.Folder != "" && fileExists(req.Folder) {
		all := collectHTMLFiles(req.Folder)
		for _, f := range all {
			if isInSupportedMraidFolderPath(f) {
				targets = append(targets, f)
			}
		}
		skipped = len(all) - len(targets)
		skippedReason = "HTML file(s) outside AppLovin/ironSource/Unity folders."
		noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found."
		baseDir = req.Folder
	} else {
		send(map[string]any{"type": "error", "message": "Pick a folder or files first", "results": []mraidResult{}})
		return
	}
	if len(targets) == 0 {
		send(map[string]any{"type": "error", "message": noTargetsError, "results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason})
		return
	}

	results := []mraidResult{}
	for i, file := range targets {
		send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))})
		data, err := os.ReadFile(file)
		if err != nil {
			continue
		}
		issues := checkMraid(string(data), file)
		display := file
		if baseDir != "" {
			if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
				display = rel
			} else {
				display = filepath.Base(file)
			}
		}
		results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
	}
	send(map[string]any{"type": "done", "results": results, "skipped": skipped, "skippedReason": skippedReason})
}

var (
	mraidScriptRx               = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
	mraidCallRx                 = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
	mraidReferenceRx            = regexp.MustCompile(`(?i)\bmraid\b`)
	mraidJsRx                   = regexp.MustCompile(`(?i)\bmraid\.js\b`)
	mraidViewportRx             = regexp.MustCompile(`(?i)<meta\b[^>]*\bname\s*=\s*['"]viewport['"][^>]*>`)
	mraidWindowOpenRx           = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
	mraidLocationNavRx          = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
	mraidHyperlinkRx            = regexp.MustCompile(`(?i)<a\b[^>]*\bhref\s*=`)
	mraidCloseUiRx              = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
	mraidLoadingLeftRx          = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
	mraidLoadingRightRx         = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
	mraidDomReadyRx             = regexp.MustCompile(`(?i)document\s*\.\s*readyState|DOMContentLoaded|\bwindow\s*\.\s*(addEventListener\s*\(\s*['"]load['"]|onload\b)`)
	mraidTypeofGuardRx          = regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*!={1,2}\s*['"]undefined['"]`)
	mraidReadyTimeoutRx         = regexp.MustCompile(`(?is)setTimeout\s*\(.*mraid|mraid.*setTimeout\s*\(`)
	mraidLateDetectionRx        = regexp.MustCompile(`(?i)(setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\().*(window\s*\.\s*mraid|\bmraid\b)|(window\s*\.\s*mraid|\bmraid\b).*(setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\()`)
	mraidOpenGuardRx            = regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*\.\s*open\s*={2,3}\s*['"]function['"]|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*open\s*!={1,2}\s*['"]undefined['"]`)
	mraidOpenLoadingGuardRx     = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*mraid\s*\.\s*getState\s*\(\s*\)|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*getState\s*!={1,2}\s*['"]function['"]`)
	mraidVariableLoadingGuardRx = regexp.MustCompile(`(?i)\b\w+\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*\w+\b`)
	mraidLoadingFallbackRx      = regexp.MustCompile(`(?is)\b\w+\s*={2,3}\s*['"]loading['"].{0,500}\bwindow\s*\.\s*open\s*\(`)
	mraidAudioSafeRx            = regexp.MustCompile(`(?i)typeof\s+\w+\s*={2,3}\s*['"]number['"]|!=\s*null|!==\s*null`)
	mraidTwoPartExpandRx        = regexp.MustCompile(`(?i)mraid\s*\.\s*expand\s*\(\s*[^)\s]`)
	mraidVideoOpenRx            = regexp.MustCompile(`(?i)mraid\s*\.\s*open\s*\(\s*[^)]*\.(mp4|m4v|mov|webm|avi|m3u8)(['"` + "`" + `?#)]|$)`)
	mraidRejectedScriptAttrsRx  = regexp.MustCompile(`(?i)<script\b[^>]*(\btype\s*=\s*['"]module['"]|\bcrossorigin\b)`)
	mraidConsoleErrorRx         = regexp.MustCompile(`(?i)\bconsole\s*\.\s*error\s*\(`)
)

const mraidReference = "GUIDE.md, MRAID 3.0 specification, and MRAID 3.0 Best Practices Guide."

func isInSupportedMraidFolderPath(filePath string) bool {
	supported := map[string]bool{
		"al":          true,
		"applovin":    true,
		"app-lovin":   true,
		"is":          true,
		"ironsource":  true,
		"iron-source": true,
		"iron_source": true,
		"un":          true,
		"unity":       true,
		"unityads":    true,
		"unity-ads":   true,
		"unity_ads":   true,
	}
	parts := strings.FieldsFunc(strings.ToLower(filepath.Dir(filePath)), func(r rune) bool {
		return r == '/' || r == '\\'
	})
	for _, part := range parts {
		if supported[part] {
			return true
		}
	}
	return false
}

func checkMraid(htmlSrc string, filePath ...string) []mraidIssue {
	sourcePath := ""
	if len(filePath) > 0 {
		sourcePath = filePath[0]
	}
	ctx := mraidContext{
		FilePath:       sourcePath,
		HTML:           htmlSrc,
		ScriptText:     extractMraidScriptText(htmlSrc),
		MraidCallLines: collectMraidCallLines(htmlSrc),
	}
	var out []mraidIssue
	for _, rule := range mraidRules {
		if found := rule.Test(ctx, rule); found != nil {
			out = append(out, *found)
		}
	}
	if out == nil {
		out = []mraidIssue{}
	}
	return out
}

func extractMraidScriptText(htmlSrc string) string {
	var parts []string
	for _, m := range mraidScriptRx.FindAllStringSubmatch(htmlSrc, -1) {
		parts = append(parts, m[1])
	}
	return strings.Join(parts, "\n")
}

func collectMraidCallLines(htmlSrc string) map[string]int {
	out := map[string]int{}
	for idx, line := range strings.Split(htmlSrc, "\n") {
		for _, m := range mraidCallRx.FindAllStringSubmatch(line, -1) {
			if _, ok := out[m[1]]; !ok {
				out[m[1]] = idx + 1
			}
		}
	}
	return out
}

func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) }
func mraidCountJSReferences(ctx mraidContext) int {
	return len(mraidJsRx.FindAllStringIndex(ctx.HTML, -1))
}
func mraidHasViewport(ctx mraidContext) bool { return mraidViewportRx.MatchString(ctx.HTML) }
func mraidIsNetworkTarget(ctx mraidContext) bool {
	if ctx.FilePath == "" {
		return true
	}
	name := strings.ToLower(filepath.Base(ctx.FilePath))
	return isInSupportedMraidFolderPath(ctx.FilePath) ||
		strings.HasSuffix(name, "_al.html") || strings.HasSuffix(name, "_is.html") || strings.HasSuffix(name, "_un.html") ||
		strings.HasSuffix(name, "_al.htm") || strings.HasSuffix(name, "_is.htm") || strings.HasSuffix(name, "_un.htm")
}
func mraidHasCall(ctx mraidContext, method string) bool {
	_, ok := ctx.MraidCallLines[method]
	return ok
}
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] }

func mraidHasFunctionGuard(ctx mraidContext, method string) bool {
	escaped := regexp.QuoteMeta(method)
	rx := regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*\.\s*` + escaped + `\s*={2,3}\s*['"]function['"]|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*` + escaped + `\s*!={1,2}\s*['"]undefined['"]`)
	return rx.MatchString(ctx.ScriptText)
}

func mraidHasEventListener(ctx mraidContext, eventName string) bool {
	rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
	return rx.MatchString(ctx.ScriptText)
}

func mraidHasSupportCheck(ctx mraidContext, feature string) bool {
	rx := regexp.MustCompile(`(?i)mraid\s*\.\s*supports\s*\(\s*['"]` + regexp.QuoteMeta(feature) + `['"]`)
	return rx.MatchString(ctx.ScriptText)
}

func mraidHasReadyGate(ctx mraidContext) bool {
	return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText)
}

func mraidHasDomReadyGate(ctx mraidContext) bool { return mraidDomReadyRx.MatchString(ctx.ScriptText) }
func mraidHasReadyStateFallback(ctx mraidContext) bool {
	return mraidHasEventListener(ctx, "ready") && mraidHasCall(ctx, "getState")
}
func mraidHasTypeofGuard(ctx mraidContext) bool {
	return mraidTypeofGuardRx.MatchString(ctx.ScriptText)
}
func mraidHasReadyTimeout(ctx mraidContext) bool {
	return mraidReadyTimeoutRx.MatchString(ctx.ScriptText)
}
func mraidHasLateDetection(ctx mraidContext) bool {
	return mraidLateDetectionRx.MatchString(ctx.ScriptText)
}
func mraidHasOpenGuard(ctx mraidContext) bool { return mraidOpenGuardRx.MatchString(ctx.ScriptText) }
func mraidHasOpenLoadingGuard(ctx mraidContext) bool {
	return mraidOpenLoadingGuardRx.MatchString(ctx.ScriptText) ||
		(mraidHasCall(ctx, "getState") && mraidVariableLoadingGuardRx.MatchString(ctx.ScriptText)) ||
		(mraidHasCall(ctx, "getState") && mraidLoadingFallbackRx.MatchString(ctx.ScriptText))
}
func mraidUsesBrowserNavigation(ctx mraidContext) bool {
	return mraidWindowOpenRx.MatchString(ctx.ScriptText)
}
func mraidUsesLocationNavigation(ctx mraidContext) bool {
	return mraidLocationNavRx.MatchString(ctx.ScriptText)
}
func mraidHasAudioVolumeSafeHandling(ctx mraidContext) bool {
	return mraidHasEventListener(ctx, "audioVolumeChange") && mraidAudioSafeRx.MatchString(ctx.ScriptText)
}
func mraidHasLifecycleSignal(ctx mraidContext, name string) bool {
	return regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(name) + `\b`).MatchString(ctx.HTML)
}

func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
	found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
	if line > 0 {
		found.Line = line
	}
	return &found
}

var mraidRules = []mraidRule{
	{ID: "mraid-js-missing", Severity: "requirement", Title: "mraid.js reference not found", Detail: "MRAID creatives for AppLovin, ironSource, and Unity must request mraid.js early in the generated HTML.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidIsNetworkTarget(ctx) && mraidCountJSReferences(ctx) == 0 {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "mraid-js-duplicate", Severity: "requirement", Title: "Multiple mraid.js references found", Detail: "Request mraid.js exactly once; duplicate references can inject multiple MRAID libraries and slow or destabilize the ad.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidCountJSReferences(ctx) > 1 {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "mraid-reference", Severity: "requirement", Title: "MRAID API reference not found", Detail: "Playable HTML should use the injected mraid object when it depends on an MRAID container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasReference(ctx) {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "viewport-meta", Severity: "best-practice", Title: "Viewport meta tag not found", Detail: "MRAID creatives should include a mobile viewport meta tag so the ad scales predictably on mobile WebViews.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasViewport(ctx) {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "mraid-typeof-guard", Severity: "requirement", Title: "MRAID object is not guarded", Detail: "Guard MRAID access with typeof mraid !== \"undefined\" or an equivalent window.mraid guard before making MRAID calls.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasReference(ctx) && !mraidHasTypeofGuard(ctx) {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "dom-ready-gate", Severity: "requirement", Title: "DOM readiness is not guarded", Detail: "MRAID startup should wait for DOM readiness as well as MRAID readiness before building or binding rich media content.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if !mraidHasReference(ctx) || mraidHasDomReadyGate(ctx) {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "ready-gate", Severity: "requirement", Title: "MRAID ready state is not guarded", Detail: "Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if !mraidHasReference(ctx) || mraidHasReadyGate(ctx) {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "ready-state-fallback", Severity: "requirement", Title: "MRAID ready listener/state fallback pair not found", Detail: "Use both mraid.addEventListener(\"ready\", ...) and mraid.getState() so startup works when ready fires before the listener attaches.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if !mraidHasReference(ctx) || mraidHasReadyStateFallback(ctx) {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "ready-timeout-fallback", Severity: "best-practice", Title: "MRAID ready timeout fallback not found", Detail: "A timeout fallback prevents startup hangs when a container never fires ready.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if !mraidHasReference(ctx) || mraidHasReadyTimeout(ctx) {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "late-mraid-detection", Severity: "best-practice", Title: "Late MRAID injection detection not found", Detail: "Poll or retry briefly for late window.mraid injection before deciding the creative is running without MRAID.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if !mraidHasReference(ctx) || mraidHasLateDetection(ctx) {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "error-listener", Severity: "best-practice", Title: "No MRAID error listener found", Detail: "Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if !mraidHasReference(ctx) || mraidHasEventListener(ctx, "error") {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "viewability", Severity: "best-practice", Title: "MRAID viewability fallback chain not found", Detail: "Use exposureChange for MRAID 3.0 and keep viewableChange or isViewable() as MRAID 2.0 fallback before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		hasVisibility := mraidHasEventListener(ctx, "exposureChange") && (mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange"))
		if !mraidHasReference(ctx) || hasVisibility {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "audio-volume-change", Severity: "best-practice", Title: "audioVolumeChange handling not found", Detail: "Listen for audioVolumeChange and ignore null values; only apply volume math when the value is numeric.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if !mraidHasReference(ctx) || mraidHasAudioVolumeSafeHandling(ctx) {
			return nil
		}
		return mraidIssueFrom(rule, 0)
	}},
	{ID: "open-api", Severity: "requirement", Title: "Browser navigation used instead of mraid.open", Detail: "Use mraid.open(url) for click-through navigation inside an MRAID ad container; avoid hyperlinks, location redirects, and unguarded browser navigation.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHyperlinkRx.MatchString(ctx.HTML) || mraidUsesLocationNavigation(ctx) || (mraidUsesBrowserNavigation(ctx) && !mraidHasCall(ctx, "open")) {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "open-guarded-fallback", Severity: "requirement", Title: "mraid.open guarded fallback not found", Detail: "Guard mraid.open with function/state checks and provide a window.open fallback when MRAID is unavailable or still loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "open") && (!mraidHasOpenGuard(ctx) || !mraidHasOpenLoadingGuard(ctx) || !mraidUsesBrowserNavigation(ctx)) {
			return mraidIssueFrom(rule, mraidLine(ctx, "open"))
		}
		return nil
	}},
	{ID: "open-video-url", Severity: "best-practice", Title: "mraid.open appears to target video media", Detail: "Use mraid.playVideo(url) for native video playback cases instead of mraid.open(videoUrl).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidVideoOpenRx.MatchString(ctx.ScriptText) && !mraidHasCall(ctx, "playVideo") {
			return mraidIssueFrom(rule, mraidLine(ctx, "open"))
		}
		return nil
	}},
	{ID: "close-api", Severity: "best-practice", Title: "No mraid.close call found", Detail: "If the creative renders its own close control, wire that control to mraid.close().", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidCloseUiRx.MatchString(ctx.HTML) && !mraidHasCall(ctx, "close") {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "unload-fallback", Severity: "best-practice", Title: "No mraid.unload graceful failure path found", Detail: "Use mraid.unload() when the ad cannot run or refuses to show, instead of leaving a broken or blank creative.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasReference(ctx) && !mraidHasCall(ctx, "unload") {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "resize-properties", Severity: "requirement", Title: "mraid.resize used without setResizeProperties", Detail: "Call mraid.setResizeProperties(...) before mraid.resize(); otherwise the container should emit an error.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "resize") && !mraidHasCall(ctx, "setResizeProperties") {
			return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
		}
		return nil
	}},
	{ID: "use-custom-close", Severity: "best-practice", Title: "Deprecated useCustomClose found", Detail: "MRAID 3.0 ignores useCustomClose; the host provides the mandatory close control.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "useCustomClose") || regexp.MustCompile(`(?i)useCustomClose`).MatchString(ctx.ScriptText) {
			return mraidIssueFrom(rule, mraidLine(ctx, "useCustomClose"))
		}
		return nil
	}},
	{ID: "two-part-expand", Severity: "best-practice", Title: "Two-part expand appears to be used", Detail: "Two-part expandable ads are deprecated in MRAID 3.0; use self-contained one-part expandables.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidTwoPartExpandRx.MatchString(ctx.ScriptText) {
			return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
		}
		return nil
	}},
	{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without function guard", Detail: "Guard mraid.expand with a function check before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "expand") && !mraidHasFunctionGuard(ctx, "expand") {
			return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
		}
		return nil
	}},
	{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without function guard", Detail: "Guard mraid.resize with a function check before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "resize") && !mraidHasFunctionGuard(ctx, "resize") {
			return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
		}
		return nil
	}},
	{ID: "store-picture-support", Severity: "requirement", Title: "storePicture used without supports check", Detail: "Call mraid.supports(\"storePicture\") before mraid.storePicture(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "storePicture") && !mraidHasSupportCheck(ctx, "storePicture") {
			return mraidIssueFrom(rule, mraidLine(ctx, "storePicture"))
		}
		return nil
	}},
	{ID: "calendar-support", Severity: "requirement", Title: "createCalendarEvent used without supports check", Detail: "Call mraid.supports(\"calendar\") before mraid.createCalendarEvent(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "createCalendarEvent") && !mraidHasSupportCheck(ctx, "calendar") {
			return mraidIssueFrom(rule, mraidLine(ctx, "createCalendarEvent"))
		}
		return nil
	}},
	{ID: "location-support", Severity: "requirement", Title: "getLocation used without supports check", Detail: "Call mraid.supports(\"location\") before mraid.getLocation(). Do not use HTML5 geolocation as a substitute in MRAID.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "getLocation") && !mraidHasSupportCheck(ctx, "location") {
			return mraidIssueFrom(rule, mraidLine(ctx, "getLocation"))
		}
		return nil
	}},
	{ID: "vpaid-support", Severity: "requirement", Title: "VPAID integration used without supports check", Detail: "Call mraid.supports(\"vpaid\") before mraid.initVpaid(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "initVpaid") && !mraidHasSupportCheck(ctx, "vpaid") {
			return mraidIssueFrom(rule, mraidLine(ctx, "initVpaid"))
		}
		return nil
	}},
	{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without function guard", Detail: "Guard mraid.setOrientationProperties with a function check before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasFunctionGuard(ctx, "setOrientationProperties") {
			return mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
		}
		return nil
	}},
	{ID: "size-change", Severity: "best-practice", Title: "No sizeChange listener found", Detail: "Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		depends := mraidHasCall(ctx, "getMaxSize") || mraidHasCall(ctx, "getScreenSize") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "setOrientationProperties")
		if depends && !mraidHasEventListener(ctx, "sizeChange") {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "state-change", Severity: "best-practice", Title: "No stateChange listener found", Detail: "Listen for stateChange when using expand, resize, close, or other state-changing container behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		changes := mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "close")
		if changes && !mraidHasEventListener(ctx, "stateChange") {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "output-script-hygiene", Severity: "requirement", Title: "Rejected script attributes found", Detail: "Playable output should not contain type=\"module\" or crossorigin on script tags; ad networks can reject ES modules/CORS attributes.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidRejectedScriptAttrsRx.MatchString(ctx.HTML) {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "console-error", Severity: "best-practice", Title: "console.error found", Detail: "The ship checklist expects no console.error calls in output; use guarded logging or remove debug error logs before submission.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		if mraidConsoleErrorRx.MatchString(ctx.ScriptText) {
			return mraidIssueFrom(rule, 0)
		}
		return nil
	}},
	{ID: "lifecycle-stubs", Severity: "requirement", Title: "Playable lifecycle signals not found", Detail: "Expose or call gameReady, gameStart, gameEnd, and gameClose so network preview tools can detect the playable lifecycle.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
		missing := []string{}
		for _, name := range []string{"gameReady", "gameStart", "gameEnd", "gameClose"} {
			if !mraidHasLifecycleSignal(ctx, name) {
				missing = append(missing, name)
			}
		}
		if len(missing) == 0 {
			return nil
		}
		found := mraidIssueFrom(rule, 0)
		found.Detail = found.Detail + " Missing: " + strings.Join(missing, ", ") + "."
		return found
	}},
}
