"""CLI loop: load manifest, login users, run scenarios in cycles with error reports."""

from __future__ import annotations

import argparse
import asyncio
import json
import logging
import os
import random
import traceback
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from dotenv import load_dotenv

from . import personas
from .scenarios import RunState, UserSimState, scenarios_for_persona
from .sim_client import SimClient

LOG = logging.getLogger(__name__)


@dataclass
class ManifestUser:
    email: str
    persona: str
    password_env_key: str | None = None
    inbound_token_env_key: str | None = None


@dataclass
class ScenarioError:
    cycle: int
    tick: int
    email: str
    persona: str
    scenario: str
    error: str
    status_code: int | None = None
    body_snippet: str | None = None


@dataclass
class CycleStats:
    cycle: int
    passed: int = 0
    failed: int = 0
    skipped: int = 0
    errors: list[ScenarioError] = field(default_factory=list)


def load_manifest(path: Path) -> list[ManifestUser]:
    raw = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(raw, list):
        raise ValueError("manifest must be a JSON array")
    out: list[ManifestUser] = []
    for i, row in enumerate(raw):
        if not isinstance(row, dict):
            raise ValueError(f"manifest[{i}] must be an object")
        email = str(row.get("email", "")).strip()
        persona = str(row.get("persona", "")).strip()
        if not email or not persona:
            raise ValueError(f"manifest[{i}] requires email and persona")
        personas.validate_persona_declared(persona)
        pw_key = str(row.get("password_env_key", "") or "").strip() or None
        tok_key = str(row.get("inbound_token_env_key", "") or "").strip() or None
        if personas.requires_login(persona) and not pw_key:
            raise ValueError(f"manifest[{i}] persona {persona!r} requires password_env_key")
        if persona == personas.EMAIL_REPORTER and not tok_key:
            # Token may still come from RunState discovery; warn at validate time only if missing env later
            pass
        out.append(
            ManifestUser(
                email=email,
                persona=persona,
                password_env_key=pw_key,
                inbound_token_env_key=tok_key,
            )
        )
    if not out:
        raise ValueError("manifest is empty")
    return out


def resolve_password(env_key: str) -> str:
    v = os.environ.get(env_key)
    if not v:
        raise RuntimeError(f"environment variable {env_key!r} is not set (password for manifest)")
    return v


def resolve_optional_env(env_key: str | None) -> str | None:
    if not env_key:
        return None
    v = os.environ.get(env_key)
    return v.strip() if v and v.strip() else None


def configure_logging(level_name: str) -> None:
    level = getattr(logging, level_name.upper(), logging.INFO)
    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)s %(message)s",
        datefmt="%H:%M:%S",
    )


def _http_bits(exc: BaseException) -> tuple[int | None, str | None]:
    status = getattr(exc, "response", None)
    if status is not None:
        try:
            code = int(status.status_code)
            snip = (status.text or "")[:300]
            return code, snip
        except Exception:
            pass
    return None, None


async def validate_user(client: SimClient, mu: ManifestUser, run: RunState) -> UserSimState:
    if not personas.requires_login(mu.persona):
        tok = resolve_optional_env(mu.inbound_token_env_key)
        if tok:
            run.email_inbound_token = run.email_inbound_token or tok
        return UserSimState(
            email=mu.email,
            persona=mu.persona,
            inbound_token=tok,
        )

    assert mu.password_env_key
    await client.login(mu.email, resolve_password(mu.password_env_key))
    me = await client.get_me()
    role = str(me.get("role", ""))
    if not personas.persona_matches_role(mu.persona, role):
        raise RuntimeError(
            f"persona/role mismatch for {mu.email!r}: persona={mu.persona!r} but auth/me role={role!r}"
        )
    uid = me.get("id")
    return UserSimState(
        email=mu.email,
        persona=mu.persona,
        user_id=str(uid) if uid else None,
    )


async def run_tick(
    clients: dict[str, SimClient],
    states: dict[str, UserSimState],
    manifest_slice: list[ManifestUser],
    jitter_ms: int,
    run: RunState,
    stats: CycleStats,
    tick_index: int,
) -> None:
    for mu in manifest_slice:
        if jitter_ms > 0:
            await asyncio.sleep(random.randint(0, jitter_ms) / 1000.0)
        client = clients[mu.email]
        state = states[mu.email]
        choices = scenarios_for_persona(mu.persona)
        scenario = random.choice(choices)
        name = scenario.__name__
        LOG.debug("tick %s scenario=%s", mu.email, name)
        try:
            await scenario(client, state, run)
            stats.passed += 1
        except Exception as exc:
            stats.failed += 1
            code, snip = _http_bits(exc)
            err = ScenarioError(
                cycle=stats.cycle,
                tick=tick_index,
                email=mu.email,
                persona=mu.persona,
                scenario=name,
                error=f"{type(exc).__name__}: {exc}",
                status_code=code,
                body_snippet=snip,
            )
            stats.errors.append(err)
            LOG.warning(
                "scenario failed cycle=%s tick=%s %s %s: %s",
                stats.cycle,
                tick_index,
                mu.email,
                name,
                err.error,
            )
            LOG.debug("traceback:\n%s", traceback.format_exc())


def write_cycle_report(report_dir: Path, stats: CycleStats, run: RunState) -> tuple[Path, Path]:
    report_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    base = report_dir / f"cycle-{stamp}-c{stats.cycle}"
    payload: dict[str, Any] = {
        "cycle": stats.cycle,
        "passed": stats.passed,
        "failed": stats.failed,
        "skipped": stats.skipped,
        "errors": [asdict(e) for e in stats.errors],
        "bootstrap": {
            "company_ids": run.company_ids,
            "created_portal_users": run.created_portal_users,
            "created_staff_users": run.created_staff_users,
            "email_inbound_token_set": bool(run.email_inbound_token),
        },
        "written_at": datetime.now(timezone.utc).isoformat(),
    }
    json_path = base.with_suffix(".json")
    md_path = base.with_suffix(".md")
    json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")

    lines = [
        f"# Cycle {stats.cycle} report",
        "",
        f"- Written: `{payload['written_at']}`",
        f"- Passed: **{stats.passed}**",
        f"- Failed: **{stats.failed}**",
        f"- Companies created/known: {len(run.company_ids)}",
        f"- Portal users created: {len(run.created_portal_users)}",
        f"- Staff users created: {len(run.created_staff_users)}",
        "",
    ]
    if stats.errors:
        lines.append("## Errors")
        lines.append("")
        for e in stats.errors:
            lines.append(
                f"- cycle={e.cycle} tick={e.tick} `{e.email}` persona=`{e.persona}` "
                f"scenario=`{e.scenario}`: {e.error}"
            )
            if e.status_code is not None:
                lines.append(f"  - status={e.status_code} body=`{(e.body_snippet or '')[:120]}`")
        lines.append("")
    else:
        lines.append("No errors in this cycle.")
        lines.append("")

    md_path.write_text("\n".join(lines), encoding="utf-8")
    LOG.info("wrote report %s and %s", json_path, md_path)
    return json_path, md_path


async def async_main(args: argparse.Namespace) -> None:
    dotenv_path = Path(args.dotenv) if args.dotenv else Path(__file__).resolve().parent / ".env"
    if dotenv_path.is_file():
        load_dotenv(dotenv_path)
    else:
        load_dotenv()

    log_level = os.environ.get("SIM_LOG_LEVEL", "INFO")
    configure_logging(args.log_level or log_level)

    api_base = (args.api_base or os.environ.get("SIM_API_BASE", "")).strip().rstrip("/")
    if not api_base:
        raise SystemExit("SIM_API_BASE or --api-base is required (e.g. http://localhost:3001)")

    manifest_path = Path(args.manifest)
    manifest = load_manifest(manifest_path)

    cap = args.max_users_per_tick
    if cap < 0:
        raise SystemExit("--max-users-per-tick must be >= 0 (0 means all users)")
    if cap == 0 or cap > len(manifest):
        cap = len(manifest)

    report_dir = Path(args.report_dir) if args.report_dir else Path(__file__).resolve().parent / "reports"
    cycles = int(args.cycles)
    if cycles < 0:
        raise SystemExit("--cycles must be >= 0 (0 = infinite)")

    run = RunState()
    env_tok = os.environ.get("SIM_EMAIL_INBOUND_TOKEN", "").strip()
    if env_tok:
        run.email_inbound_token = env_tok

    clients: dict[str, SimClient] = {}
    states: dict[str, UserSimState] = {}
    all_stats: list[CycleStats] = []

    try:
        for mu in manifest:
            c = SimClient(api_base)
            clients[mu.email] = c
            states[mu.email] = await validate_user(c, mu, run)
            LOG.info("validated %s persona=%s", mu.email, mu.persona)

        if args.dry_run:
            LOG.info("dry-run: exiting after successful validation for all users")
            return

        interval = max(1, int(args.interval_seconds))
        jitter_ms = max(0, int(args.jitter_ms))

        cycle_num = 0
        while True:
            cycle_num += 1
            stats = CycleStats(cycle=cycle_num)
            tick_index = 0
            LOG.info("=== cycle %s start ===", cycle_num)

            # One tick per cycle for finite mode; soak mode also one tick then sleep (same as before)
            slice_u = random.sample(manifest, cap) if cap < len(manifest) else list(manifest)
            await run_tick(clients, states, slice_u, jitter_ms, run, stats, tick_index)
            all_stats.append(stats)
            write_cycle_report(report_dir, stats, run)
            LOG.info(
                "=== cycle %s done passed=%s failed=%s ===",
                cycle_num,
                stats.passed,
                stats.failed,
            )

            if cycles > 0 and cycle_num >= cycles:
                break

            LOG.debug("sleeping %ss", interval)
            await asyncio.sleep(interval)
    except KeyboardInterrupt:
        LOG.info("stopped by user")
        if all_stats:
            write_cycle_report(report_dir, all_stats[-1], run)
    finally:
        for c in clients.values():
            await c.aclose()


def build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="Simulate portal, staff, and channel users against the ticketing API.")
    p.add_argument("--manifest", default="user_automation/users.json", help="Path to users JSON manifest")
    p.add_argument("--api-base", default="", help="Override SIM_API_BASE")
    p.add_argument("--dotenv", default="", help="Path to .env (default: user_automation/.env if present)")
    p.add_argument("--interval-seconds", type=int, default=60, help="Sleep between cycles (default 60)")
    p.add_argument("--jitter-ms", type=int, default=0, help="Random delay 0..N ms before each user in a tick")
    p.add_argument(
        "--max-users-per-tick",
        type=int,
        default=0,
        help="Max users to run per tick (0 = all manifest users)",
    )
    p.add_argument(
        "--cycles",
        type=int,
        default=1,
        help="Number of cycles then exit (default 1). Use 0 for infinite soak.",
    )
    p.add_argument(
        "--report-dir",
        default="",
        help="Directory for cycle JSON/MD reports (default: user_automation/reports)",
    )
    p.add_argument("--dry-run", action="store_true", help="Validate credentials/personas only, then exit")
    p.add_argument("--log-level", default="", help="Override SIM_LOG_LEVEL (DEBUG, INFO, ...)")
    return p


def main() -> None:
    parser = build_arg_parser()
    args = parser.parse_args()
    try:
        asyncio.run(async_main(args))
    except KeyboardInterrupt:
        LOG.info("stopped by user")


if __name__ == "__main__":
    main()
