#!/usr/bin/env python3
"""
TipSharks End-to-End Smoke Test

Verifies the full TipSharks stack works together:
  - Starts all services with docker compose
  - Checks health of each service and infrastructure
  - Validates data flows: ingestion -> ratings -> client backend

Usage:
    # Run smoke test (services left running on success)
    python scripts/e2e-smoke-test.py

    # Run and clean up (docker compose down -v) after test
    python scripts/e2e-smoke-test.py --cleanup

    # Skip docker compose up (assume services already running)
    python scripts/e2e-smoke-test.py --skip-up

Exit code: 0 on success, 1 on failure
"""

import json
import subprocess
import sys
import time
import urllib.error
import urllib.request
from argparse import ArgumentParser, Namespace
from pathlib import Path
from typing import Any

# ── Configuration ──────────────────────────────────────────────────────────────

ROOT_DIR = Path(__file__).resolve().parent.parent
COMPOSE_FILE = ROOT_DIR / "docker-compose.yml"

# All services and their health-check endpoints
SERVICES: dict[str, dict[str, Any]] = {
    "tab-api-ingest": {
        "url": "http://localhost:9090/health",
        "expected_keys": ["status"],
        "display_name": "tab-api-ingest (Express)",
    },
    "tipsharks-elo-api": {
        "url": "http://localhost:8000/health",
        "expected_keys": ["status", "version"],
        "display_name": "tipsharks-elo-api (FastAPI)",
    },
    "tipsharks-client-backend": {
        "url": "http://localhost:8001/api/health",
        "expected_keys": ["status"],
        "display_name": "tipsharks-client-backend (FastAPI)",
    },
}

# Infrastructure services to check via docker inspect
INFRA_SERVICES = {
    "postgres-ingest": {"container": "tipsharks-postgres-ingest"},
    "postgres-elo": {"container": "tipsharks-postgres-elo"},
    "redis": {"container": "tipsharks-redis"},
    "mongo": {"container": "tipsharks-mongo"},
}

# Data-flow endpoints to verify after health checks
DATA_FLOW_ENDPOINTS: list[dict[str, Any]] = [
    {
        "name": "tab-api-ingest /api/meetings",
        "url": "http://localhost:9090/api/meetings",
        "expected_structure": "list_or_object",
        "accept_empty": True,
    },
    {
        "name": "tab-api-ingest /api/races",
        "url": "http://localhost:9090/api/races",
        "expected_structure": "list_or_object",
        "accept_empty": True,
    },
    {
        "name": "tipsharks-elo-api /v1/races",
        "url": "http://localhost:8000/v1/races",
        "expected_structure": "object_with_keys",
        "expected_keys": ["races"],
        "accept_empty": True,
    },
    {
        "name": "tipsharks-client-backend /api/races",
        "url": "http://localhost:8001/api/races",
        "expected_structure": "list",
        "accept_empty": True,
    },
]

STARTUP_TIMEOUT = 120  # seconds to wait for all containers to become healthy
RETRY_DELAY = 5  # seconds between retries

# ── Helpers ─────────────────────────────────────────────────────────────────────


def log_pass(message: str) -> None:
    print(f"  ✅  PASS: {message}")


def log_fail(message: str) -> None:
    print(f"  ❌  FAIL: {message}")


def log_info(message: str) -> None:
    print(f"  ℹ️   {message}")


def log_section(title: str) -> None:
    width = 72
    print()
    print("=" * width)
    print(f"  {title}")
    print("=" * width)


def run_cmd(
    cmd: list[str], description: str = "", timeout: int = 60
) -> subprocess.CompletedProcess:
    """Run a shell command and return the result."""
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
        )
        return result
    except subprocess.TimeoutExpired:
        print(f"  ⚠️   Command timed out after {timeout}s: {' '.join(cmd)}")
        result = subprocess.CompletedProcess(
            cmd, returncode=-1, stdout="", stderr="Timed out"
        )
        return result
    except FileNotFoundError:
        print(f"  ⚠️   Command not found: {cmd[0]}")
        result = subprocess.CompletedProcess(
            cmd, returncode=-1, stdout="", stderr="Not found"
        )
        return result


def http_get(
    url: str, timeout: int = 10
) -> tuple[int, dict[str, Any] | list[Any] | str | None]:
    """Perform a GET request and return (status_code, parsed_json_or_text)."""
    req = urllib.request.Request(url, method="GET")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            body = resp.read().decode("utf-8")
            try:
                data = json.loads(body)
            except json.JSONDecodeError:
                data = body
            return resp.status, data
    except urllib.error.HTTPError as e:
        try:
            body = e.read().decode("utf-8")
            try:
                data = json.loads(body)
            except json.JSONDecodeError:
                data = body
        except Exception:
            data = None
        return e.code, data
    except (urllib.error.URLError, OSError) as e:
        return 0, {"_error": str(e)}


def check_docker_installed() -> bool:
    """Check that docker and docker compose are available."""
    docker_ok = run_cmd(["docker", "--version"]).returncode == 0
    # Try `docker compose` (v2) first, then `docker-compose` (v1)
    compose_v2 = run_cmd(["docker", "compose", "version"]).returncode == 0
    compose_v1 = run_cmd(["docker-compose", "--version"]).returncode == 0
    compose_ok = compose_v2 or compose_v1
    return docker_ok and compose_ok


def docker_compose_cmd() -> list[str]:
    """Return the appropriate docker compose command prefix."""
    if run_cmd(["docker", "compose", "version"]).returncode == 0:
        return ["docker", "compose"]
    return ["docker-compose"]


def check_compose_file() -> bool:
    """Verify the docker-compose.yml exists."""
    return COMPOSE_FILE.exists()


def compose_up() -> bool:
    """Run docker compose up -d and return success."""
    cmd = docker_compose_cmd() + ["up", "-d"]
    print(f"  🚀  Starting services with: {' '.join(cmd)}")
    result = run_cmd(cmd, timeout=120)
    if result.returncode != 0:
        print(f"      stderr: {result.stderr.strip()}")
        return False
    return True


def compose_down(volumes: bool = False) -> bool:
    """Run docker compose down (optionally -v) and return success."""
    cmd = docker_compose_cmd() + ["down"]
    if volumes:
        cmd.append("-v")
    print(f"  🧹  Running: {' '.join(cmd)}")
    result = run_cmd(cmd, timeout=60)
    return result.returncode == 0


def wait_for_health(url: str, timeout: int) -> bool:
    """Poll a health endpoint until it returns 200 or timeout expires."""
    deadline = time.monotonic() + timeout
    attempts = 0
    while time.monotonic() < deadline:
        attempts += 1
        status, data = http_get(url, timeout=5)
        if status == 200:
            return True
        if attempts == 1:
            print(f"      (attempt {attempts}: status={status})", end="")
        else:
            print(f", {attempts}:{status}", end="")
        time.sleep(RETRY_DELAY)
    print()  # newline after trailing dots
    return False


def check_container_healthy(container_name: str) -> bool:
    """Check if a docker container is running and healthy."""
    result = run_cmd(
        ["docker", "inspect", "--format", "{{.State.Status}}", container_name],
        timeout=10,
    )
    if result.returncode != 0:
        return False
    status = result.stdout.strip()
    if status != "running":
        log_info(
            f"Container '{container_name}' status is '{status}' (expected 'running')"
        )
        return False

    # Try health check if available
    health_result = run_cmd(
        [
            "docker",
            "inspect",
            "--format",
            "{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
            container_name,
        ],
        timeout=10,
    )
    if health_result.returncode == 0:
        health_status = health_result.stdout.strip()
        if health_status == "none":
            # No health check defined, assume alive since status is 'running'
            return True
        if health_status != "healthy":
            log_info(f"Container '{container_name}' health is '{health_status}'")
            return False
    return True


def wait_for_containers(timeout: int) -> dict[str, bool]:
    """Wait for infrastructure containers to report healthy."""
    results: dict[str, bool] = {}
    deadline = time.monotonic() + timeout

    for svc_name in INFRA_SERVICES:
        container = INFRA_SERVICES[svc_name]["container"]
        print(f"  ⏳  Waiting for {svc_name} ({container})...", end="", flush=True)
        attempts = 0
        while time.monotonic() < deadline:
            attempts += 1
            if check_container_healthy(container):
                print(f" healthy (attempt {attempts})")
                results[svc_name] = True
                break
            if attempts == 1:
                print(".", end="", flush=True)
            else:
                print(".", end="", flush=True)
            time.sleep(RETRY_DELAY)
        else:
            print(" TIMEOUT")
            results[svc_name] = False
    return results


def verify_json_response(
    name: str,
    url: str,
    expected_structure: str,
    expected_keys: list[str] | None = None,
    accept_empty: bool = True,
) -> bool:
    """Fetch a URL and verify the response is valid JSON with expected shape."""
    print(f"  🔍  Checking: {name}")
    status, data = http_get(url, timeout=15)
    if status == 0:
        log_fail(f"Connection refused or error: {data}")
        return False
    if status >= 400:
        log_fail(f"HTTP {status}: {url}")
        return False

    # Must be valid JSON
    if not isinstance(data, (dict, list)):
        log_fail(f"Response is not JSON (type={type(data).__name__})")
        return False

    # Structure checks
    if expected_structure == "list":
        if not isinstance(data, list):
            log_fail(f"Expected list, got {type(data).__name__}")
            return False
        if not accept_empty and len(data) == 0:
            log_fail("Response is an empty list")
            return False
    elif expected_structure == "object_with_keys":
        if not isinstance(data, dict):
            log_fail(f"Expected dict, got {type(data).__name__}")
            return False
        if expected_keys:
            for key in expected_keys:
                if key not in data:
                    log_fail(f"Missing expected key '{key}'")
                    return False
    elif expected_structure == "list_or_object":
        # Accept either list or object; for objects check expected_keys if provided
        if isinstance(data, dict) and expected_keys:
            for key in expected_keys:
                if key not in data:
                    log_fail(f"Missing expected key '{key}'")
                    return False
        elif not isinstance(data, (dict, list)):
            log_fail(f"Expected dict or list, got {type(data).__name__}")
            return False

    log_pass(f"Valid JSON response from {url} ({status})")
    return True


# ── Main logic ──────────────────────────────────────────────────────────────────


def parse_args() -> Namespace:
    parser = ArgumentParser(
        description="TipSharks End-to-End Smoke Test",
    )
    parser.add_argument(
        "--cleanup",
        action="store_true",
        help="Run docker compose down -v after tests",
    )
    parser.add_argument(
        "--skip-up",
        action="store_true",
        help="Skip docker compose up (assume services already running)",
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=STARTUP_TIMEOUT,
        help=f"Timeout in seconds for service startup (default: {STARTUP_TIMEOUT})",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    results: dict[str, bool] = {}
    passed = 0
    failed = 0

    print()
    print("╔══════════════════════════════════════════════════════════════════╗")
    print("║           TipSharks End-to-End Smoke Test                       ║")
    print("╚══════════════════════════════════════════════════════════════════╝")

    # ── Step 1: Prerequisites ──────────────────────────────────────────────

    log_section("Step 1: Prerequisites")

    print("  🔧  Checking Docker installation...")
    prereq_ok = True
    if not check_docker_installed():
        log_fail("Docker or docker compose not found. Please install Docker.")
        prereq_ok = False
    else:
        log_pass("Docker and docker compose are installed")

    print("  📄  Checking docker-compose.yml...")
    if not check_compose_file():
        log_fail(f"docker-compose.yml not found at {COMPOSE_FILE}")
        prereq_ok = False
    else:
        log_pass(f"docker-compose.yml found at {COMPOSE_FILE}")

    if not prereq_ok:
        print()
        print("Prerequisites check failed. Aborting.")
        return 1

    # ── Step 2: Start infrastructure ────────────────────────────────────────

    if not args.skip_up:
        log_section("Step 2: Start Infrastructure")

        if not compose_up():
            log_fail("docker compose up failed")
            return 1
        log_pass("docker compose up completed")

        log_section("Step 3: Wait for containers")
        infra_results = wait_for_containers(args.timeout)
        for svc_name, healthy in infra_results.items():
            results[f"infra:{svc_name}"] = healthy
            if healthy:
                passed += 1
            else:
                failed += 1
    else:
        log_section("Step 2 & 3: Skipped (--skip-up)")
        log_info("Assuming services are already running")
        # Still need infra container status
        for svc_name in INFRA_SERVICES:
            container = INFRA_SERVICES[svc_name]["container"]
            healthy = check_container_healthy(container)
            results[f"infra:{svc_name}"] = healthy

    # ── Step 4: Health checks (application services) ─────────────────────

    log_section("Step 4: Application Health Checks")

    for svc_name, svc_info in SERVICES.items():
        url = svc_info["url"]
        display = svc_info["display_name"]
        print(f"  🩺  Checking {display}...")
        healthy = wait_for_health(url, timeout=args.timeout if not args.skip_up else 30)
        if healthy:
            # Verify response content
            status, data = http_get(url, timeout=10)
            if isinstance(data, dict):
                expected = svc_info["expected_keys"]
                missing = [k for k in expected if k not in data]
                if missing:
                    log_fail(f"{display}: missing keys {missing}")
                    healthy = False
                else:
                    log_pass(f"{display} is healthy (keys: {expected})")
            else:
                log_pass(f"{display} responded with status {status}")
        else:
            log_fail(f"{display} health check failed (timeout)")

        results[f"health:{svc_name}"] = healthy
        if healthy:
            passed += 1
        else:
            failed += 1

    # ── Step 5: Data flow verification ────────────────────────────────────

    log_section("Step 5: Data Flow Verification")

    for ep in DATA_FLOW_ENDPOINTS:
        ok = verify_json_response(
            name=ep["name"],
            url=ep["url"],
            expected_structure=ep["expected_structure"],
            expected_keys=ep.get("expected_keys"),
            accept_empty=ep.get("accept_empty", True),
        )
        results[f"data:{ep['name']}"] = ok
        if ok:
            passed += 1
        else:
            failed += 1

    # ── Summary ────────────────────────────────────────────────────────────

    log_section("Summary")

    print()
    for key, ok in sorted(results.items()):
        label = key.replace("infra:", "").replace("health:", "").replace("data:", "")
        status_icon = "✅" if ok else "❌"
        print(f"  {status_icon}  {label}: {'PASS' if ok else 'FAIL'}")

    print()
    print(f"  Total: {len(results)} checks | ✅ {passed} passed | ❌ {failed} failed")

    if args.cleanup or (failed > 0 and args.cleanup):
        log_section("Cleanup")
        compose_down(volumes=True)
        log_info("Containers stopped and volumes removed")
    elif args.cleanup is False and failed == 0:
        # Default: leave services running on success
        pass

    # Print access URLs on success
    if failed == 0:
        print()
        print("  🌐  Access URLs:")
        print(f"       tab-api-ingest:      http://localhost:9090/health")
        print(f"       tipsharks-elo-api:   http://localhost:8000/health")
        print(f"       tipsharks-elo-api UI: http://localhost:8000/ui/")
        print(f"       tipsharks-client:    http://localhost:8001/api/health")
        print(f"       Jaeger UI:           http://localhost:16686")
        print(f"       Grafana:             http://localhost:3000 (admin/admin)")
        print(f"       Prometheus:          http://localhost:9091")
        print()

    exit_code = 0 if failed == 0 else 1
    print(
        f"  {'🎉 All checks passed!' if exit_code == 0 else '💥 Some checks failed.'}"
    )
    print()
    return exit_code


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