#!/usr/bin/env python3
"""
Delivery Machine — management CLI.

Environment variables:
  DELIVERY_MACHINE_API_URL   API base URL (default: https://api.deliverymachine.net/v1)

Usage:
  dm login [--hours <N>]              authenticate via browser + TOTP
  dm login --email <e> --totp <code>  non-interactive login
  dm logout                           clear session
  dm users add <email>                invite a user (provisions TOTP, requires login)
  dm users verify <email> [--totp <code>]  verify TOTP for an unverified account

  dm domains list
  dm domains add <domain>
  dm domains show <domain>
  dm domains check <domain>
  dm domains delete <domain>

  dm rules list [--domain <domain>]
  dm rules add <domain> <localpart> <destination> [--expires <days>] [--description <text>]
  dm rules show <domain> <localpart>
  dm rules enable <domain> <localpart>
  dm rules disable <domain> <localpart>
  dm rules delete <domain> <localpart>

  dm tenants list
  dm tenants add [--plan free|starter|pro|business|unlimited]
  dm tenants usage <tenant-id>

  dm import <rules.yaml|rules.json|rules.csv>
"""

import argparse
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from datetime import datetime, timezone, timedelta

try:
    from delivery_machine import __version__
except ImportError:
    __version__ = "0.0.0.dev0"

from rich import box
from rich.columns import Columns
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm
from rich.table import Table
from rich.text import Text

console = Console()
err_console = Console(stderr=True)

_version_warning_shown = False

PLAN_COLOURS = {
    "free":      "dim",
    "starter":   "cyan",
    "pro":       "green",
    "business":  "yellow",
    "unlimited": "magenta bold",
}

CHECK_OK   = "[green]✓[/green]"
CHECK_FAIL = "[red]✗[/red]"
CHECK_WARN = "[yellow]⚠[/yellow]"


# ---------------------------------------------------------------------------
# Session management
# ---------------------------------------------------------------------------

SESSION_DIR = pathlib.Path.home() / ".config" / "dm"
SESSION_FILE = SESSION_DIR / "session.json"


def _load_session():
    """Load saved JWT session, or None if not found / expired."""
    try:
        data = json.loads(SESSION_FILE.read_text())
        if data.get("expires_at", 0) > time.time() + 30:  # 30s buffer
            return data
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        pass
    return None


def _save_session(token, email, tenant_id, expires_in):
    SESSION_DIR.mkdir(parents=True, exist_ok=True)
    SESSION_FILE.write_text(json.dumps({
        "token": token,
        "email": email,
        "tenant_id": tenant_id,
        "expires_at": time.time() + expires_in,
    }, indent=2))
    SESSION_FILE.chmod(0o600)


def _clear_session():
    try:
        SESSION_FILE.unlink()
    except FileNotFoundError:
        pass


# ---------------------------------------------------------------------------
# Version check (server-driven via response headers)
# ---------------------------------------------------------------------------

def _version_tuple(v):
    """Parse '1.2.3' into (1, 2, 3). Returns (0,) for unparseable strings."""
    try:
        return tuple(int(x) for x in v.split(".")[:3])
    except (ValueError, AttributeError):
        return (0,)


def _quiet_version():
    return os.environ.get("DM_QUIET_VERSION", "") == "1"


def _handle_version_headers(response_headers):
    """Read X-Latest-Version / X-Min-Version from a response and warn."""
    if _quiet_version():
        return

    current = _version_tuple(__version__)
    if current == (0,):
        return  # dev install, skip

    min_version = response_headers.get("X-Min-Version", "")
    latest_version = response_headers.get("X-Latest-Version", "")

    if min_version and _version_tuple(min_version) > current:
        err_console.print(
            f"[bold red]This CLI version ({__version__}) is no longer supported.[/bold red]\n"
            f"  Minimum required: [bold]{min_version}[/bold]\n"
            f"  Run: [bold]pip install --upgrade delivery-machine[/bold]"
        )
        sys.exit(1)

    global _version_warning_shown
    if latest_version and _version_tuple(latest_version) > current:
        if not _version_warning_shown:
            _version_warning_shown = True
            err_console.print(
                f"[yellow]Update available:[/yellow] {__version__} → [bold green]{latest_version}[/bold green]"
                f"    [dim]pip install --upgrade delivery-machine[/dim]"
            )


# ---------------------------------------------------------------------------
# Config & API client
# ---------------------------------------------------------------------------

def get_config():
    url = os.environ.get("DELIVERY_MACHINE_API_URL", "https://api.deliverymachine.net/v1").rstrip("/")
    session = _load_session()
    if not session:
        err_console.print(
            "[red]Not authenticated.[/red]\n"
            "Run [bold]dm login[/bold] to authenticate via browser."
        )
        sys.exit(1)
    return url, session.get("tenant_id", "default"), session["token"]


def api(method, path, body=None, tenant_id=None):
    base_url, default_tenant, jwt_token = get_config()
    tenant = tenant_id or default_tenant
    url = f"{base_url}{path}"
    data = json.dumps(body).encode() if body is not None else None
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {jwt_token}",
        "X-Client-Version": __version__,
    }
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req) as r:
            _handle_version_headers(r.headers)
            raw = r.read()
            return r.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as e:
        _handle_version_headers(e.headers)
        raw = e.read()
        try:
            detail = json.loads(raw)
        except Exception:
            detail = {"error": raw.decode(errors="replace")}
        # If 401, session expired — suggest re-login.
        if e.code == 401:
            err_console.print("[yellow]Session expired.[/yellow] Run [bold]dm login[/bold] to re-authenticate.")
            _clear_session()
            sys.exit(1)
        return e.code, detail
    except urllib.error.URLError as e:
        err_console.print(f"[red]Network error:[/red] {e.reason}")
        sys.exit(1)


def api_noauth(method, path, body=None):
    """Make an API call without any authentication (for public endpoints)."""
    base_url = os.environ.get("DELIVERY_MACHINE_API_URL", "https://api.deliverymachine.net/v1").rstrip("/")
    url = f"{base_url}{path}"
    data = json.dumps(body).encode() if body is not None else None
    headers = {
        "Content-Type": "application/json",
        "X-Client-Version": __version__,
    }
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req) as r:
            _handle_version_headers(r.headers)
            raw = r.read()
            return r.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as e:
        _handle_version_headers(e.headers)
        raw = e.read()
        try:
            detail = json.loads(raw)
        except Exception:
            detail = {"error": raw.decode(errors="replace")}
        return e.code, detail
    except urllib.error.URLError as e:
        err_console.print(f"[red]Network error:[/red] {e.reason}")
        sys.exit(1)


def require_ok(status, body, success=(200, 201, 204)):
    if status not in success:
        err_console.print(f"[red]Error {status}:[/red] {body.get('error', body)}")
        sys.exit(1)
    return body


# ---------------------------------------------------------------------------
# Login / logout / auth commands
# ---------------------------------------------------------------------------

def cmd_login(args):
    """Device flow: open browser, authenticate with TOTP, get JWT."""
    session = _load_session()
    if session:
        remaining = session["expires_at"] - time.time()
        if remaining > 60:
            hours = remaining / 3600
            console.print(f"[green]Already logged in[/green] as [bold]{session['email']}[/bold] "
                          f"({hours:.1f}h remaining)")
            if not Confirm.ask("Log in again?", default=False):
                return

    hours = getattr(args, "hours", 8) or 8
    totp = getattr(args, "totp", None)

    # Direct login with --totp (no browser needed).
    if totp:
        email = getattr(args, "email", None) or (session or {}).get("email")
        if not email:
            err_console.print("[red]--email is required with --totp when no session exists.[/red]")
            sys.exit(1)
        status, body = api_noauth("POST", "/auth/login", {
            "email": email,
            "code": totp,
            "session_hours": hours,
        })
        if status != 200:
            err_console.print(f"[red]Login failed:[/red] {body.get('error', body)}")
            sys.exit(1)
        _save_session(body["token"], body["email"], body["tenant_id"], body["expires_in"])
        hours_granted = body["expires_in"] / 3600
        console.print(f"  [green]✓[/green] Logged in as [bold]{body['email']}[/bold] "
                      f"(session: {hours_granted:.0f}h)")
        if body.get("warning"):
            console.print(f"  [yellow]⚠ {body['warning']}[/yellow]")
        return

    # Step 1: Initiate device flow.
    status, body = api_noauth("POST", "/auth/device", {"session_hours": hours})
    if status != 200:
        err_console.print(f"[red]Failed to start login:[/red] {body.get('error', body)}")
        sys.exit(1)

    code = body["code"]
    device_url = body["device_url"]

    console.print()
    console.print(f"  [cyan]Device code:[/cyan]  [bold]{code}[/bold]  [dim](expires in {body['expires_in'] // 60} minutes)[/dim]")
    console.print()

    # Step 2: Open browser.
    console.print(f"  Opening [bold cyan]{device_url}[/bold cyan]")
    webbrowser.open(device_url)
    console.print()

    # Step 3: Poll for completion.
    with Progress(
        SpinnerColumn(style="cyan"),
        TextColumn("[cyan]Waiting for browser authentication…[/cyan]"),
        console=console,
        transient=True,
    ) as progress:
        task = progress.add_task("", total=None)
        deadline = time.time() + body["expires_in"]

        while time.time() < deadline:
            time.sleep(body.get("poll_interval", 5))
            ps, pb = api_noauth("POST", "/auth/device/poll", {"code": code})

            if ps == 200 and pb.get("token"):
                # Success!
                _save_session(pb["token"], pb["email"], pb["tenant_id"], pb["expires_in"])
                progress.stop()
                hours_granted = pb["expires_in"] / 3600
                console.print(f"  [green]✓[/green] Logged in as [bold]{pb['email']}[/bold] "
                              f"(session: {hours_granted:.0f}h)")
                if pb.get("warning"):
                    console.print(f"  [yellow]⚠ {pb['warning']}[/yellow]")
                console.print()
                return
            elif ps == 410:
                progress.stop()
                err_console.print("[red]Device code expired.[/red] Run [bold]dm login[/bold] again.")
                sys.exit(1)
            elif ps != 202:
                progress.stop()
                err_console.print(f"[red]Error:[/red] {pb.get('error', pb)}")
                sys.exit(1)

    err_console.print("[red]Timed out waiting for browser.[/red] Run [bold]dm login[/bold] again.")
    sys.exit(1)


def cmd_logout(args):
    session = _load_session()
    _clear_session()
    if session:
        console.print(f"[green]✓[/green] Logged out ([dim]{session.get('email', 'unknown')}[/dim])")
    else:
        console.print("[dim]No active session.[/dim]")


def cmd_signup(args):
    """Create a new account: tenant + user + TOTP in one step."""
    status, body = api_noauth("POST", "/auth/signup", {
        "email": args.email,
        "plan": args.plan,
    })
    if status == 409:
        err_console.print(f"[yellow]{body.get('error', 'Account already exists.')}[/yellow]")
        console.print(f"  Log in with: [bold cyan]dm login[/bold cyan]")
        return
    require_ok(status, body, (201,))

    console.print()
    console.print(f"[green]✓[/green] Account created for [bold]{args.email}[/bold]")
    console.print(f"  Tenant: [dim]{body['tenant_id']}[/dim]  Plan: {body['plan']}")
    console.print()

    # Show secret for manual entry.
    console.print(f"  [cyan]TOTP Secret:[/cyan]  [bold]{body['secret']}[/bold]")
    console.print()

    # Show QR code.
    try:
        qr_text = _render_qr_compact(body["provisioning_uri"])
        console.print(f"  [cyan]Scan with authenticator:[/cyan]")
        console.print()
        for line in qr_text.split("\n"):
            console.print(f"    {line}", highlight=False)
    except ImportError:
        console.print(f"  [dim]Provisioning URI:[/dim]")
        console.print(f"  [cyan]{body['provisioning_uri']}[/cyan]")

    _offer_macos_integration(body["provisioning_uri"], body["secret"])

    console.print("  Add the secret to your authenticator app, then enter the 6-digit code.")
    console.print()

    # Prompt for verification code with retry loop.
    from rich.prompt import Prompt
    invite_token = body.get("invite_token", "")
    max_attempts = 3
    for attempt in range(1, max_attempts + 1):
        code = Prompt.ask("  [cyan]TOTP code[/cyan]")
        code = code.strip().replace(" ", "")

        if not code or len(code) != 6 or not code.isdigit():
            err_console.print("[red]Invalid code.[/red] Must be 6 digits.")
            if attempt < max_attempts:
                continue
            console.print(f"  Try again: [bold cyan]dm signup {args.email}[/bold cyan]")
            sys.exit(1)

        verify_body_req = {"email": args.email, "code": code}
        if invite_token:
            verify_body_req["invite_token"] = invite_token
        status, verify_body = api_noauth("POST", "/auth/totp/verify", verify_body_req)
        if status == 401:
            err_console.print(f"[red]Wrong code.[/red] Check your authenticator and try again.")
            if attempt < max_attempts:
                continue
            console.print(f"  Try again: [bold cyan]dm signup {args.email}[/bold cyan]")
            sys.exit(1)
        require_ok(status, verify_body)
        console.print(f"  [green]✓[/green] TOTP verified. Account is ready.")
        console.print(f"  Log in with: [bold cyan]dm login[/bold cyan]")
        console.print()
        return

    console.print(f"  Try again: [bold cyan]dm signup {args.email}[/bold cyan]")
    sys.exit(1)


def _offer_macos_integration(provisioning_uri, secret):
    """On macOS, offer to save TOTP to Passwords.app or copy secret to clipboard."""
    if sys.platform != "darwin":
        return
    import subprocess
    if Confirm.ask("  Save to Passwords.app?", default=False):
        try:
            subprocess.run(["open", provisioning_uri], check=True)
            console.print("  [green]✓[/green] Opened Passwords.app")
        except Exception:
            err_console.print("  [red]Failed to open Passwords.app[/red]")
    console.print()
    if Confirm.ask("  Copy secret to clipboard?", default=False):
        try:
            subprocess.run(["pbcopy"], input=secret.encode(), check=True)
            console.print("  [green]✓[/green] Secret copied to clipboard")
        except Exception:
            err_console.print("  [red]Failed to copy to clipboard[/red]")
    console.print()


def _render_qr_compact(data):
    """Render a QR code using half-block characters for 2x vertical density."""
    import qrcode
    qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1)
    qr.add_data(data)
    qr.make(fit=True)
    matrix = qr.get_matrix()
    rows = len(matrix)
    lines = []
    for y in range(0, rows, 2):
        line = []
        for x in range(len(matrix[0])):
            top = matrix[y][x]
            bot = matrix[y + 1][x] if y + 1 < rows else False
            if top and bot:
                line.append("\u2588")      # █ full block
            elif top:
                line.append("\u2580")      # ▀ upper half
            elif bot:
                line.append("\u2584")      # ▄ lower half
            else:
                line.append(" ")
        lines.append("".join(line))
    return "\n".join(lines)


def cmd_user_add(args):
    """Invite a user: provisions TOTP and returns an invite token for them to verify."""
    status, body = api("POST", "/auth/setup", {
        "email": args.email,
    })
    if status == 409:
        err_console.print(f"[yellow]{body.get('error', 'User already configured.')}[/yellow]")
        return
    require_ok(status, body, (201,))

    console.print()
    console.print(f"[green]✓[/green] Account created for [bold]{args.email}[/bold]")
    console.print()

    # Show secret for manual entry.
    console.print(f"  [cyan]TOTP Secret:[/cyan]  [bold]{body['secret']}[/bold]")
    console.print()

    # Show QR code (compact half-block rendering).
    try:
        qr_text = _render_qr_compact(body["provisioning_uri"])
        console.print(f"  [cyan]Scan with authenticator:[/cyan]")
        console.print()
        for line in qr_text.split("\n"):
            console.print(f"    {line}", highlight=False)
    except ImportError:
        console.print(f"  [dim]Provisioning URI:[/dim]")
        console.print(f"  [cyan]{body['provisioning_uri']}[/cyan]")

    _offer_macos_integration(body["provisioning_uri"], body["secret"])

    # Show invite token for the user to verify their TOTP.
    invite_token = body.get("invite_token", "")
    if invite_token:
        console.print()
        console.print("  Share these instructions with the user:")
        console.print()
        console.print(f"    1. Scan the QR code or enter the secret in an authenticator app")
        console.print(f"    2. Run: [bold cyan]dm users verify {args.email}[/bold cyan]")
        console.print(f"    3. Invite token: [bold]{invite_token}[/bold]")
    console.print()


def cmd_user_verify(args):
    """Verify TOTP for an account that was created but not yet verified."""
    code = getattr(args, "totp", None)
    if not code:
        from rich.prompt import Prompt
        console.print(f"  Enter the 6-digit code from your authenticator for [bold]{args.email}[/bold]")
        code = Prompt.ask("  [cyan]TOTP code[/cyan]")
    code = code.strip().replace(" ", "")

    verify_body = {"email": args.email, "code": code}
    invite_token = getattr(args, "invite_token", None)
    if invite_token:
        verify_body["invite_token"] = invite_token

    status, body = api_noauth("POST", "/auth/totp/verify", verify_body)
    if status == 401:
        err_console.print(f"[red]Wrong code.[/red] Check your authenticator and try again.")
        sys.exit(1)
    require_ok(status, body)
    console.print(f"  [green]✓[/green] {body.get('message', 'TOTP verified.')}")
    console.print(f"  Log in with: [bold cyan]dm login[/bold cyan]")


# ---------------------------------------------------------------------------
# DNS helpers (DNS-over-HTTPS — no extra dependencies)
# ---------------------------------------------------------------------------

def dns_query(name, record_type):
    """Query DNS via Google's DNS-over-HTTPS. Returns list of answer dicts."""
    url = (
        "https://dns.google/resolve"
        f"?name={urllib.parse.quote(name)}&type={record_type}"
    )
    req = urllib.request.Request(url, headers={"Accept": "application/dns-json"})
    try:
        with urllib.request.urlopen(req, timeout=5) as r:
            return json.loads(r.read()).get("Answer", [])
    except Exception:
        return []


def dns_value(answer):
    """Return the data string from a DNS answer record, stripped of trailing dot."""
    v = answer.get("data", "").rstrip(".")
    # TXT records arrive wrapped in quotes: "value"
    if v.startswith('"') and v.endswith('"'):
        v = v[1:-1]
    return v


# ---------------------------------------------------------------------------
# Domain commands
# ---------------------------------------------------------------------------

def cmd_domains_list(args):
    status, body = api("GET", "/domains")
    require_ok(status, body)
    domains = body.get("domains", [])

    if not domains:
        console.print("[dim]No domains registered.[/dim]")
        return

    t = Table(box=box.ROUNDED, show_header=True, header_style="bold")
    t.add_column("Domain", style="bold cyan")
    t.add_column("Tenant")
    t.add_column("Registered")
    for d in sorted(domains, key=lambda x: x["domain"]):
        t.add_row(
            d["domain"],
            d.get("tenant_id", "default"),
            d.get("created_at", "")[:10],
        )
    console.print(t)


def cmd_domains_add(args):
    status, body = api("POST", "/domains", {"domain": args.domain})
    require_ok(status, body, (201, 202))
    dns = body["dns_records"]

    console.print(f"\n[green]Domain [bold]{args.domain}[/bold] registered.[/green]\n")
    console.print("[bold]Step 1:[/bold] Add the TXT record to prove domain ownership.")
    console.print("[bold]Step 2:[/bold] Wait for verification, then add MX and DKIM records.")
    console.print(f"[bold]Step 3:[/bold] Check status:  [bold]dm domains check {args.domain}[/bold]\n")
    if body.get("message"):
        console.print(f"[yellow]{body['message']}[/yellow]\n")

    _print_dns_records(dns, zonefile=args.zonefile)


def _print_dns_records(dns, zonefile=False):
    if zonefile:
        for rec in dns:
            purpose = rec.get("purpose", "")
            name = rec["name"]
            rtype = rec["type"]
            value = rec["value"]
            if rtype == "TXT":
                value = f'"{value}"'
            if rtype in ("MX", "CNAME") and not value.endswith("."):
                value += "."
            console.print(f"; {purpose}")
            console.print(f"{name}.  IN  {rtype}  {value}")
            console.print()
    else:
        for rec in dns:
            purpose = rec.get("purpose", "")
            console.print(f"  [dim]{purpose}[/dim]")
            console.print(f"  [bold]Type:[/bold]   {rec['type']}")
            console.print(f"  [bold]Name:[/bold]   [cyan]{rec['name']}[/cyan]")
            console.print(f"  [bold]Value:[/bold]  {rec['value']}")
            console.print()


def cmd_domains_show(args):
    status, body = api("GET", f"/domains/{urllib.parse.quote(args.domain, safe='')}")
    require_ok(status, body)
    d = body["domain"]

    verified = d.get("verified", False)
    colour = "green" if verified else "yellow"
    status_text = "verified" if verified else "pending"

    console.print(f"\n  [bold cyan]{d['domain']}[/bold cyan]")
    console.print(f"  [dim]Domain:[/dim]      {d['domain']}")
    console.print(f"  [dim]Tenant:[/dim]      {d.get('tenant_id', 'default')}")
    console.print(f"  [dim]Ownership:[/dim]   [{colour}]{status_text}[/{colour}]")
    console.print(f"  [dim]Registered:[/dim]  {d.get('created_at', '')[:10]}")
    console.print()

    if "dns_records" in body:
        console.print("\n[bold]Expected DNS records:[/bold]")
        _print_dns_records(body["dns_records"], zonefile=args.zonefile)


def cmd_domains_check(args):
    domain = args.domain
    enc = urllib.parse.quote(domain, safe="")

    with console.status(f"Checking DNS for [bold]{domain}[/bold]…"):
        status, body = api("GET", f"/domains/{enc}/check")

    if status == 404:
        err_console.print(
            f"[red]Domain [bold]{domain}[/bold] is not registered.[/red]\n"
            f"Run:  dm domains add {domain}"
        )
        sys.exit(1)
    require_ok(status, body)

    checks_data = body.get("checks", [])
    verified = body.get("verified", False)
    all_ok = body.get("all_records_ok", False)

    rows = []
    for c in checks_data:
        ok = c["ok"]
        icon = CHECK_OK if ok else CHECK_FAIL
        style = "green" if ok else "red"
        rows.append(f"  {icon}  [bold]{c['record']}[/bold]  {c['name']}")
        if ok:
            found_str = ", ".join(c["found"]) if c["found"] else c["expected"]
            rows.append(f"     [{style}]{found_str}[/{style}]")
        else:
            rows.append(f"     [{style}]expected: {c['expected']}[/{style}]")
            found_str = ", ".join(c["found"]) if c["found"] else "not found"
            rows.append(f"     [{style}]found:    {found_str}[/{style}]")

    passed = sum(1 for c in checks_data if c["ok"])
    total = len(checks_data)
    result_colour = "green" if all_ok else ("yellow" if passed > 0 else "red")
    result_text = (
        f"[{result_colour}]{passed}/{total} checks passed"
        + (" — email is fully configured ✓" if all_ok else " — see issues above")
        + f"[/{result_colour}]"
    )

    if verified:
        rows.append(f"\n  [green]Domain ownership verified ✓[/green]")
    else:
        rows.append(f"\n  [yellow]Domain ownership not yet verified — add the TXT record[/yellow]")

    console.print(f"\n  [bold]Domain health check:[/bold] [cyan]{domain}[/cyan]")
    for row in rows:
        console.print(f"  {row}")
    console.print()
    console.print(f"  {result_text}")
    console.print()

    sys.exit(0 if all_ok else 1)


def cmd_domains_delete(args):
    if not Confirm.ask(
        f"[red]Delete domain [bold]{args.domain}[/bold]?[/red]",
        default=False,
    ):
        console.print("[dim]Aborted.[/dim]")
        return
    status, body = api("DELETE", f"/domains/{urllib.parse.quote(args.domain, safe='')}")
    require_ok(status, body, (204,))
    console.print(f"[green]Domain [bold]{args.domain}[/bold] deleted.[/green]")


# ---------------------------------------------------------------------------
# Rule commands
# ---------------------------------------------------------------------------

def cmd_rules_list(args):
    path = "/rules"
    if args.domain:
        path += f"?domain={urllib.parse.quote(args.domain, safe='')}"
    status, body = api("GET", path)
    require_ok(status, body)
    rules = body.get("rules", [])

    if not rules:
        console.print("[dim]No rules found.[/dim]")
        return

    now_ts = int(datetime.now(timezone.utc).timestamp())

    t = Table(box=box.ROUNDED, show_header=True, header_style="bold")
    t.add_column("Domain",      style="bold cyan")
    t.add_column("Local part",  style="cyan")
    t.add_column("Destination")
    t.add_column("Status",      justify="center")
    t.add_column("Expires",     justify="right", style="dim")

    for r in sorted(rules, key=lambda x: (x["domain"], x["localpart"] != "*", x["localpart"])):
        enabled = r.get("enabled", True)
        status_cell = "[green]on[/green]" if enabled else "[dim]off[/dim]"

        expires_cell = ""
        if "expires_at" in r:
            ts = int(r["expires_at"])
            if ts <= now_ts:
                expires_cell = "[red]expired[/red]"
            else:
                days_left = (ts - now_ts) // 86400
                dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
                expires_cell = f"{dt} [dim]({days_left}d)[/dim]"

        lp = r["localpart"]
        if lp == "*":
            lp = "[dim italic]* (catch-all)[/dim italic]"

        # Support both new destinations list and legacy single destination
        if "destinations" in r:
            dest_str = ", ".join(
                f"{d['address']} ({d['role']})" for d in r["destinations"]
            )
        else:
            dest_str = r.get("destination", "?")
        t.add_row(r["domain"], lp, dest_str, status_cell, expires_cell)

    console.print(t)


def cmd_rules_add(args):
    # Build destinations list from CLI args
    # Syntax: addr (default to), or to:addr, cc:addr, bcc:addr
    destinations = []
    for dest_spec in args.destination:
        if dest_spec.startswith(("to:", "cc:", "bcc:")):
            role, addr = dest_spec.split(":", 1)
        else:
            addr, role = dest_spec, "to"
        destinations.append({"address": addr, "role": role})

    body = {
        "domain":       args.domain,
        "localpart":    args.localpart,
        "destinations": destinations,
    }
    if args.expires:
        expires_dt = datetime.now(timezone.utc) + timedelta(days=args.expires)
        body["expires_at"] = int(expires_dt.timestamp())
    if args.description:
        body["description"] = args.description

    status, resp_body = api("POST", "/rules", body)
    require_ok(status, resp_body, (201, 202))
    r = resp_body["rule"]

    lp = args.localpart if args.localpart != "*" else "* [dim](catch-all)[/dim]"
    dest_str = ", ".join(f"{d['address']} ({d['role']})" for d in r["destinations"])
    msg = f"[green]✓[/green]  [bold]{lp}@{r['domain']}[/bold] → {dest_str}"
    if "expires_at" in r:
        dt = datetime.fromtimestamp(int(r["expires_at"]), tz=timezone.utc)
        msg += f"  [dim](expires {dt.strftime('%Y-%m-%d')})[/dim]"
    if resp_body.get("message"):
        msg += f"\n  [yellow]⚠ {resp_body['message']}[/yellow]"
    console.print(msg)


def cmd_rules_show(args):
    enc = urllib.parse.quote
    path = f"/rules/{enc(args.domain, safe='')}/{enc(args.localpart, safe='')}"
    status, body = api("GET", path)
    require_ok(status, body)
    r = body["rule"]

    if "destinations" in r:
        dest_lines = "\n".join(
            f"  {d['address']} ({d['role']})" for d in r["destinations"]
        )
        dest_str = f"\n{dest_lines}"
    else:
        dest_str = f" {r.get('destination', '?')}"
    lines = [
        f"[bold]Address:[/bold]     {r['localpart']}@{r['domain']}",
        f"[bold]Destinations:[/bold]{dest_str}",
        f"[bold]Status:[/bold]      {'[green]enabled[/green]' if r.get('enabled', True) else '[dim]disabled[/dim]'}",
        f"[bold]Tenant:[/bold]      {r.get('tenant_id', 'default')}",
        f"[bold]Created:[/bold]     {r.get('created_at', '')[:19]}",
    ]
    if r.get("description"):
        lines.append(f"[bold]Description:[/bold] {r['description']}")
    if "expires_at" in r:
        dt = datetime.fromtimestamp(int(r["expires_at"]), tz=timezone.utc)
        lines.append(f"[bold]Expires:[/bold]     {dt.strftime('%Y-%m-%d %H:%M UTC')}")

    console.print(f"\n  [bold cyan]{r['localpart']}@{r['domain']}[/bold cyan]")
    for line in lines:
        console.print(f"  {line}")
    console.print()


def cmd_rules_set_enabled(args, enabled):
    enc = urllib.parse.quote
    path = f"/rules/{enc(args.domain, safe='')}/{enc(args.localpart, safe='')}"
    status, body = api("PUT", path, {"enabled": enabled})
    require_ok(status, body)
    word = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
    console.print(f"{word}  {args.localpart}@{args.domain}")


def cmd_rules_delete(args):
    if not Confirm.ask(
        f"Delete rule [bold]{args.localpart}@{args.domain}[/bold]?",
        default=False,
    ):
        console.print("[dim]Aborted.[/dim]")
        return
    enc = urllib.parse.quote
    path = f"/rules/{enc(args.domain, safe='')}/{enc(args.localpart, safe='')}"
    if args.quiet:
        path += "?quiet=true"
    status, body = api("DELETE", path)
    require_ok(status, body, (204,))
    console.print(f"[green]Deleted[/green]  {args.localpart}@{args.domain}")


def cmd_rules_log(args):
    enc = urllib.parse.quote
    path = f"/rules/{enc(args.domain, safe='')}/{enc(args.localpart, safe='')}/log"
    status, body = api("GET", path)
    require_ok(status, body)
    events = body.get("events", [])

    if not events:
        console.print(f"[dim]No activity recorded for {args.localpart}@{args.domain}[/dim]")
        return

    ACTION_ICONS = {
        "rule_created": "[green]+[/green]",
        "rule_deleted": "[red]×[/red]",
        "destination_added": "[green]↑[/green]",
        "destination_removed": "[yellow]↓[/yellow]",
        "destination_unsubscribed": "[red]↓[/red]",
    }

    t = Table(
        box=box.SIMPLE_HEAD, show_header=True, header_style="bold dim",
        title=f"[bold]Activity log:[/bold] [cyan]{args.localpart}@{args.domain}[/cyan]",
    )
    t.add_column("Time", style="dim")
    t.add_column("", width=2)
    t.add_column("Action")
    t.add_column("Details", style="dim")

    for ev in events:
        ts = ev.get("timestamp", "")[:19].replace("T", " ")
        action = ev.get("action", "?")
        icon = ACTION_ICONS.get(action, " ")
        details = ev.get("details", {})

        detail_parts = []
        if "address" in details:
            detail_parts.append(details["address"])
        if "addresses" in details:
            detail_parts.append(", ".join(details["addresses"]))
        if "destinations" in details:
            dest_list = details["destinations"]
            if isinstance(dest_list, list):
                if dest_list and isinstance(dest_list[0], dict):
                    detail_parts.append(", ".join(
                        f"{d['address']} ({d.get('role', 'to')})" for d in dest_list
                    ))
                else:
                    detail_parts.append(", ".join(str(d) for d in dest_list))
        if "actor" in details and details["actor"] != "recipient":
            detail_parts.append(f"by {details['actor']}")
        if action == "destination_unsubscribed":
            detail_parts.append("(self-unsubscribed)")

        t.add_row(ts, icon, action.replace("_", " "), " ".join(detail_parts))

    console.print(t)


# ---------------------------------------------------------------------------
# Verification commands
# ---------------------------------------------------------------------------

def cmd_verifications_list(args):
    status, body = api("GET", "/verifications")
    require_ok(status, body)
    verifications = body.get("verifications", [])

    if not verifications:
        console.print("[dim]No verifications found.[/dim]")
        return

    t = Table(box=box.ROUNDED, show_header=True, header_style="bold")
    t.add_column("Destination", style="cyan")
    t.add_column("Status")
    t.add_column("Verified at", style="dim")
    t.add_column("Last sent", style="dim")

    for v in sorted(verifications, key=lambda x: x.get("destination", "")):
        ok = v.get("verified", False)
        status_cell = "[green]verified[/green]" if ok else "[yellow]pending[/yellow]"
        verified_at = (v.get("verified_at") or "")[:19]
        last_sent = (v.get("last_sent_at") or "")[:19]
        t.add_row(v["destination"], status_cell, verified_at, last_sent)

    console.print(t)


def cmd_verifications_check(args):
    enc = urllib.parse.quote(args.destination, safe="")
    status, body = api("GET", f"/verifications/{enc}")
    require_ok(status, body)
    v = body.get("verification", {})

    ok = v.get("verified", False)
    colour = "green" if ok else "yellow"
    status_text = "verified" if ok else "pending"

    lines = [
        f"[bold]Destination:[/bold]  {v['destination']}",
        f"[bold]Status:[/bold]       [{colour}]{status_text}[/{colour}]",
    ]
    if v.get("verified_at"):
        lines.append(f"[bold]Verified at:[/bold]  {v['verified_at'][:19]}")
    if v.get("last_sent_at"):
        lines.append(f"[bold]Last sent:[/bold]    {v['last_sent_at'][:19]}")
    if v.get("created_at"):
        lines.append(f"[bold]Created:[/bold]      {v['created_at'][:19]}")

    rules = v.get("rules", [])
    if rules:
        lines.append(f"\n[bold]Referenced by {len(rules)} rule(s):[/bold]")
        for r in rules:
            enabled = "[green]on[/green]" if r["enabled"] else "[dim]off[/dim]"
            lines.append(f"  {r['localpart']}@{r['domain']}  ({r['role']})  {enabled}")
    else:
        lines.append("\n[dim]Not referenced by any rules.[/dim]")

    console.print(f"\n  [bold cyan]{v['destination']}[/bold cyan]")
    for line in lines:
        console.print(f"  {line}")
    console.print()


def cmd_verifications_resend(args):
    status, body = api("POST", "/verifications", {"destination": args.destination})
    require_ok(status, body, (200, 202))

    if body.get("verified"):
        console.print(f"[green]✓[/green]  {args.destination} is already verified.")
    else:
        console.print(f"[yellow]↗[/yellow]  {body.get('message', 'Verification email sent.')}")


# ---------------------------------------------------------------------------
# Tenant commands
# ---------------------------------------------------------------------------

def cmd_tenants_list(args):
    status, body = api("GET", "/tenants")
    require_ok(status, body)
    tenants = body.get("tenants", [])

    if not tenants:
        console.print("[dim]No tenants.[/dim]")
        return

    t = Table(box=box.ROUNDED, show_header=True, header_style="bold")
    t.add_column("Tenant ID",  style="bold cyan")
    t.add_column("Plan",       justify="center")
    t.add_column("Quota",      justify="right")
    t.add_column("Created")

    for ten in sorted(tenants, key=lambda x: x.get("tenant_id", "")):
        plan    = ten.get("plan", "free")
        colour  = PLAN_COLOURS.get(plan, "white")
        quota   = ten.get("email_quota")
        quota_s = "unlimited" if quota is None else f"{int(quota):,}/mo"
        t.add_row(
            ten["tenant_id"],
            f"[{colour}]{plan}[/{colour}]",
            quota_s,
            ten.get("created_at", "")[:10],
        )
    console.print(t)


def cmd_tenants_add(args):
    body = {"plan": args.plan}
    status, resp_body = api("POST", "/tenants", body)
    require_ok(status, resp_body, (201,))
    t = resp_body["tenant"]
    colour = PLAN_COLOURS.get(t["plan"], "white")
    quota  = t.get("email_quota")
    quota_s = "unlimited" if quota is None else f"{int(quota):,}/mo"
    console.print(
        f"[green]✓[/green]  "
        f"id={t['tenant_id']}  "
        f"plan=[{colour}]{t['plan']}[/{colour}]  "
        f"quota={quota_s}"
    )


def cmd_tenants_usage(args):
    path = f"/tenants/{urllib.parse.quote(args.tenant_id, safe='')}/usage"
    status, body = api("GET", path)
    require_ok(status, body)
    u = body["usage"]

    plan    = u.get("plan", "free")
    colour  = PLAN_COLOURS.get(plan, "white")
    count   = u.get("email_count", 0)
    quota   = u.get("email_quota")
    month   = u.get("month", "")
    pct     = u.get("pct_used")

    if u.get("unlimited"):
        meter = "[magenta]unlimited[/magenta]"
    else:
        bar_width = 30
        filled    = int((pct or 0) / 100 * bar_width)
        bar_colour = "green" if (pct or 0) < 75 else ("yellow" if (pct or 0) < 90 else "red")
        bar = f"[{bar_colour}]{'█' * filled}[/{bar_colour}][dim]{'░' * (bar_width - filled)}[/dim]"
        meter = f"{bar}  {count:,} / {int(quota):,}  ({pct}%)"

    console.print(f"\n  [bold cyan]Email usage[/bold cyan]")
    console.print(f"  [dim]Tenant:[/dim]  {u['tenant_id']}")
    console.print(f"  [dim]Plan:[/dim]    [{colour}]{plan}[/{colour}]")
    console.print(f"  [dim]Month:[/dim]   {month}")
    console.print(f"  [dim]Usage:[/dim]   {meter}")
    console.print()


# ---------------------------------------------------------------------------
# Bulk import
# ---------------------------------------------------------------------------

def _load_rules_yaml(path):
    try:
        import yaml
    except ImportError:
        err_console.print("[red]PyYAML required for .yaml/.yml:[/red]  pip install pyyaml")
        sys.exit(1)
    with open(path) as f:
        config = yaml.safe_load(f)
    rules = []
    for domain, mapping in config.get("rules", {}).items():
        for localpart, destination in mapping.items():
            rules.append((domain, str(localpart), destination))
    return rules


def _load_rules_json(path):
    with open(path) as f:
        config = json.load(f)
    rules = []
    # Support same nested format as YAML, or a flat array of objects.
    if "rules" in config and isinstance(config["rules"], dict):
        for domain, mapping in config["rules"].items():
            for localpart, destination in mapping.items():
                rules.append((domain, str(localpart), destination))
    elif isinstance(config, list):
        for row in config:
            rules.append((row["domain"], str(row["localpart"]), row["destination"]))
    else:
        err_console.print("[red]JSON must contain a \"rules\" object or a top-level array.[/red]")
        sys.exit(1)
    return rules


def _load_rules_csv(path):
    import csv
    rules = []
    with open(path, newline="") as f:
        reader = csv.DictReader(f)
        for row in reader:
            rules.append((row["domain"], str(row["localpart"]), row["destination"]))
    return rules


def cmd_import(args):
    path = args.file
    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""

    if ext in ("yaml", "yml"):
        rules = _load_rules_yaml(path)
    elif ext == "json":
        rules = _load_rules_json(path)
    elif ext == "csv":
        rules = _load_rules_csv(path)
    else:
        err_console.print(
            f"[red]Unsupported file type:[/red] .{ext}\n"
            "Supported formats: .yaml, .yml, .json, .csv"
        )
        sys.exit(1)

    created = failed = 0

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        console=console,
    ) as progress:
        task = progress.add_task("Importing rules…", total=len(rules))
        for domain, localpart, destination in rules:
            progress.update(task, description=f"  {localpart}@{domain}")
            status, resp_body = api("POST", "/rules", {
                "domain": domain,
                "localpart": localpart,
                "destination": destination,
            })
            if status in (201, 202):
                created += 1
            else:
                err_console.print(
                    f"  [red]✗[/red]  {localpart}@{domain}: "
                    f"{resp_body.get('error', 'unknown error')}"
                )
                failed += 1
            progress.advance(task)

    console.print(
        f"\n[green]✓ {created} created[/green]"
        + (f"  [red]✗ {failed} failed[/red]" if failed else "")
    )


# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------

def build_parser():
    parser = argparse.ArgumentParser(
        prog="dm",
        description="Delivery Machine management CLI",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--version", action="version",
                        version=f"%(prog)s {__version__}")
    sub = parser.add_subparsers(dest="resource", required=True)

    # ── login / logout ───────────────────────────────────────────────────
    p_login = sub.add_parser("login", help="Authenticate via browser + TOTP")
    p_login.add_argument("--hours", type=float, default=8,
                         help="Session duration in hours (default: 8, max: tenant limit)")
    p_login.add_argument("--email",
                         help="Email address (required with --totp, otherwise uses session)")
    p_login.add_argument("--totp", metavar="CODE",
                         help="TOTP code for non-interactive login (skips browser device flow)")

    sub.add_parser("logout", help="Clear saved session")

    p_signup = sub.add_parser("signup", help="Create a new account (tenant + user + TOTP)")
    p_signup.add_argument("email", help="Your email address")
    p_signup.add_argument("--plan", default="free",
                          choices=["free", "starter", "pro", "business"],
                          help="Subscription plan (default: free)")

    # ── users ─────────────────────────────────────────────────────────────
    p_users = sub.add_parser("users", help="Manage user accounts")
    users_sub = p_users.add_subparsers(dest="action", required=True)

    p_uadd = users_sub.add_parser("add", help="Invite a user (provisions TOTP)")
    p_uadd.add_argument("email")

    p_uverify = users_sub.add_parser("verify", help="Verify TOTP for an unverified account")
    p_uverify.add_argument("email")
    p_uverify.add_argument("--totp", metavar="CODE",
                           help="TOTP code (prompted interactively if omitted)")
    p_uverify.add_argument("--invite-token", dest="invite_token", default=None,
                           help="Invite token from the admin who created the account")

    # ── domains ──────────────────────────────────────────────────────────
    p_d = sub.add_parser("domains", help="Manage custom domains")
    ds  = p_d.add_subparsers(dest="action", required=True)

    ds.add_parser("list", help="List all registered domains")

    p_dadd = ds.add_parser("add", help="Register a new domain")
    p_dadd.add_argument("domain")
    p_dadd.add_argument("--zonefile", action="store_true",
                        help="Output DNS records in zonefile format")

    p_dshow = ds.add_parser("show", help="Show domain details and expected DNS records")
    p_dshow.add_argument("domain")
    p_dshow.add_argument("--zonefile", action="store_true",
                         help="Output DNS records in zonefile format")

    p_dcheck = ds.add_parser("check", help="Check DNS configuration and SES status")
    p_dcheck.add_argument("domain")

    p_ddel = ds.add_parser("delete", help="Remove a domain from SES and the system")
    p_ddel.add_argument("domain")

    # ── rules ─────────────────────────────────────────────────────────────
    p_r = sub.add_parser("rules", help="Manage forwarding rules")
    rs  = p_r.add_subparsers(dest="action", required=True)

    p_rlist = rs.add_parser("list", help="List forwarding rules")
    p_rlist.add_argument("--domain", default=None, help="Filter by domain")

    p_radd = rs.add_parser("add", help="Create a forwarding rule")
    p_radd.add_argument("domain",      help="Receiving domain (e.g. example.com)")
    p_radd.add_argument("localpart",   help="Local part, or '*' for catch-all")
    p_radd.add_argument("destination", nargs="+",
                        help="Destination(s): addr or to:addr, cc:addr, bcc:addr")
    p_radd.add_argument("--expires",   type=int, metavar="DAYS",
                        help="Expire this rule after N days (ephemeral address)")
    p_radd.add_argument("--description", default="", metavar="TEXT")

    p_rshow = rs.add_parser("show", help="Show a single rule")
    p_rshow.add_argument("domain")
    p_rshow.add_argument("localpart")

    p_ren = rs.add_parser("enable", help="Enable a disabled rule")
    p_ren.add_argument("domain")
    p_ren.add_argument("localpart")

    p_rdis = rs.add_parser("disable", help="Disable a rule without deleting it")
    p_rdis.add_argument("domain")
    p_rdis.add_argument("localpart")

    p_rdel = rs.add_parser("delete", help="Permanently delete a rule")
    p_rdel.add_argument("domain")
    p_rdel.add_argument("localpart")
    p_rdel.add_argument("--quiet", action="store_true",
                        help="Skip sending notification emails to destinations")

    p_rlog = rs.add_parser("log", help="Show activity log for a rule")
    p_rlog.add_argument("domain")
    p_rlog.add_argument("localpart")

    # ── verifications ──────────────────────────────────────────────────
    p_ver = sub.add_parser("verifications", help="Manage destination email verifications")
    vs = p_ver.add_subparsers(dest="action", required=True)

    vs.add_parser("list", help="List all destination verifications")

    p_vcheck = vs.add_parser("check", help="Check verification status for a destination")
    p_vcheck.add_argument("destination", help="Destination email address")

    p_vresend = vs.add_parser("resend", help="Resend verification email")
    p_vresend.add_argument("destination", help="Destination email address")

    # ── tenants ───────────────────────────────────────────────────────────
    p_t = sub.add_parser("tenants", help="Manage tenants and quotas")
    ts  = p_t.add_subparsers(dest="action", required=True)

    ts.add_parser("list", help="List all tenants")

    p_tadd = ts.add_parser("add", help="Create a new tenant")
    p_tadd.add_argument("--plan", default="free",
                        choices=["free", "starter", "pro", "business", "unlimited"])

    p_tusage = ts.add_parser("usage", help="Show monthly email usage for a tenant")
    p_tusage.add_argument("tenant_id")

    # ── import ────────────────────────────────────────────────────────────
    p_imp = sub.add_parser("import", help="Bulk-import rules from YAML, JSON, or CSV")
    p_imp.add_argument("file", metavar="rules.{yaml,json,csv}")

    return parser


def main():
    parser = build_parser()
    args   = parser.parse_args()

    dispatch = {
        ("domains", "list"):    cmd_domains_list,
        ("domains", "add"):     cmd_domains_add,
        ("domains", "show"):    cmd_domains_show,
        ("domains", "check"):   cmd_domains_check,
        ("domains", "delete"):  cmd_domains_delete,
        ("rules",   "list"):    cmd_rules_list,
        ("rules",   "add"):     cmd_rules_add,
        ("rules",   "show"):    cmd_rules_show,
        ("rules",   "enable"):  lambda a: cmd_rules_set_enabled(a, True),
        ("rules",   "disable"): lambda a: cmd_rules_set_enabled(a, False),
        ("rules",   "delete"):  cmd_rules_delete,
        ("rules",   "log"):     cmd_rules_log,
        ("verifications", "list"):   cmd_verifications_list,
        ("verifications", "check"):  cmd_verifications_check,
        ("verifications", "resend"): cmd_verifications_resend,
        ("tenants", "list"):    cmd_tenants_list,
        ("tenants", "add"):     cmd_tenants_add,
        ("tenants", "usage"):   cmd_tenants_usage,
        ("import",  None):      cmd_import,
        ("login",   None):      cmd_login,
        ("logout",  None):      cmd_logout,
        ("signup",  None):      cmd_signup,
        ("users",   "add"):     cmd_user_add,
        ("users",   "verify"):  cmd_user_verify,
    }

    key = (args.resource, getattr(args, "action", None))
    fn  = dispatch.get(key)
    if fn:
        fn(args)
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()
