<?php
namespace Instinctor\Services;

use Instinctor\Core\DB;

/**
 * PageLinter: build-time enforcement of the page correctness rules that were,
 * until 2026-08, only ever checked after the fact by rendered-audit sweeps.
 * The rule set is the documented one in the platform KB
 * (kb/rules/design-defaults.md) and was validated empirically against all 366
 * published pages during the 2026-08-07/08 fleet audit: 4592 rendered contrast
 * violations and 758 zebra pairs existed because generation never checked
 * anything. This class computes everything from the element tree, no browser.
 *
 * Three enforcement modes, one rule core:
 *   - generate (fix=true):  silent auto-fix inside DemoService::startDemo()
 *   - gate     (fix=false): publish gate for Academy/managed builds, report back
 *   - advisory (fix=false): editor warnings
 * The periodic headless fleet audit on the VPS stays as rendered-truth
 * verification that this tree-level model matches reality.
 *
 * Every luminance decision uses WCAG-LINEARIZED luminance. The simple sRGB
 * average puts #999999 at 0.60 and mass-flags muted eyebrow accents that are
 * sub-AA by design; linearized it sits at 0.325 and is left alone. This burned
 * two fix rounds during the fleet audit; do not "simplify" the formula.
 */
class PageLinter
{
    /** mailto targets that actually exist (never fabricate contacts) */
    private const MAILTO_ALLOW = ['helpdesk@instinctor.com', 'team@instinctor.com', 'sergei@instinctor.com'];

    /** color-carrying settings keys the contrast rule may flip */
    private const FLIP_RE = '/(^color$|text_color|title_color|content_color|header_color|sub_color|subtitle_color|body_color|label_color|question_color|answer_color|price_color|item_color|value_color|desc_color|name_color|eyebrow_color|icon_color|line_\d+_color|cl_color_\d+|col\d*_color)/i';

    /** element types whose align key the hero rule manages */
    private const ALIGN_TYPES = ['text', 'button', 'title', 'kinetic', 'title_animated', 'heading'];

    /**
     * Lint (and optionally fix) a page tree.
     *
     * @param array $els     decoded elements array
     * @param array $pageSet decoded page settings
     * @param array $ctx     ['vertical'=>?, 'trade'=>?, 'fix'=>bool]
     * @return array{elements: array, violations: array, fixed: int}
     */
    public static function run(array $els, array $pageSet, array $ctx = []): array
    {
        $fix = (bool) ($ctx['fix'] ?? false);
        $v = [];
        $fixed = 0;

        // Cinematic pages are hand-made and exempt from every rule (Sergei
        // 2026-08-07). Absent body_scroll = snap = cinematic.
        if (($pageSet['body_scroll'] ?? '') !== 'classic') {
            return ['elements' => $els, 'violations' => [], 'fixed' => 0];
        }

        $by = [];
        foreach ($els as $i => $e) $by[(string) $e['id']] = $i;
        $get = fn($e) => is_array($e['settings'] ?? null) ? $e['settings'] : (json_decode((string) ($e['settings'] ?? ''), true) ?: []);
        $put = function (&$e, array $s) {
            $e['settings'] = is_string($e['settings'] ?? null)
                ? json_encode($s, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $s;
        };

        $tops = [];
        foreach ($els as $e) if (($e['parent_id'] ?? null) === null && ($e['type'] ?? '') === 'section') $tops[] = $e;
        usort($tops, fn($a, $b) => ($a['sort_order'] ?? 0) <=> ($b['sort_order'] ?? 0));
        $heroId = $tops ? (string) $tops[0]['id'] : null;
        // Blog articles are their own design class; hero + zebra rules skip.
        $isArticle = $heroId === 'art_sec';

        $inSubtree = function ($eid, $rootId) use (&$by, &$els) {
            $seen = 0; $cur = $els[$by[(string) $eid] ?? -1] ?? null;
            while ($cur && $seen++ < 30) {
                if ((string) $cur['id'] === (string) $rootId) return true;
                $p = $cur['parent_id'] ?? null;
                $cur = $p !== null ? ($els[$by[(string) $p] ?? -1] ?? null) : null;
            }
            return false;
        };

        // ── page palette: dominant dark + light foregrounds (contrast targets) ──
        $darkHist = []; $lightHist = [];
        foreach ($els as $e) {
            foreach ($get($e) as $k => $val) {
                if (!is_string($val) || !preg_match(self::FLIP_RE, (string) $k)) continue;
                $l = self::lum($val);
                if ($l === null || self::chroma($val) >= 70) continue;
                if ($l <= 0.1) $darkHist[$val] = ($darkHist[$val] ?? 0) + 1;
                elseif ($l >= 0.6) $lightHist[$val] = ($lightHist[$val] ?? 0) + 1;
            }
        }
        arsort($darkHist); arsort($lightHist);
        $pageDark  = $darkHist ? array_key_first($darkHist) : '#1d2327';
        $pageLight = $lightHist ? array_key_first($lightHist) : '#ffffff';

        // ── Rule: zebra alternation (before contrast, so contrast sees final bgs;
        //    the fleet-audit fix rounds proved the reverse order composes badly) ──
        if (!$isArticle) {
            // Substantiality proxy (the tree has no pixel heights): a band joins
            // the zebra rhythm only if it carries content, meaning at least one
            // text-bearing descendant. Thin spacer/divider bands never flag.
            $kidsOf = [];
            foreach ($els as $e2) $kidsOf[(string) ($e2['parent_id'] ?? '')][] = $e2;
            $hasText = function ($rootId) use (&$kidsOf, &$hasText) {
                foreach ($kidsOf[(string) $rootId] ?? [] as $k2) {
                    if (in_array($k2['type'] ?? '', ['text', 'kinetic', 'title', 'title_animated', 'heading'], true)) return true;
                    if ($hasText($k2['id'])) return true;
                }
                return false;
            };
            $seq = [];
            foreach ($tops as $t) {
                if (!$hasText($t['id'])) continue;
                $eff = self::effOwnBg($get($t));
                if ($eff['kind'] === 'none') {
                    // transparent top-level band shows the page background
                    $pb = (string) ($pageSet['bg_color'] ?? '#ffffff');
                    $eff = ['kind' => 'flat', 'lum' => self::lum($pb) ?? 1.0, 'hex' => $pb];
                }
                $seq[] = ['id' => (string) $t['id'], 'kind' => $eff['kind'], 'lum' => $eff['lum'], 'hex' => $eff['hex']];
            }
            $prev = null;
            foreach ($seq as $i => $band) {
                if ($band['kind'] !== 'flat') { $prev = null; continue; }
                // 0.09: white next to warm cream (lum ~0.90) is accepted design
                // language (Sergei 2026-08-08, celaglow); identical tones are not
                if ($prev !== null && abs($prev['lum'] - $band['lum']) < 0.09) {
                    $v[] = ['rule' => 'zebra', 'sec' => $band['id'], 'msg' => "adjacent same-tone bands {$prev['hex']} ~ {$band['hex']}"];
                    if ($fix) {
                        $target = self::pickBand($seq, $prev['lum'] < 0.5);
                        $idx = $by[$band['id']];
                        $s = $get($els[$idx]);
                        $s['bg_color'] = $target;
                        if (!empty($s['mesh_base_color'])) $s['mesh_base_color'] = ''; // never leave a stray override
                        $put($els[$idx], $s);
                        $band['lum'] = self::lum($target); $band['hex'] = $target;
                        $fixed++;
                    }
                }
                $prev = $band;
            }
        }

        // ── Rule: same-tone text contrast against EFFECTIVE background.
        //    mesh_base_color overrides bg_color when no mesh is active
        //    (section.php ~603); images/meshes make contrast unknowable here
        //    and are skipped, the rendered audit owns those. Kinetic excluded:
        //    the engine recolors at runtime. Same-tone collisions ONLY, muted
        //    mid-luminance accents are by design (Sergei 2026-08-08). ──
        // Composite blocks own their internal backgrounds (cards, cells, bars);
        // judging their color keys against the SECTION bg is how the first
        // report run over-flagged 2148 findings on a rendered-clean fleet.
        // They get paired-key evaluation only, never the section fallback.
        $composite = ['header', 'footer', 'table', 'plans', 'reviews', 'price_list', 'flip_box', 'nav_menu', 'products', 'grid', 'rows', 'columns', 'schedule', 'team', 'unfold', 'carousel', 'slider'];
        foreach ($els as $i => $e) {
            $ty = $e['type'] ?? '';
            if ($ty === 'kinetic' || $ty === 'section') continue;
            $isComposite = in_array($ty, $composite, true);
            $secBg = $isComposite ? null : self::effBgFor($e, $by, $els, $get, $pageSet);
            $s = $get($e); $tch = false;
            $cardBg = self::opaque($s['card_bg'] ?? '') ? (string) $s['card_bg'] : null;
            foreach ($s as $k => $val) {
                if (!is_string($val) || !preg_match(self::FLIP_RE, (string) $k)) continue;
                // paired bg: announcement_text_color -> announcement_bg,
                // cta_color -> cta_bg, any X_color/X_text_color -> X_bg;
                // card_bg governs every color key of its element
                $pairBg = null;
                $prefix = preg_replace('/(text_)?color$/i', '', (string) $k);
                if ($prefix !== '' && self::opaque($s[$prefix . 'bg'] ?? '')) $pairBg = (string) $s[$prefix . 'bg'];
                elseif ($cardBg !== null) $pairBg = $cardBg;
                if ($pairBg !== null) {
                    $bgHex = $pairBg; $bgLum = self::lum($pairBg);
                } elseif (!$isComposite && $secBg !== null && $secBg['kind'] === 'flat') {
                    $bgHex = $secBg['hex']; $bgLum = $secBg['lum'];
                } else {
                    continue;
                }
                if ($bgLum === null) continue;
                $bgLight = $bgLum >= 0.6; $bgDark = $bgLum <= 0.1;
                if (!$bgLight && !$bgDark) continue;
                $l = self::lum($val);
                if ($l === null || self::chroma($val) >= 70) continue;
                if (($bgLight && $l >= 0.5) || ($bgDark && $l <= 0.05)) {
                    $v[] = ['rule' => 'contrast', 'el' => (string) $e['id'], 'msg' => "$k $val on $bgHex"];
                    if ($fix) { $s[$k] = $bgLight ? $pageDark : $pageLight; $tch = true; }
                }
            }
            if ($tch) { $put($els[$i], $s); $fixed++; }
        }

        // ── Rule: exactly one h1, carried by the hero kinetic ──
        $h1s = [];
        foreach ($els as $i => $e) {
            if (strtolower((string) ($get($e)['tag'] ?? '')) === 'h1') $h1s[] = $i;
        }
        if (!$h1s && $heroId !== null && !$isArticle) {
            $v[] = ['rule' => 'h1', 'msg' => 'page has no h1'];
            if ($fix) {
                foreach ($els as $i => $e) {
                    if (($e['type'] ?? '') !== 'kinetic' || !$inSubtree($e['id'], $heroId)) continue;
                    $s = $get($e); $s['tag'] = 'h1'; $put($els[$i], $s); $fixed++;
                    break;
                }
            }
        } elseif (count($h1s) > 1) {
            $v[] = ['rule' => 'h1', 'msg' => count($h1s) . ' h1 elements'];
            if ($fix) {
                foreach (array_slice($h1s, 1) as $i) {
                    $s = $get($els[$i]); $s['tag'] = 'h2'; $put($els[$i], $s); $fixed++;
                }
            }
        }

        // ── Rule: hero class. A real hero carries an image PAIR (desktop
        //    bg_image + mobile variant, Sergei 2026-08-08); pair heroes are
        //    left-aligned on desktop, centered on mobile. Pair-less pages keep
        //    their own layout. ──
        if ($heroId !== null && !$isArticle) {
            $hIdx = $by[$heroId];
            $hs = $get($els[$hIdx]);
            $hasDesk = trim((string) ($hs['bg_image'] ?? '')) !== '';
            $hasMob  = trim((string) ($hs['bg_image_mobile'] ?? '')) !== '' || trim((string) ($hs['bg_image_tablet'] ?? '')) !== '';
            if ($hasDesk && !$hasMob) {
                $v[] = ['rule' => 'hero_pair', 'msg' => 'hero bg_image has no mobile variant']; // needs an asset, no auto-fix
            }
            if ($hasDesk && $hasMob) {
                if (($hs['content_align'] ?? 'center') !== 'left') {
                    $v[] = ['rule' => 'hero_align', 'sec' => $heroId, 'msg' => 'pair hero not left-aligned'];
                    if ($fix) {
                        $hs['content_align'] = 'left';
                        if (empty($hs['content_align_mobile'])) $hs['content_align_mobile'] = 'center';
                        $put($els[$hIdx], $hs); $fixed++;
                    }
                }
                foreach ($els as $i => $e) {
                    if (!in_array($e['type'] ?? '', self::ALIGN_TYPES, true) || !$inSubtree($e['id'], $heroId)) continue;
                    $s = $get($e);
                    if (($s['align'] ?? 'center') === 'center' || ($s['align'] ?? '') === '') {
                        $v[] = ['rule' => 'hero_align', 'el' => (string) $e['id'], 'msg' => 'hero child centered on desktop'];
                        if ($fix) {
                            $s['align'] = 'left';
                            if (empty($s['align_mobile'])) $s['align_mobile'] = 'center';
                            $put($els[$i], $s); $fixed++;
                        }
                    }
                }
            }
        }

        // ── Rule: mobile fitness. Desktop-only px values inherit to mobile
        //    unless a _mobile override exists (per-device model: desktop is the
        //    base for ALL settings). The two classes that actually break
        //    phones: big horizontal padding (e.g. the hand-tuned hero
        //    padding_left 200) and display-size type without a mobile size. ──
        foreach ($els as $i => $e) {
            $s = $get($e); $tch = false;
            foreach (['padding_left', 'padding_right'] as $pk) {
                $val = (int) ($s[$pk] ?? 0);
                if ($val >= 60 && trim((string) ($s[$pk . '_mobile'] ?? '')) === '') {
                    $v[] = ['rule' => 'mobile_pad', 'el' => (string) $e['id'], 'msg' => "$pk=$val inherits to mobile"];
                    if ($fix) { $s[$pk . '_mobile'] = 20; $tch = true; }
                }
            }
            $size = (int) ($s['size'] ?? 0);
            if ($size >= 48 && trim((string) ($s['size_mobile'] ?? '')) === '' && in_array($e['type'] ?? '', self::ALIGN_TYPES, true)) {
                $v[] = ['rule' => 'mobile_size', 'el' => (string) $e['id'], 'msg' => "size=$size inherits to mobile"];
                if ($fix) { $s['size_mobile'] = max(28, min(40, (int) round($size * 0.5))); $tch = true; }
            }
            if ($tch) { $put($els[$i], $s); $fixed++; }
        }

        // ── Rule: link + CTA integrity (business invariants included) ──
        $anchors = [];
        foreach ($els as $e) {
            $aid = trim((string) ($get($e)['anchor_id'] ?? ''));
            if ($aid !== '') $anchors[$aid] = true;
        }
        $isRestaurant = in_array(strtolower((string) ($ctx['vertical'] ?? '')), ['restaurant', 'instaurant'], true)
            || stripos((string) ($ctx['trade'] ?? ''), 'restaurant') !== false;
        foreach ($els as $i => $e) {
            $s = $get($e); $tch = false;
            foreach (['url', 'link', 'button_url', 'cta_url', 'link_url'] as $lk) {
                $u = $s[$lk] ?? null;
                if (!is_string($u)) continue;
                $u = trim($u);
                if (($e['type'] ?? '') === 'button' && ($u === '' || $u === '#')) {
                    $v[] = ['rule' => 'cta', 'el' => (string) $e['id'], 'msg' => "button $lk is empty"];
                    continue; // no guessable target, report only
                }
                if ($u !== '' && $u[0] === '#' && strlen($u) > 1 && !isset($anchors[substr($u, 1)])) {
                    $v[] = ['rule' => 'cta', 'el' => (string) $e['id'], 'msg' => "dead in-page anchor $u"];
                }
                if (stripos($u, 'mailto:') === 0) {
                    $addr = strtolower(substr($u, 7));
                    if (!in_array($addr, self::MAILTO_ALLOW, true)) {
                        $v[] = ['rule' => 'cta', 'el' => (string) $e['id'], 'msg' => "mailto to non-existent $addr"];
                        if ($fix) { $s[$lk] = 'mailto:helpdesk@instinctor.com'; $tch = true; }
                    }
                }
                if ($isRestaurant && stripos($u, '/make-a-site') !== false) {
                    $v[] = ['rule' => 'cta', 'el' => (string) $e['id'], 'msg' => 'restaurant CTA must route /talk-to-us'];
                    if ($fix) { $s[$lk] = '/talk-to-us'; $tch = true; }
                }
            }
            if ($tch) { $put($els[$i], $s); $fixed++; }
        }

        // ── Rule: copy tokens. Em-dashes never (typography rule), $0.00 never
        //    renders (price rule is regular>0 ? regular : sale). ──
        foreach ($els as $i => $e) {
            $s = $get($e); $tch = false;
            foreach ($s as $k => $val) {
                if (!is_string($val) || !preg_match('/content|text|title|label|line_|sub|body|heading|desc/i', (string) $k)) continue;
                if (strpos($val, "\u{2014}") !== false) {
                    $v[] = ['rule' => 'copy', 'el' => (string) $e['id'], 'msg' => "em-dash in $k"];
                    if ($fix) { $s[$k] = str_replace([" \u{2014} ", "\u{2014}"], [', ', ', '], $val); $tch = true; }
                }
                if (strpos($val, '$0.00') !== false) {
                    $v[] = ['rule' => 'copy', 'el' => (string) $e['id'], 'msg' => "\$0.00 in $k"]; // price data bug, report only
                }
            }
            if ($tch) { $put($els[$i], $s); $fixed++; }
        }

        return ['elements' => $els, 'violations' => $v, 'fixed' => $fixed];
    }

    /** WCAG-linearized luminance of a hex color, or null. */
    public static function lum(?string $hex): ?float
    {
        $hex = trim((string) $hex);
        if ($hex === '' || $hex[0] !== '#') return null;
        $hex = ltrim($hex, '#');
        if (strlen($hex) === 3) $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) return null;
        $f = function ($c) { $c /= 255; return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4; };
        return 0.2126 * $f(hexdec(substr($hex, 0, 2))) + 0.7152 * $f(hexdec(substr($hex, 2, 2))) + 0.0722 * $f(hexdec(substr($hex, 4, 2)));
    }

    /** max-min channel spread; accents (chroma >= 70) are never auto-flipped */
    private static function chroma(string $hex): int
    {
        $hex = ltrim(trim($hex), '#');
        if (strlen($hex) === 3) $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) return 999;
        $c = [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
        return max($c) - min($c);
    }

    private static function opaque($c): bool
    {
        $c = trim((string) $c);
        if ($c === '' || strtolower($c) === 'transparent') return false;
        if (preg_match('/^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/', $c, $m)) return !isset($m[2]) || $m[2] === '' || hexdec($m[2]) >= 242;
        return (bool) preg_match('/^#[0-9a-fA-F]{3}$/', $c);
    }

    /**
     * A layer's own visible band: image | mesh | flat(+lum) | none.
     * section.php semantics: mesh_base_color overrides bg_color when no mesh
     * stops are active. 'none' = transparent, the layer defers to its parent
     * (top-level callers substitute the page bg themselves).
     */
    private static function effOwnBg(array $s): array
    {
        foreach (['bg_color_2', 'bg_color_3', 'bg_color_4'] as $mk) {
            if (self::opaque($s[$mk] ?? '')) return ['kind' => 'mesh', 'lum' => null, 'hex' => null];
        }
        if (!empty($s['bg_image']) || !empty($s['bg_depth_image']) || !empty($s['bg_video'])) {
            return ['kind' => 'image', 'lum' => null, 'hex' => null];
        }
        $mb = trim((string) ($s['mesh_base_color'] ?? ''));
        if (self::opaque($mb)) return ['kind' => 'flat', 'lum' => self::lum($mb), 'hex' => $mb];
        $bg = trim((string) ($s['bg_color'] ?? ''));
        // section.php coerces #f5f5f5 to transparent; mirror it or the linter
        // sees a band the renderer never paints
        if (strtolower($bg) === '#f5f5f5') $bg = '';
        if (self::opaque($bg)) return ['kind' => 'flat', 'lum' => self::lum($bg), 'hex' => $bg];
        return ['kind' => 'none', 'lum' => null, 'hex' => null];
    }

    /**
     * Effective rendered bg for an element via its ancestor chain. Walking
     * THROUGH a grid/rows/columns ancestor returns 'container': cell
     * backgrounds live in per-cell arrays this generic walk cannot see, and
     * judging cell content against the section bg is how vs-page comparison
     * cards (dark cells on white sections) got mass-flagged. The rendered
     * audit owns cell content.
     */
    private static function effBgFor(array $e, array $by, array $els, callable $get, array $pageSet): array
    {
        $cur = $e; $seen = 0; $first = true;
        while ($cur && $seen++ < 25) {
            if (!$first && in_array($cur['type'] ?? '', ['grid', 'rows', 'columns'], true)) {
                return ['kind' => 'container', 'lum' => null, 'hex' => null];
            }
            $eff = self::effOwnBg($get($cur));
            if ($eff['kind'] !== 'none') return $eff;
            $p = $cur['parent_id'] ?? null;
            $cur = $p !== null ? ($els[$by[(string) $p] ?? -1] ?? null) : null;
            $first = false;
        }
        $pb = (string) ($pageSet['bg_color'] ?? '#ffffff');
        return ['kind' => 'flat', 'lum' => self::lum($pb) ?? 1.0, 'hex' => $pb];
    }

    /** darkest or lightest tone already on the page, for zebra retones */
    private static function pickBand(array $seq, bool $wantLight): string
    {
        $best = null; $bestL = $wantLight ? -1 : 2;
        foreach ($seq as $b) {
            if ($b['kind'] !== 'flat' || $b['lum'] === null) continue;
            if ($wantLight ? $b['lum'] > $bestL : $b['lum'] < $bestL) { $best = $b['hex']; $bestL = $b['lum']; }
        }
        if ($best !== null && ($wantLight ? $bestL >= 0.6 : $bestL <= 0.1)) return $best;
        return $wantLight ? '#ffffff' : '#1d2327';
    }
}
