import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join, relative } from 'node:path';

/**
 * Enforcement canary for host-templates spec §J (render-time set:html sanitization).
 *
 * Every `set:html={…}` in src/**.astro MUST be either:
 *   - `set:html={sanitizeContentBody(<expr>)}` — a content-store body re-sanitized at render
 *     (defense-in-depth), where <expr> is a plain identifier / member access (no concatenation,
 *     no nested call that could smuggle a raw tail past the sanitizer), OR
 *   - `set:html={themeCss}` — a documented exemption: static engine CSS (themeToCss(MOD_CMS_PALETTE_SET)),
 *     no admin-untrusted input reaches it as-built (see spec §J). Two callsites: Base.astro (all pages)
 *     and install/preview.astro (DB-free iframe target). Re-evaluate the moment admin-authored
 *     theme values (custom color/font/CSS) flow into themeToCss output — it then becomes a CSS-context
 *     sink needing CSS-aware escaping, NOT sanitizeContentBody.
 *
 * Sibling enforcement: the React sink class (`dangerouslySetInnerHTML`) MUST stay empty — a stored-HTML
 * sink in a .tsx is invisible to the .astro scan, so it is asserted-absent here. Adding one without
 * sanitizing (and updating this canary) fails the build.
 */
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..');

function filesWithExt(dir: string, exts: readonly string[]): string[] {
  const out: string[] = [];
  for (const ent of readdirSync(dir, { withFileTypes: true })) {
    const full = join(dir, ent.name);
    if (ent.isDirectory()) out.push(...filesWithExt(full, exts));
    else if (exts.some((e) => ent.name.endsWith(e))) out.push(full);
  }
  return out;
}

// Tolerates whitespace around `=`; captures the single expression inside set:html={ … }.
const SINK_RE = /set:html\s*=\s*\{([^}]*)\}/g;
// A safe content wrap = exactly sanitizeContentBody(<identifier or member access>), nothing appended.
const SAFE_WRAP_RE = /^sanitizeContentBody\([\w.]+\)$/;
const THEME_EXEMPTION = 'themeCss';

interface Sink {
  file: string;
  expr: string;
}

function collectSinks(): Sink[] {
  const sinks: Sink[] = [];
  for (const file of filesWithExt(SRC, ['.astro'])) {
    const text = readFileSync(file, 'utf8');
    for (const m of text.matchAll(SINK_RE)) {
      sinks.push({ file: relative(SRC, file), expr: m[1]!.trim() });
    }
  }
  return sinks;
}

describe('set:html sink canary (host-templates spec §J)', () => {
  const sinks = collectSinks();

  it('scans the real .astro tree (guards against a vacuous pass)', () => {
    // 5 content sinks + 2 themeCss exemptions exist today; the scan must see them.
    expect(sinks.length).toBeGreaterThanOrEqual(7);
  });

  it('every set:html is a tight sanitizeContentBody(...) wrap or the documented themeCss exemption', () => {
    const violations = sinks.filter((s) => s.expr !== THEME_EXEMPTION && !SAFE_WRAP_RE.test(s.expr));
    expect(
      violations,
      `Unsanitized/loose set:html sink(s) — wrap in sanitizeContentBody(<expr>) per spec §J:\n${violations
        .map((v) => `  ${v.file}: set:html={${v.expr}}`)
        .join('\n')}`,
    ).toEqual([]);
  });

  it('exactly two themeCss exemptions (Base.astro + preview.astro — both reviewed)', () => {
    expect(sinks.filter((s) => s.expr === THEME_EXEMPTION).length).toBe(2);
  });

  it('no React dangerouslySetInnerHTML sink exists (the .astro scan cannot see .tsx)', () => {
    const offenders = filesWithExt(SRC, ['.tsx', '.jsx'])
      .filter((f) => readFileSync(f, 'utf8').includes('dangerouslySetInnerHTML'))
      .map((f) => relative(SRC, f));
    expect(
      offenders,
      `dangerouslySetInnerHTML found — sanitize the value AND extend this canary to enforce it:\n${offenders
        .map((f) => `  ${f}`)
        .join('\n')}`,
    ).toEqual([]);
  });
});
