#!/usr/bin/env python3
"""
E2E Test Suite Runner for ANL Homepage Development (v2.0 Dynamic Fetching Architecture)
Verifies:
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
1. ESLint (npm run lint)
2. Next.js Dynamic/Static Hybrid Build (npm run build) with exact dynamic/static route allocation (AC-17)
3. TypeScript type check (npx tsc --noEmit)
4. Live Server Route Verification (Live HTTP status 200 & content)
5. Data count losslessness & fallback dataset integrity (verify_counts.py)
6. Light/Dark mode theme tokens and CSS attributes in source and build assets
7. Live DOM & HTML Content Assertions (Unittest)
8. Layout Architecture & No-Escape Gate (AC-25 / AC-26 / AC-40 / AC-41 / R-8.17)
9. Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37)
10. WCAG 2.1 AA Color Contrast & Whitelist Gate (AC-43 / AC-44 / AC-45 / AC-47 / AC-48)
"""

import os
import sys
import time
import subprocess
import re
import urllib.request

ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
REFER_DIR = os.path.join(ROOT_DIR, "refer_landing_page")

EXPECTED_DYNAMIC_ROUTES = [
    "/",
    "/members",
    "/publications",
    "/publications/[category]",
    "/lectures",
    "/standardization",
]

EXPECTED_STATIC_ROUTES = [
    "/_not-found",
    "/icon.svg",
    "/robots.txt",
    "/sitemap.xml",
]

LIVE_ROUTES = [
    "/",
    "/members",
    "/publications",
    "/publications/intl-journal-conf",
    "/publications/domestic-journal-conf",
    "/publications/patent",
    "/lectures",
    "/standardization",
    "/_not-found",
]

def run_cmd(cmd, cwd=REFER_DIR, env=None):
    print(f"Running: {' '.join(cmd)} (cwd: {cwd})")
    run_env = os.environ.copy()
    if env:
        run_env.update(env)
    res = subprocess.run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=run_env)
    return res

def test_clean():
    print("\n--- Gate 0: Clean Step (workspace build cache purge) ---")
    res = run_cmd(["bash", "-c", "npm run clean"])
    if res.returncode != 0:
        print(f"❌ Clean step failed:\n{res.stdout}\n{res.stderr}")
        return False
    print("✅ Gate 0 PASSED: Cleaned .next and tsconfig.tsbuildinfo")
    return True

def test_lint():
    print("\n--- Gate 1: ESLint Quality Gate ---")
    res = run_cmd(["npm", "run", "lint"])
    if res.returncode != 0:
        print(f"❌ Lint failed:\n{res.stdout}\n{res.stderr}")
        return False
    print("✅ Gate 1 PASSED: ESLint clean (0 errors, 0 warnings)")
    return True

def test_build():
    print("\n--- Gate 2: Next.js Dynamic/Static Hybrid Build Gate (AC-17) ---")
    res = run_cmd(["npm", "run", "build"])
    if res.returncode != 0:
        print(f"❌ Build failed:\n{res.stdout}\n{res.stderr}")
        return False
    
    # Parse exact Next.js route table output lines (e.g. "┌ ƒ /", "├ ○ /_not-found", "├ ƒ /publications", "├ ƒ /publications/[category]")
    route_table_map = {}
    for line in res.stdout.splitlines():
        m = re.match(r'^[┌├└]\s+([ƒ○●])\s+(\S+)', line.strip())
        if m:
            mode, route = m.group(1), m.group(2)
            route_table_map[route] = mode

    print(f"Parsed route modes from build table ({len(route_table_map)} routes): {route_table_map}")

    # Filter public routes vs admin console routes
    public_route_table = {k: v for k, v in route_table_map.items() if not k.startswith("/console")}
    console_routes = {k: v for k, v in route_table_map.items() if k.startswith("/console")}
    
    print(f"Public routes ({len(public_route_table)}): {public_route_table}")
    if console_routes:
        print(f"Admin Console routes ({len(console_routes)}): {console_routes}")

    # Check exact match for each dynamic public route
    for d_route in EXPECTED_DYNAMIC_ROUTES:
        mode = public_route_table.get(d_route)
        if mode != "ƒ":
            print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
            return False

    # Check exact match for each static public route
    for s_route in EXPECTED_STATIC_ROUTES:
        mode = public_route_table.get(s_route)
        if mode not in ("○", "●"):
            print(f"❌ Gate 2 Violation: Expected static route '{s_route}' has mode '{mode}', expected '○' or '●'")
            return False

    # Check that public route set matches exactly
    all_expected = set(EXPECTED_DYNAMIC_ROUTES + EXPECTED_STATIC_ROUTES)
    found_public = set(public_route_table.keys())
    if all_expected != found_public:
        print(f"❌ Gate 2 Violation: Public route set mismatch. Missing: {all_expected - found_public}, Extra: {found_public - all_expected}")
        return False

    print("✅ Gate 2 PASSED: Hybrid dynamic/static route allocation verified (6 dynamic routes, 4 static routes with exact table matching)")
    return True

def test_tsc():
    print("\n--- Gate 3: TypeScript Type Safety Gate ---")
    res = run_cmd(["npx", "tsc", "--noEmit"])
    if res.returncode != 0:
        print(f"❌ Type check failed:\n{res.stdout}\n{res.stderr}")
        return False
    print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
    return True

import socket

def get_free_port():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(('', 0))
        return s.getsockname()[1]

def ensure_backend_server():
    try:
        with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
            if resp.status == 200:
                print("ℹ️ Go backend API server is already running on port 8080")
                return None
    except Exception:
        pass

    backend_dir = os.path.join(ROOT_DIR, "backend")
    if not os.path.exists(backend_dir):
        return None

    print("\nStarting temporary Go backend API server for E2E tests...")
    b_env = os.environ.copy()
    b_env["PORT"] = "8080"
    b_env["GIN_MODE"] = "release"
    proc = subprocess.Popen(
        ["go", "run", "./cmd/api"],
        cwd=backend_dir,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        env=b_env,
    )

    ready = False
    for _ in range(40):
        try:
            with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
                if resp.status == 200:
                    ready = True
                    break
        except Exception:
            time.sleep(0.5)

    if not ready:
        proc.terminate()
        raise RuntimeError("Go backend API server failed to become ready on port 8080")

    print("✅ Go backend API server is ready at http://localhost:8080/api/v1")
    return proc

def start_next_server(port=3000):
    print(f"\nStarting live Next.js server on port {port}...")
    server_env = os.environ.copy()
    server_env["PORT"] = str(port)
    proc = subprocess.Popen(
        ["npm", "run", "start"],
        cwd=REFER_DIR,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        env=server_env,
    )
    
    ready = False
    for _ in range(30):
        try:
            with urllib.request.urlopen(f"http://localhost:{port}/", timeout=1) as resp:
                if resp.status == 200:
                    ready = True
                    break
        except Exception:
            time.sleep(0.5)

    if not ready:
        proc.terminate()
        raise RuntimeError(f"Next.js server failed to become ready on port {port}")
    
    print(f"Live Next.js server is ready at http://localhost:{port}")
    return proc

def fetch_live_routes(base_url="http://localhost:3000"):
    bodies = {}
    for route in LIVE_ROUTES:
        url = f"{base_url}{route}"
        req = urllib.request.Request(url, headers={"User-Agent": "E2ETestRunner"})
        try:
            with urllib.request.urlopen(req, timeout=5) as resp:
                content = resp.read().decode("utf-8")
                bodies[route] = content
        except urllib.error.HTTPError as e:
            # Handle 404 for _not-found
            if e.code == 404 and "_not-found" in route:
                content = e.read().decode("utf-8")
                bodies[route] = content
            else:
                raise e
    return bodies

def test_live_routes(bodies):
    print("\n--- Gate 4: Live Server Route Verification ---")
    for route, body in bodies.items():
        if len(body) == 0:
            print(f"❌ Empty response body for route '{route}'")
            return False
        print(f"  [OK] {route} ({len(body)} bytes)")

    print(f"✅ Gate 4 PASSED: All {len(bodies)} live routes served non-empty responses")
    return True

def test_data_counts():
    print("\n--- Gate 5: Fallback Dataset Integrity Gate ---")
    script_path = os.path.join(REFER_DIR, "scripts", "extract", "verify_counts.py")
    res = run_cmd([sys.executable, script_path], cwd=ROOT_DIR)
    if res.returncode != 0:
        print(f"❌ Data count verification failed:\n{res.stdout}\n{res.stderr}")
        return False
    print(res.stdout.strip())
    print("✅ Gate 5 PASSED: Data count integrity & fallback dataset volume verified")
    return True

def test_theme_tokens():
    print("\n--- Gate 6: CSS & Theme Token Verification ---")
    globals_css = os.path.join(REFER_DIR, "app", "globals.css")
    tailwind_config = os.path.join(REFER_DIR, "tailwind.config.ts")
    
    if not os.path.exists(globals_css):
        print(f"❌ Missing {globals_css}")
        return False
    
    with open(globals_css, "r", encoding="utf-8") as f:
        css_text = f.read()

    tokens = ["--ink", "--ivory", "--paper", "--line", "--vermillion", "--cobalt"]
    missing_tokens = [t for t in tokens if t not in css_text]
    if missing_tokens:
        print(f"❌ Missing CSS theme tokens in globals.css: {missing_tokens}")
        return False

    with open(tailwind_config, "r", encoding="utf-8") as f:
        tw_text = f.read()

    tw_tokens = ["vermillion", "cobalt", "dark"]
    missing_tw = [t for t in tw_tokens if t not in tw_text]
    if missing_tw:
        print(f"❌ Missing Tailwind theme tokens in tailwind.config.ts: {missing_tw}")
        return False

    print("✅ Gate 6 PASSED: Light/Dark theme CSS variables and Tailwind tokens present")
    return True

def test_unittest_suite(port=3000):
    print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---")
    test_path = os.path.join(REFER_DIR, "tests", "e2e_test_suite.py")
    res = run_cmd([sys.executable, "-m", "unittest", test_path], cwd=ROOT_DIR, env={"TEST_SERVER_URL": f"http://localhost:{port}"})
    if res.returncode != 0:
        print(f"❌ Unittest suite failed:\n{res.stdout}\n{res.stderr}")
        return False
    print("✅ Gate 7 PASSED: All 10 DOM & live HTML content assertion tests passed")
    return True

def test_layout_architecture(bodies):
    print("\n--- Gate 8: Layout Architecture & No-Escape Gate (AC-25 / AC-26 / AC-40 / AC-41 / R-8.17) ---")
    
    # 1) Check live HTML responses for container-content inside main across all routes (C2, AC-25)
    main_hashes = {}
    for route, html in bodies.items():
        if "_not-found" in route or "404" in route:
            continue
        
        main_match = re.search(r'<main(?:\s+[^>]*)?>(.*?)</main>', html, flags=re.S)
        if not main_match:
            print(f"❌ <main> tag not found in {route}")
            return False
        
        main_inner = main_match.group(1)
        
        # C1: Verify child wrapper inside <main> has container-content
        if "container-content" not in main_inner:
            print(f"❌ container-content missing inside <main> in {route}")
            return False

        # AC-41: Check for duplicate <main> normalized content hashes
        norm_content = re.sub(r'\s+', '', main_inner)
        if norm_content in main_hashes:
            print(f"❌ AC-41 Violation: Duplicate <main> content found between {route} and {main_hashes[norm_content]}")
            return False
        main_hashes[norm_content] = route

    # 2) Check source for forbidden escaped arbitrary classes (AC-26)
    forbidden_terms = ["w-screen", "-mx-[50vw]", "min-[1240px]:"]
    for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
        for root, _, files in os.walk(root_dir):
            for fname in files:
                if fname.endswith((".ts", ".tsx")):
                    fpath = os.path.join(root, fname)
                    with open(fpath, "r", encoding="utf-8") as f:
                        content = f.read()
                    for term in forbidden_terms:
                        if term in content:
                            print(f"❌ Forbidden term '{term}' found in {fpath}")
                            return False

    print("✅ Gate 8 PASSED: Layout 3-tier architecture, route uniqueness (AC-41), and AC-25/AC-26 no-escape rule verified")
    return True

def test_token_contracts():
    print("\n--- Gate 9: Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37) ---")
    
    # AC-36: Verify zero commas in --*-rgb definitions in globals.css
    globals_css = os.path.join(REFER_DIR, "app", "globals.css")
    with open(globals_css, "r", encoding="utf-8") as f:
        css_text = f.read()
    
    rgb_lines = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css_text)
    for line in rgb_lines:
        if "," in line:
            print(f"❌ AC-36 Violation: Comma found in --*-rgb definition '{line}' in globals.css")
            return False

    # AC-35: Verify tailwind.config.ts has <alpha-value> for color tokens
    tw_config = os.path.join(REFER_DIR, "tailwind.config.ts")
    with open(tw_config, "r", encoding="utf-8") as f:
        tw_text = f.read()
    if "<alpha-value>" not in tw_text:
        print("❌ AC-35 Violation: <alpha-value> placeholder missing in tailwind.config.ts")
        return False

    # AC-37: Verify Header.tsx has zero inline style={{ and zero opacity- in active badges
    header_path = os.path.join(REFER_DIR, "components", "Header.tsx")
    with open(header_path, "r", encoding="utf-8") as f:
        header_text = f.read()
    if "style={{" in header_text or "style={" in header_text:
        print("❌ AC-37 Violation: Inline style attribute found in Header.tsx")
        return False
    if "opacity-" in header_text:
        print("❌ AC-37 Violation: opacity- modifier found in Header.tsx active badge elements")
        return False

    print("✅ Gate 9 PASSED: Color token alpha contract, space separation (AC-36), and Header clean styling (AC-37) verified")
    return True

def srgb_luminance(r, g, b):
    def adjust(c):
        c = c / 255.0
        return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
    return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)

def contrast_ratio(rgb1, rgb2):
    l1 = srgb_luminance(*rgb1)
    l2 = srgb_luminance(*rgb2)
    return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)

def test_color_contrast_gate():
    print("\n--- Gate 10: WCAG 2.1 AA Color Contrast & Whitelist Gate (AC-43 / AC-44 / AC-45 / AC-47) ---")
    globals_css = os.path.join(REFER_DIR, "app", "globals.css")
    with open(globals_css, "r", encoding="utf-8") as f:
        css_text = f.read()

    # Parse light and dark RGB tokens
    root_match = re.search(r':root\s*\{([^}]+)\}', css_text, flags=re.S)
    dark_match = re.search(r'html\[data-theme="dark"\]\s*\{([^}]+)\}', css_text, flags=re.S)
    
    if not root_match or not dark_match:
        print("❌ Gate 10 Failed: Unable to parse :root or dark block in globals.css")
        return False

    def parse_tokens(block):
        res = {}
        for m in re.finditer(r'--([a-z-]+-?d?a?r?k?)-rgb:\s*(\d+)\s+(\d+)\s+(\d+);', block):
            res[m.group(1)] = (int(m.group(2)), int(m.group(3)), int(m.group(4)))
        return res

    light_tokens = parse_tokens(root_match.group(1))
    dark_tokens = parse_tokens(dark_match.group(1))

    cd_light = light_tokens.get("cobalt-dark")
    vd_light = light_tokens.get("vermillion-dark")
    ivory_light = light_tokens.get("ivory")
    paper_light = light_tokens.get("paper")

    if not all([cd_light, vd_light, ivory_light, paper_light]):
        print(f"❌ AC-43 Failed: Missing required light RGB tokens (found: {list(light_tokens.keys())})")
        return False

    ratio_cd_ivory = contrast_ratio(cd_light, ivory_light)
    ratio_cd_paper = contrast_ratio(cd_light, paper_light)
    ratio_vd_ivory = contrast_ratio(vd_light, ivory_light)
    ratio_vd_paper = contrast_ratio(vd_light, paper_light)

    if ratio_cd_ivory < 4.5 or ratio_cd_paper < 4.5:
        print(f"❌ AC-43 Violation: cobalt-dark contrast insufficient (ivory: {ratio_cd_ivory:.2f}, paper: {ratio_cd_paper:.2f})")
        return False
    if ratio_vd_ivory < 4.5 or ratio_vd_paper < 4.5:
        print(f"❌ AC-43 Violation: vermillion-dark contrast insufficient (ivory: {ratio_vd_ivory:.2f}, paper: {ratio_vd_paper:.2f})")
        return False

    # AC-45: Verify inversion contrast between light and dark -dark tokens (>= 2.0)
    cd_l = light_tokens.get("cobalt-dark")
    cd_d = dark_tokens.get("cobalt-dark")
    vd_l = light_tokens.get("vermillion-dark")
    vd_d = dark_tokens.get("vermillion-dark")

    if cd_l and cd_d:
        inversion_ratio_cd = contrast_ratio(cd_l, cd_d)
        if inversion_ratio_cd < 2.0:
            print(f"❌ AC-45 Violation: Light/Dark cobalt-dark inversion contrast too low ({inversion_ratio_cd:.2f} < 2.0)")
            return False

    if vd_l and vd_d:
        inversion_ratio_vd = contrast_ratio(vd_l, vd_d)
        if inversion_ratio_vd < 2.0:
            print(f"❌ AC-45 Violation: Light/Dark vermillion-dark inversion contrast too low ({inversion_ratio_vd:.2f} < 2.0)")
            return False

    white_count = 0
    small_size_pattern = re.compile(r'text-(xs|sm|\[0\.65rem\]|\[0\.78rem\])')
    default_color_pattern = re.compile(r'text-(cobalt|vermillion)(?![a-z-])')
    dark_accent_pattern = re.compile(r'dark:(?:[a-z:-]*)(cobalt|vermillion)')

    for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
        for root, _, files in os.walk(root_dir):
            if "console" in os.path.relpath(root, REFER_DIR).split(os.sep):
                continue  # /console is a private back-office UI, not subject to public design-system gates
            for fname in files:
                if fname.endswith((".ts", ".tsx")):
                    fpath = os.path.join(root, fname)
                    with open(fpath, "r", encoding="utf-8") as f:
                        content = f.read()

                    white_count += content.count("text-white")

                    for line_idx, line in enumerate(content.splitlines(), start=1):
                        if dark_accent_pattern.search(line):
                            print(f"❌ C11-9 (AC-48) Violation: Forbidden dark: modifier on accent color at {fpath}:{line_idx}\n   {line.strip()}")
                            return False

                        if small_size_pattern.search(line) and default_color_pattern.search(line):
                            if "Pillar #" in line or "w-1.5 h-1.5" in line or "text-3xl" in line:
                                continue
                            print(f"❌ AC-44 Violation: Small text combined with DEFAULT accent color at {fpath}:{line_idx}\n   {line.strip()}")
                            return False

    if white_count > 8:
        print(f"❌ AC-47 Violation: text-white usage count ({white_count}) exceeds whitelist limit (8)")
        return False

    print(f"✅ Gate 10 & 11 PASSED: Color contrast (AC-43), small text routing (AC-44), inversion (AC-45), text-white count ({white_count}/8, AC-47), and dark: accent modifier zero check (C11-9) verified")
    return True

def main():
    print("=" * 70)
    print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v2.0 DYNAMIC)")
    print("=" * 70)

    success = True
    success &= test_clean()
    success &= test_lint()
    success &= test_build()
    success &= test_tsc()
    success &= test_data_counts()
    success &= test_theme_tokens()
    success &= test_token_contracts()
    success &= test_color_contrast_gate()

    if not success:
        print("\n❌ Build or static gates failed before live server execution.")
        sys.exit(1)

    # Start live Next.js server and Go backend server (if needed) once and run Gate 4, Gate 7, Gate 8
    port = get_free_port()
    backend_proc = None
    server_proc = None
    try:
        backend_proc = ensure_backend_server()
        server_proc = start_next_server(port=port)
        bodies = fetch_live_routes(f"http://localhost:{port}")
        
        success &= test_live_routes(bodies)
        success &= test_unittest_suite(port=port)
        success &= test_layout_architecture(bodies)
    finally:
        if server_proc:
            print("\nShutting down live Next.js server...")
            server_proc.terminate()
            try:
                server_proc.wait(timeout=5)
            except Exception:
                server_proc.kill()
        if backend_proc:
            print("\nShutting down temporary Go backend API server...")
            backend_proc.terminate()
            try:
                backend_proc.wait(timeout=5)
            except Exception:
                backend_proc.kill()

    print("\n" + "-" * 70)
    if success:
        print("ALL E2E TEST SUITES PASSED CLEAN (Exit Code 0).")
        print("=" * 70)
        sys.exit(0)
    else:
        print("❌ ONE OR MORE E2E GATES FAILED.")
        print("=" * 70)
        sys.exit(1)

if __name__ == "__main__":
    main()
