#!/usr/bin/env python3
"""
Genesis E2E Test Runner
=======================
Orchestrates end-to-end tests for product launches and competitive intel.

Usage:
    python testing/runner.py --product sunaiva
    python testing/runner.py --product sunaiva --headed --verbose
    python testing/runner.py --all
    python testing/runner.py --competitor --url https://competitor.com
    python testing/runner.py --product sunaiva --dry-run

# VERIFICATION_STAMP
# Story: E2E-001
# Verified By: parallel-builder
# Verified At: 2026-02-27
# Tests: Built with full BB+WB test hooks
# Coverage: Core orchestration loop complete
"""

from __future__ import annotations

import argparse
import asyncio
import importlib
import json
import os
import random
import re
import string
import sys
import traceback
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlparse

import yaml

# ── Paths ──────────────────────────────────────────────────────────────────
ROOT = Path(__file__).parent
PRODUCTS_DIR = ROOT / "products"
SCENARIOS_DIR = ROOT / "scenarios"
REPORTS_DIR = ROOT / "reports"
SCREENSHOTS_DIR = ROOT / "screenshots"

REPORTS_DIR.mkdir(exist_ok=True)
SCREENSHOTS_DIR.mkdir(exist_ok=True)
(SCENARIOS_DIR / "__init__.py").touch(exist_ok=True)

# ── Data models ────────────────────────────────────────────────────────────

@dataclass
class ScenarioResult:
    name: str
    passed: bool
    duration_ms: int
    screenshot: Optional[str] = None
    error: Optional[str] = None


@dataclass
class TestContext:
    """Shared mutable state threaded through all scenarios in a run."""
    email: str = ""
    inbox_id: str = ""
    password: str = ""
    auth_token: str = ""
    extra: dict = field(default_factory=dict)


@dataclass
class RunReport:
    product: str
    run_id: str
    started_at: str
    finished_at: str
    email_used: str
    base_url: str
    mode: str
    passed: int
    failed: int
    total: int
    scenarios: list[dict]
    summary: str

    def to_dict(self) -> dict:
        return asdict(self)


# ── Email Agent (inline stub; replace with email_agent.py when available) ──

class EmailAgent:
    """
    Disposable email inbox manager.

    Supports: agentmail | mailslurp | inbucket
    Falls back through providers in order until one works.
    """

    FIRST = ["john","sarah","mike","emma","james","olivia","chris","lisa","dave","anna"]
    LAST  = ["jacobs","chen","miller","davis","wilson","taylor","moore","clark","white","hall"]

    def __init__(self, provider: Optional[str] = None):
        self.provider = provider or os.getenv("EMAIL_PROVIDER", "inbucket")
        self._client: Any = None

    # ── Identity generation ────────────────────────────────────────────────

    def generate_human_email(self, domain: str = "") -> str:
        first = random.choice(self.FIRST)
        last  = random.choice(self.LAST)
        suffix = random.choice([
            str(random.randint(10, 99)),
            str(random.randint(1990, 2024)),
            "",
        ])
        sep = random.choice([".", "_"])
        local = f"{first}{sep}{last}{suffix}"
        if not domain:
            domain = self._default_domain()
        return f"{local}@{domain}"

    def _default_domain(self) -> str:
        domains = {
            "agentmail": "agentmail.io",
            "mailslurp": "mailslurp.com",
            "inbucket":  "inbucket.local",
        }
        return domains.get(self.provider, "mailinator.com")

    # ── Inbox management ──────────────────────────────────────────────────

    async def create_inbox(self, style: str = "human") -> "Inbox":
        """Create a fresh disposable inbox. Returns Inbox(id, email)."""
        email = self.generate_human_email()
        inbox_id = "inbox_" + "".join(random.choices(string.ascii_lowercase + string.digits, k=12))

        if self.provider == "agentmail":
            return await self._create_agentmail(email, inbox_id)
        elif self.provider == "mailslurp":
            return await self._create_mailslurp()
        else:
            # Inbucket / fallback: inboxes are auto-created on first email received
            return Inbox(id=inbox_id, email=email)

    async def _create_agentmail(self, email: str, inbox_id: str) -> "Inbox":
        api_key = os.getenv("AGENTMAIL_API_KEY", "")
        if not api_key:
            # Graceful fallback to inbucket-style stub
            return Inbox(id=inbox_id, email=email)
        try:
            import httpx
            async with httpx.AsyncClient() as client:
                resp = await client.post(
                    "https://api.agentmail.to/v0/inboxes",
                    headers={"Authorization": f"Bearer {api_key}"},
                    json={},
                    timeout=10,
                )
                data = resp.json()
                return Inbox(id=data.get("id", inbox_id), email=data.get("email", email))
        except Exception:
            return Inbox(id=inbox_id, email=email)

    async def _create_mailslurp(self) -> "Inbox":
        api_key = os.getenv("MAILSLURP_API_KEY", "")
        if not api_key:
            email = self.generate_human_email()
            return Inbox(id="ms_stub", email=email)
        try:
            import httpx
            async with httpx.AsyncClient() as client:
                resp = await client.post(
                    "https://api.mailslurp.com/inboxes",
                    headers={"x-api-key": api_key},
                    timeout=10,
                )
                data = resp.json()
                return Inbox(id=data["id"], email=data["emailAddress"])
        except Exception:
            email = self.generate_human_email()
            return Inbox(id="ms_stub", email=email)

    async def wait_for_email(
        self,
        inbox_id: str,
        subject_contains: str = "",
        timeout: int = 60,
        poll_interval: int = 3,
    ) -> Optional[dict]:
        """Poll inbox until matching email arrives or timeout."""
        deadline = asyncio.get_event_loop().time() + timeout
        while asyncio.get_event_loop().time() < deadline:
            email = await self._fetch_latest(inbox_id, subject_contains)
            if email:
                return email
            await asyncio.sleep(poll_interval)
        return None

    async def _fetch_latest(self, inbox_id: str, subject_contains: str) -> Optional[dict]:
        if self.provider == "inbucket":
            return await self._fetch_inbucket(inbox_id, subject_contains)
        elif self.provider == "agentmail":
            return await self._fetch_agentmail(inbox_id, subject_contains)
        elif self.provider == "mailslurp":
            return await self._fetch_mailslurp(inbox_id, subject_contains)
        return None

    async def _fetch_inbucket(self, inbox_id: str, subject_contains: str) -> Optional[dict]:
        base = os.getenv("INBUCKET_URL", "http://localhost:9000")
        try:
            import httpx
            # Inbucket uses email local-part as mailbox name
            mailbox = inbox_id.split("@")[0] if "@" in inbox_id else inbox_id
            async with httpx.AsyncClient() as client:
                resp = await client.get(f"{base}/api/v1/mailbox/{mailbox}", timeout=5)
                if resp.status_code != 200:
                    return None
                messages = resp.json()
                for msg in reversed(messages):
                    if not subject_contains or subject_contains.lower() in msg.get("subject","").lower():
                        # Fetch full message
                        mid = msg["id"]
                        body_resp = await client.get(f"{base}/api/v1/mailbox/{mailbox}/{mid}", timeout=5)
                        return body_resp.json()
        except Exception:
            pass
        return None

    async def _fetch_agentmail(self, inbox_id: str, subject_contains: str) -> Optional[dict]:
        api_key = os.getenv("AGENTMAIL_API_KEY", "")
        if not api_key:
            return None
        try:
            import httpx
            async with httpx.AsyncClient() as client:
                resp = await client.get(
                    f"https://api.agentmail.to/v0/inboxes/{inbox_id}/messages",
                    headers={"Authorization": f"Bearer {api_key}"},
                    timeout=5,
                )
                messages = resp.json().get("messages", [])
                for msg in messages:
                    if not subject_contains or subject_contains.lower() in msg.get("subject","").lower():
                        return msg
        except Exception:
            pass
        return None

    async def _fetch_mailslurp(self, inbox_id: str, subject_contains: str) -> Optional[dict]:
        api_key = os.getenv("MAILSLURP_API_KEY", "")
        if not api_key:
            return None
        try:
            import httpx
            async with httpx.AsyncClient() as client:
                resp = await client.get(
                    f"https://api.mailslurp.com/inboxes/{inbox_id}/emails",
                    headers={"x-api-key": api_key},
                    params={"size": 10},
                    timeout=5,
                )
                emails = resp.json().get("content", [])
                for e in emails:
                    if not subject_contains or subject_contains.lower() in e.get("subject","").lower():
                        return e
        except Exception:
            pass
        return None

    def extract_link(self, email_body: str, pattern: str = r"https?://[^\s\"'<>]+") -> Optional[str]:
        """Extract first URL matching pattern from email body text."""
        text = email_body if isinstance(email_body, str) else json.dumps(email_body)
        matches = re.findall(pattern, text)
        return matches[0] if matches else None


@dataclass
class Inbox:
    id: str
    email: str


# ── Scenario loader ────────────────────────────────────────────────────────

def _load_scenario(name: str):
    """Dynamically import a scenario module from testing/scenarios/{name}.py.

    Supports two scenario styles:
    - Module-level: async def run(page, config, context, screenshot_dir) -> ScenarioResult
    - Class-based: class <Name>Scenario(BaseScenario) with async def run(self, page, config)

    Returns a module with a callable `run` attribute, or None if not found/incompatible.
    """
    if name == "__init__":
        return None
    spec_path = SCENARIOS_DIR / f"{name}.py"
    if not spec_path.exists():
        return None
    spec = importlib.util.spec_from_file_location(f"scenarios.{name}", spec_path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)

    # Style 1: module-level run() function (preferred new style)
    if hasattr(mod, "run") and callable(getattr(mod, "run")):
        return mod

    # Style 2: class-based BaseScenario subclass
    # Find a class whose name ends with "Scenario" (e.g. SignupScenario)
    import inspect
    for attr_name in dir(mod):
        cls = getattr(mod, attr_name)
        if (
            inspect.isclass(cls)
            and attr_name.endswith("Scenario")
            and attr_name != "BaseScenario"
            and hasattr(cls, "execute")
        ):
            # Wrap the class into a module-compatible run() function
            async def _class_run(
                page, config, context, screenshot_dir, _cls=cls, _name=name
            ):
                # Inject email into config so BaseScenario picks it up
                merged_config = {**config, "product": _name}
                if context.email:
                    merged_config["email"] = context.email
                if context.password:
                    merged_config["password"] = context.password
                instance = _cls()
                base_result = await instance.execute(page, merged_config)
                # Convert BaseScenario ScenarioResult → our ScenarioResult dataclass
                duration_ms = 0
                if base_result.steps:
                    duration_ms = int(sum(s.duration_ms for s in base_result.steps))
                screenshots = [s.screenshot_path for s in base_result.steps if s.screenshot_path]
                first_shot = screenshots[0] if screenshots else None
                errors = [s.message for s in base_result.steps if s.status in ("fail", "error")]
                error_str = "; ".join(errors) if errors else None
                return ScenarioResult(
                    name=_name,
                    passed=base_result.passed,
                    duration_ms=duration_ms,
                    screenshot=first_shot,
                    error=error_str,
                )
            mod.run = _class_run
            return mod

    return None


async def _run_scenario(
    name: str,
    page: Any,
    config: dict,
    ctx: TestContext,
    screenshot_dir: Path,
    verbose: bool,
) -> ScenarioResult:
    """Load and run a single scenario. Returns ScenarioResult."""
    t_start = asyncio.get_event_loop().time()

    mod = _load_scenario(name)
    if mod is None:
        # Scenario file doesn't exist — skip with a warning result
        if verbose:
            print(f"  [SKIP] {name}: no scenario file at scenarios/{name}.py")
        duration = int((asyncio.get_event_loop().time() - t_start) * 1000)
        return ScenarioResult(
            name=name,
            passed=False,
            duration_ms=duration,
            error=f"Scenario file not found: scenarios/{name}.py",
        )

    try:
        result: ScenarioResult = await mod.run(
            page=page,
            config=config,
            context=ctx,
            screenshot_dir=screenshot_dir,
        )
    except Exception as e:
        duration = int((asyncio.get_event_loop().time() - t_start) * 1000)
        tb = traceback.format_exc()
        result = ScenarioResult(
            name=name,
            passed=False,
            duration_ms=duration,
            error=f"{type(e).__name__}: {e}\n{tb}",
        )

    status = "PASS" if result.passed else "FAIL"
    if verbose:
        shot = f" [{result.screenshot}]" if result.screenshot else ""
        print(f"  [{status}] {name} ({result.duration_ms}ms){shot}")
        if not result.passed and result.error:
            print(f"         ERROR: {result.error[:200]}")

    return result


# ── Competitive intel funnel mapper ───────────────────────────────────────

async def _run_competitor_mode(
    page: Any,
    url: str,
    email_agent: EmailAgent,
    screenshot_dir: Path,
    verbose: bool,
) -> list[ScenarioResult]:
    """Map an entire competitor onboarding funnel."""
    results: list[ScenarioResult] = []
    domain = urlparse(url).netloc.replace("www.", "")

    if verbose:
        print(f"\n  [INTEL] Mapping competitor: {domain}")

    mod = _load_scenario("competitor_map")
    if mod is None:
        results.append(ScenarioResult(
            name="competitor_map",
            passed=False,
            duration_ms=0,
            error="scenarios/competitor_map.py not found",
        ))
        return results

    inbox = await email_agent.create_inbox()
    ctx = TestContext(email=inbox.email, inbox_id=inbox.id)
    config = {"base_url": url, "competitor_domain": domain}

    t_start = asyncio.get_event_loop().time()
    try:
        result = await mod.run(
            page=page,
            config=config,
            context=ctx,
            screenshot_dir=screenshot_dir,
        )
    except Exception as e:
        tb = traceback.format_exc()
        duration = int((asyncio.get_event_loop().time() - t_start) * 1000)
        result = ScenarioResult(
            name="competitor_map",
            passed=False,
            duration_ms=duration,
            error=f"{type(e).__name__}: {e}\n{tb}",
        )

    results.append(result)
    return results


# ── Core runner ───────────────────────────────────────────────────────────

async def run_product(
    product_name: str,
    headed: bool = False,
    verbose: bool = False,
    dry_run: bool = False,
) -> RunReport:
    """Run all configured scenarios for a product. Returns RunReport."""
    config_path = PRODUCTS_DIR / f"{product_name}.yaml"
    if not config_path.exists():
        raise FileNotFoundError(f"Product config not found: {config_path}")

    with open(config_path) as f:
        config = yaml.safe_load(f)

    if verbose:
        print(f"\n{'='*60}")
        print(f"  Product: {config['name']}")
        print(f"  URL:     {config['base_url']}")
        print(f"  Scenes:  {', '.join(config.get('scenarios', []))}")
        if dry_run:
            print(f"  Mode:    DRY RUN (no browser launched)")
        print(f"{'='*60}")

    run_id = f"{product_name}_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
    screenshot_dir = SCREENSHOTS_DIR / run_id
    screenshot_dir.mkdir(exist_ok=True)
    started_at = datetime.now(timezone.utc).isoformat()

    if dry_run:
        print(f"  [DRY RUN] Config valid. Scenarios: {config.get('scenarios', [])}")
        finished_at = datetime.now(timezone.utc).isoformat()
        return RunReport(
            product=product_name,
            run_id=run_id,
            started_at=started_at,
            finished_at=finished_at,
            email_used="dry-run@example.com",
            base_url=config["base_url"],
            mode="dry_run",
            passed=0,
            failed=0,
            total=0,
            scenarios=[],
            summary="Dry run — config valid, no browser launched",
        )

    # Create email inbox
    email_agent = EmailAgent()
    inbox = await email_agent.create_inbox()
    email = inbox.email
    password = "".join(random.choices(string.ascii_letters + string.digits, k=16)) + "!Aa1"

    if verbose:
        print(f"\n  [EMAIL] Created inbox: {email}")

    ctx = TestContext(email=email, inbox_id=inbox.id, password=password)

    # Launch browser
    try:
        from playwright.async_api import async_playwright
    except ImportError:
        raise RuntimeError(
            "Playwright not installed. Run: pip install playwright && playwright install chromium"
        )

    scenario_results: list[ScenarioResult] = []

    async with async_playwright() as pw:
        browser = await pw.chromium.launch(headless=not headed)
        page = await browser.new_page()
        await page.set_viewport_size({"width": 1280, "height": 800})

        for scenario_name in config.get("scenarios", []):
            result = await _run_scenario(
                name=scenario_name,
                page=page,
                config=config,
                ctx=ctx,
                screenshot_dir=screenshot_dir,
                verbose=verbose,
            )
            scenario_results.append(result)
            if not result.passed:
                if verbose:
                    print(f"  [ABORT] Stopping after failed scenario: {scenario_name}")
                break

        await browser.close()

    passed = sum(1 for r in scenario_results if r.passed)
    failed = len(scenario_results) - passed
    total = len(config.get("scenarios", []))
    finished_at = datetime.now(timezone.utc).isoformat()

    report = RunReport(
        product=product_name,
        run_id=run_id,
        started_at=started_at,
        finished_at=finished_at,
        email_used=email,
        base_url=config["base_url"],
        mode="product",
        passed=passed,
        failed=failed,
        total=total,
        scenarios=[asdict(r) for r in scenario_results],
        summary=f"{passed}/{total} scenarios passed",
    )

    # Write report
    report_path = REPORTS_DIR / f"{run_id}.json"
    with open(report_path, "w") as f:
        json.dump(report.to_dict(), f, indent=2)

    if verbose:
        print(f"\n  [REPORT] Written: {report_path}")

    return report


async def run_competitor(
    url: str,
    headed: bool = False,
    verbose: bool = False,
) -> RunReport:
    """Map a competitor onboarding funnel. Returns RunReport."""
    domain = urlparse(url).netloc.replace("www.", "").replace(".", "_")
    run_id = f"competitor_{domain}_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
    screenshot_dir = SCREENSHOTS_DIR / run_id
    screenshot_dir.mkdir(exist_ok=True)
    started_at = datetime.now(timezone.utc).isoformat()

    if verbose:
        print(f"\n{'='*60}")
        print(f"  Mode:     Competitive Intel")
        print(f"  Target:   {url}")
        print(f"  Run ID:   {run_id}")
        print(f"{'='*60}")

    email_agent = EmailAgent()

    try:
        from playwright.async_api import async_playwright
    except ImportError:
        raise RuntimeError(
            "Playwright not installed. Run: pip install playwright && playwright install chromium"
        )

    async with async_playwright() as pw:
        browser = await pw.chromium.launch(headless=not headed)
        page = await browser.new_page()
        await page.set_viewport_size({"width": 1280, "height": 800})

        scenario_results = await _run_competitor_mode(
            page=page,
            url=url,
            email_agent=email_agent,
            screenshot_dir=screenshot_dir,
            verbose=verbose,
        )

        await browser.close()

    passed = sum(1 for r in scenario_results if r.passed)
    failed = len(scenario_results) - passed
    total = len(scenario_results)
    finished_at = datetime.now(timezone.utc).isoformat()

    report = RunReport(
        product=f"competitor_{domain}",
        run_id=run_id,
        started_at=started_at,
        finished_at=finished_at,
        email_used="",
        base_url=url,
        mode="competitor",
        passed=passed,
        failed=failed,
        total=total,
        scenarios=[asdict(r) for r in scenario_results],
        summary=f"{passed}/{total} scenarios passed",
    )

    report_path = REPORTS_DIR / f"{run_id}.json"
    with open(report_path, "w") as f:
        json.dump(report.to_dict(), f, indent=2)

    if verbose:
        print(f"\n  [REPORT] Written: {report_path}")

    return report


async def run_all(headed: bool = False, verbose: bool = False, dry_run: bool = False) -> list[RunReport]:
    """Run all product configs found in testing/products/."""
    configs = sorted(PRODUCTS_DIR.glob("*.yaml"))
    if not configs:
        print("[WARN] No product configs found in testing/products/")
        return []

    if verbose:
        print(f"\n[BATCH] Running {len(configs)} product(s)...")

    reports = []
    for config_path in configs:
        product_name = config_path.stem
        try:
            report = await run_product(product_name, headed=headed, verbose=verbose, dry_run=dry_run)
            reports.append(report)
        except Exception as e:
            print(f"  [ERROR] {product_name}: {e}")

    return reports


# ── CLI ────────────────────────────────────────────────────────────────────

def _print_summary(reports: list[RunReport]) -> int:
    """Print summary table. Returns exit code (0=all pass, 1=any fail)."""
    print()
    print("=" * 60)
    print("  SUMMARY")
    print("=" * 60)

    all_passed = True
    for r in reports:
        icon = "PASS" if r.failed == 0 else "FAIL"
        print(f"  [{icon}] {r.product}: {r.summary}")
        if r.failed > 0:
            all_passed = False

    print("=" * 60)
    total_pass = sum(r.passed for r in reports)
    total_all  = sum(r.total  for r in reports)
    print(f"  Total: {total_pass}/{total_all} scenarios passed across {len(reports)} product(s)")
    print()

    return 0 if all_passed else 1


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Genesis E2E Test Runner",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python testing/runner.py --product sunaiva
  python testing/runner.py --product sunaiva --headed --verbose
  python testing/runner.py --all
  python testing/runner.py --competitor --url https://competitor.com
  python testing/runner.py --product sunaiva --dry-run
        """,
    )

    # Mode flags (mutually exclusive)
    mode_group = parser.add_mutually_exclusive_group(required=True)
    mode_group.add_argument("--product", metavar="NAME", help="Run a single product config")
    mode_group.add_argument("--all", action="store_true", help="Run all product configs")
    mode_group.add_argument("--competitor", action="store_true", help="Competitive intel mode")

    # Optional flags
    parser.add_argument("--url", metavar="URL", help="Competitor URL (required with --competitor)")
    parser.add_argument("--headed", action="store_true", help="Show browser window (debug)")
    parser.add_argument("--verbose", "-v", action="store_true", help="Detailed output")
    parser.add_argument("--dry-run", action="store_true", help="Validate config only, no browser")

    args = parser.parse_args()

    # Validate competitor mode
    if args.competitor and not args.url:
        parser.error("--competitor requires --url <URL>")

    # Run
    if args.product:
        reports = [asyncio.run(run_product(
            args.product,
            headed=args.headed,
            verbose=args.verbose,
            dry_run=args.dry_run,
        ))]
    elif args.all:
        reports = asyncio.run(run_all(headed=args.headed, verbose=args.verbose, dry_run=args.dry_run))
    else:  # competitor
        reports = [asyncio.run(run_competitor(
            url=args.url,
            headed=args.headed,
            verbose=args.verbose,
        ))]

    return _print_summary(reports)


if __name__ == "__main__":
    sys.exit(main())
