#!/usr/bin/env python3
"""
portman — Port Manager for containerised development
Engineered by Codermasky
Works on macOS, Linux, and Windows
Tracks and assigns ports across all your projects to avoid conflicts.

Usage:
    portman list [--app APP]              List all registered ports
    portman new APP                       Wizard: assign standard services for a new app
    portman assign SERVICE --app APP      Register a port (auto-assigns if no --port given)
    portman release SERVICE --app APP     Remove a port from the registry
    portman check [--app APP]             Detect registry conflicts
    portman scan                          Show what is actually listening on your machine
    portman export APP                    Print docker-compose ports snippet
    portman rm APP                        Delete an entire app from the registry
    portman dashboard                     Generate + open the visual HTML dashboard
    portman workspace init [WORKSPACE]    Auto-discover projects and assign ports (default: ~/Sandbox)
    portman workspace list [WORKSPACE]    Show all projects in workspace (default: ~/Sandbox)
    portman workspace report [WORKSPACE]  Generate workspace port assignment report
"""

import argparse
import json
import os
import platform
import socket
import subprocess
import sys
import webbrowser
import yaml
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple

# ─── Paths ────────────────────────────────────────────────────────────────────

PORTMAN_DIR   = Path.home() / ".portman"
REGISTRY_FILE = PORTMAN_DIR / "registry.json"

# ─── Port ranges per service type ─────────────────────────────────────────────

PORT_RANGES: Dict[str, Tuple[int, int]] = {
    "frontend":      (3000, 3099),
    "backend":       (8000, 8099),
    "postgres":      (5432, 5532),
    "redis":         (6379, 6479),
    "langfuse":      (3200, 3299),
    "clickhouse":    (8123, 8222),
    "minio":         (9000, 9099),
    "nginx":         (80,   89),
    "adminer":       (8080, 8180),
    "grafana":       (3300, 3399),
    "prometheus":    (9090, 9189),
    "elasticsearch": (9200, 9299),
    "kibana":        (5601, 5700),
    "mongodb":       (27017, 27116),
    "mysql":         (3306, 3405),
    "generic":       (9500, 9999),
}

# Internal (container-side) port for each service type
INTERNAL_PORTS: Dict[str, int] = {
    "frontend":      3000,
    "backend":       8000,
    "postgres":      5432,
    "redis":         6379,
    "langfuse":      3000,
    "clickhouse":    8123,
    "minio":         9000,
    "nginx":         80,
    "adminer":       8080,
    "grafana":       3000,
    "prometheus":    9090,
    "elasticsearch": 9200,
    "kibana":        5601,
    "mongodb":       27017,
    "mysql":         3306,
    "generic":       8080,
}

# Map common service names → type
SERVICE_TYPE_MAP: Dict[str, str] = {
    "frontend": "frontend", "ui": "frontend", "web": "frontend", "vite": "frontend",
    "backend":  "backend",  "api": "backend", "server": "backend", "fastapi": "backend",
    "db": "postgres", "postgres": "postgres", "postgresql": "postgres", "database": "postgres",
    "redis": "redis", "cache": "redis",
    "langfuse": "langfuse", "observability": "langfuse",
    "clickhouse": "clickhouse",
    "minio": "minio", "storage": "minio",
    "nginx": "nginx", "proxy": "nginx",
    "adminer": "adminer",
    "grafana": "grafana",
    "prometheus": "prometheus",
    "elasticsearch": "elasticsearch", "elastic": "elasticsearch",
    "kibana": "kibana",
    "mongodb": "mongodb", "mongo": "mongodb",
    "mysql": "mysql",
}

# Standard service set auto-created by `portman new`
NEW_APP_SERVICES: List[Tuple[str, str]] = [
    ("frontend",  "frontend"),
    ("backend",   "backend"),
    ("postgres",  "postgres"),
    ("redis",     "redis"),
    ("langfuse",  "langfuse"),
]

# ─── ANSI colours ─────────────────────────────────────────────────────────────

class C:
    RED    = "\033[91m"
    GREEN  = "\033[92m"
    YELLOW = "\033[93m"
    BLUE   = "\033[94m"
    CYAN   = "\033[96m"
    WHITE  = "\033[97m"
    BOLD   = "\033[1m"
    DIM    = "\033[2m"
    RESET  = "\033[0m"

def col(text: str, *codes: str) -> str:
    return "".join(codes) + text + C.RESET

def banner(title: str):
    print()
    print(col(f"  ┌─ portman by Codermasky  {title}", C.BOLD, C.CYAN))
    print(col("  └" + "─" * 56, C.DIM))

def divider():
    print(col("  " + "─" * 58, C.DIM))

# ─── Registry helpers ─────────────────────────────────────────────────────────

def load_registry() -> Dict:
    PORTMAN_DIR.mkdir(parents=True, exist_ok=True)
    if not REGISTRY_FILE.exists():
        default = {
            "meta": {"created": datetime.now().isoformat(), "version": "1.0"},
            "apps": {},
        }
        _save(default)
        return default
    with open(REGISTRY_FILE) as f:
        return json.load(f)

def _save(data: Dict):
    PORTMAN_DIR.mkdir(parents=True, exist_ok=True)
    with open(REGISTRY_FILE, "w") as f:
        json.dump(data, f, indent=2)

def all_assigned_ports(registry: Dict) -> Dict[int, str]:
    """Returns {port: 'app/service'} for every registered port."""
    taken: Dict[int, str] = {}
    for app, services in registry.get("apps", {}).items():
        for svc, info in services.items():
            taken[info["port"]] = f"{app}/{svc}"
    return taken

def infer_type(service_name: str) -> str:
    return SERVICE_TYPE_MAP.get(service_name.lower(), "generic")

def is_port_in_use(port: int) -> bool:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(0.15)
        return s.connect_ex(("127.0.0.1", port)) == 0

def next_free_port(svc_type: str, registry: Dict) -> int:
    taken = set(all_assigned_ports(registry).keys())
    low, high = PORT_RANGES.get(svc_type, PORT_RANGES["generic"])
    for p in range(low, high + 1):
        if p not in taken and not is_port_in_use(p):
            return p
    raise RuntimeError(f"No free ports in range {low}–{high} for type '{svc_type}'")

def discover_projects(workspace: str) -> List[str]:
    """Discover all project directories in a workspace (those with docker-compose.yml)."""
    projects = []
    try:
        for entry in os.listdir(workspace):
            project_path = os.path.join(workspace, entry)
            if not os.path.isdir(project_path):
                continue
            if entry.startswith("."):
                continue
            # Check if it has docker-compose.yml
            docker_compose = os.path.join(project_path, "docker-compose.yml")
            if os.path.exists(docker_compose):
                projects.append(entry)
    except (OSError, PermissionError):
        pass
    return sorted(projects)

# ─── Commands ─────────────────────────────────────────────────────────────────

def cmd_list(args):
    registry = load_registry()
    apps = registry.get("apps", {})
    filter_app = getattr(args, "app", None)

    banner("registry")
    if not apps:
        print(col("  No ports registered yet.  Run `portman new <app>` to get started.", C.DIM))
        print()
        return

    print(col(f"  {'SERVICE':<24} {'PORT':<8} {'TYPE':<16} {'APP'}", C.BOLD))
    divider()

    total = 0
    for app_name in sorted(apps):
        if filter_app and app_name != filter_app:
            continue
        for svc_name, info in sorted(apps[app_name].items(), key=lambda x: x[1]["port"]):
            port = info["port"]
            svc_type = info.get("type", "generic")
            active = is_port_in_use(port)
            dot    = col("●", C.GREEN) if active else col("○", C.DIM)
            status = col("active   ", C.GREEN) if active else col("free     ", C.DIM)
            print(f"  {dot} {svc_name:<22} {col(str(port), C.BOLD, C.WHITE):<8} {svc_type:<16} {col(app_name, C.CYAN)}")
            total += 1

    divider()
    active_count = sum(
        1 for app in apps.values()
        for info in app.values()
        if is_port_in_use(info["port"])
    )
    print(col(f"  {total} services across {len(apps)} apps  ·  {active_count} currently active", C.DIM))
    print()


def cmd_new(args):
    app = args.app
    registry = load_registry()

    if app in registry.get("apps", {}):
        print(col(f"\n  ⚠  App '{app}' already exists. Use `portman list --app {app}`\n", C.YELLOW))
        return

    banner(f"new app: {app}")
    registry["apps"][app] = {}

    for svc_name, svc_type in NEW_APP_SERVICES:
        try:
            port = next_free_port(svc_type, registry)
            registry["apps"][app][svc_name] = {
                "port":     port,
                "type":     svc_type,
                "assigned": datetime.now().isoformat(),
            }
            print(f"  {col('✓', C.GREEN)} {svc_name:<18} → {col(str(port), C.BOLD, C.WHITE)}  ({svc_type})")
        except RuntimeError as e:
            print(col(f"  ✗ {svc_name}: {e}", C.RED))

    _save(registry)
    divider()
    print(col(f"  Saved to ~/.portman/registry.json", C.DIM))
    print(col(f"  Run `portman export {app}` for a docker-compose snippet.", C.DIM))
    print()


def cmd_assign(args):
    registry = load_registry()
    app      = args.app
    service  = args.service
    svc_type = infer_type(service)
    taken    = all_assigned_ports(registry)

    # Resolve port
    if hasattr(args, "port") and args.port:
        port = int(args.port)
        if port in taken:
            print(col(f"\n  ✗  Port {port} is already assigned to {taken[port]}\n", C.RED))
            sys.exit(1)
        if is_port_in_use(port):
            print(col(f"  ⚠  Port {port} is currently in use by another process", C.YELLOW))
    else:
        port = next_free_port(svc_type, registry)

    if app not in registry["apps"]:
        registry["apps"][app] = {}

    if service in registry["apps"][app]:
        old = registry["apps"][app][service]["port"]
        print(col(f"  ⚠  Overwriting existing assignment: {app}/{service} was :{old}", C.YELLOW))

    registry["apps"][app][service] = {
        "port":     port,
        "type":     svc_type,
        "assigned": datetime.now().isoformat(),
    }
    _save(registry)
    print(col(f"\n  ✓  {app}/{service}  →  :{port}  ({svc_type})\n", C.GREEN))


def cmd_release(args):
    registry = load_registry()
    app      = args.app
    service  = args.service

    if app not in registry.get("apps", {}) or service not in registry["apps"].get(app, {}):
        print(col(f"\n  ✗  {app}/{service} not found in registry\n", C.RED))
        sys.exit(1)

    port = registry["apps"][app][service]["port"]
    del registry["apps"][app][service]
    if not registry["apps"][app]:
        del registry["apps"][app]

    _save(registry)
    print(col(f"\n  ✓  Released :{port} from {app}/{service}\n", C.GREEN))


def cmd_rm(args):
    registry = load_registry()
    app      = args.app

    if app not in registry.get("apps", {}):
        print(col(f"\n  ✗  App '{app}' not found\n", C.RED))
        sys.exit(1)

    services = registry["apps"][app]
    del registry["apps"][app]
    _save(registry)
    ports = ", ".join(str(i["port"]) for i in services.values())
    print(col(f"\n  ✓  Removed app '{app}' and freed ports: {ports}\n", C.GREEN))


def cmd_check(args):
    registry   = load_registry()
    apps       = registry.get("apps", {})
    filter_app = getattr(args, "app", None)

    banner("conflict check")

    # Registry-level duplicate port detection
    port_owners: Dict[int, List[str]] = {}
    for app_name, services in apps.items():
        if filter_app and app_name != filter_app:
            continue
        for svc_name, info in services.items():
            p = info["port"]
            port_owners.setdefault(p, []).append(f"{app_name}/{svc_name}")

    conflicts = {p: owners for p, owners in port_owners.items() if len(owners) > 1}

    # Print per-service status
    for app_name in sorted(apps):
        if filter_app and app_name != filter_app:
            continue
        print(col(f"  {app_name}", C.BOLD))
        for svc_name, info in sorted(apps[app_name].items(), key=lambda x: x[1]["port"]):
            port    = info["port"]
            active  = is_port_in_use(port)
            in_conf = port in conflicts

            if in_conf:
                marker = col("⚠ CONFLICT", C.RED, C.BOLD)
            elif active:
                marker = col("● active  ", C.GREEN)
            else:
                marker = col("○ free    ", C.DIM)

            print(f"    {marker}  {svc_name:<20} :{col(str(port), C.BOLD)}")
        print()

    divider()
    if conflicts:
        print(col(f"  ⚠  {len(conflicts)} CONFLICT(S):", C.RED, C.BOLD))
        for port, owners in conflicts.items():
            print(col(f"     :{port}  →  {' + '.join(owners)}", C.RED))
    else:
        print(col("  ✓  No conflicts detected", C.GREEN))
    print()


def cmd_scan(args):
    """Live scan: what is actually listening, cross-referenced with registry."""
    registry = load_registry()
    taken    = all_assigned_ports(registry)

    banner("live port scan")
    
    system = platform.system()
    seen: set = set()
    
    try:
        if system == "Darwin" or system == "Linux":
            # macOS / Linux: use lsof
            result = subprocess.run(
                ["lsof", "-iTCP", "-sTCP:LISTEN", "-n", "-P"],
                capture_output=True, text=True, timeout=15,
            )
            lines = result.stdout.strip().splitlines()
            if len(lines) < 2:
                print(col("  No listening ports found.", C.DIM))
                print()
                return

            print(col(f"  {'PORT':<8} {'PROCESS':<18} {'PID':<8} REGISTRY", C.BOLD))
            divider()

            for line in lines[1:]:
                parts = line.split()
                if len(parts) < 9:
                    continue
                proc = parts[0]
                pid  = parts[1]
                addr = parts[8]
                if ":" not in addr:
                    continue
                try:
                    port = int(addr.rsplit(":", 1)[1])
                except ValueError:
                    continue
                if port in seen:
                    continue
                seen.add(port)

                if port in taken:
                    reg_label = col(f"✓ {taken[port]}", C.GREEN)
                else:
                    reg_label = col("  unregistered", C.YELLOW)

                print(f"  {col(str(port), C.BOLD, C.WHITE):<8} {proc:<18} {pid:<8} {reg_label}")

        elif system == "Windows":
            # Windows: use netstat
            result = subprocess.run(
                ["netstat", "-ano"],
                capture_output=True, text=True, timeout=15,
            )
            lines = result.stdout.strip().splitlines()
            if len(lines) < 4:
                print(col("  No listening ports found.", C.DIM))
                print()
                return

            print(col(f"  {'PORT':<8} {'PROCESS':<18} {'PID':<8} REGISTRY", C.BOLD))
            divider()

            for line in lines[4:]:  # Skip headers
                parts = line.split()
                if len(parts) < 5 or "LISTENING" not in line:
                    continue
                try:
                    addr_port = parts[1]  # e.g., "0.0.0.0:3000"
                    port = int(addr_port.rsplit(":", 1)[1])
                    pid = parts[4]
                    # Get process name from PID (simplified)
                    proc = f"PID-{pid}"
                except (ValueError, IndexError):
                    continue
                
                if port in seen:
                    continue
                seen.add(port)

                if port in taken:
                    reg_label = col(f"✓ {taken[port]}", C.GREEN)
                else:
                    reg_label = col("  unregistered", C.YELLOW)

                print(f"  {col(str(port), C.BOLD, C.WHITE):<8} {proc:<18} {pid:<8} {reg_label}")
        else:
            print(col(f"  ✗  Unsupported OS: {system}", C.RED))
            print()
            return

        # Registered but silent
        silent = {p: label for p, label in sorted(taken.items()) if p not in seen}
        if silent:
            print()
            print(col("  Registered but not running:", C.DIM))
            for port, label in silent.items():
                print(col(f"  ○ :{port:<6}  {label}", C.DIM))

    except FileNotFoundError as e:
        print(col(f"  ✗  Required tool not found: {e}", C.RED))
        if system == "Darwin" or system == "Linux":
            print(col("     Install lsof: brew install lsof (macOS) or apt install lsof (Linux)", C.DIM))
        elif system == "Windows":
            print(col("     netstat should be built-in on Windows", C.DIM))

    print()


def cmd_export(args):
    registry = load_registry()
    app      = args.app

    if app not in registry.get("apps", {}):
        print(col(f"\n  ✗  App '{app}' not found. Run `portman new {app}` first.\n", C.RED))
        sys.exit(1)

    services = registry["apps"][app]
    lines = [
        f"  # docker-compose.yml — ports for '{app}'",
        f"  # Generated by portman on {datetime.now().strftime('%Y-%m-%d')}",
        "",
    ]
    for svc_name, info in sorted(services.items(), key=lambda x: x[1]["port"]):
        host_port     = info["port"]
        svc_type      = info.get("type", "generic")
        internal_port = INTERNAL_PORTS.get(svc_type, host_port)
        lines.append(f"  {svc_name}:")
        lines.append(f"    ports:")
        lines.append(f'      - "{host_port}:{internal_port}"')
        lines.append("")

    print()
    for ln in lines:
        print(ln)


# ─── HTML Dashboard ───────────────────────────────────────────────────────────

DASHBOARD_TEMPLATE = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>portman — Port Manager</title>
<style>
  :root {
    --bg:         #0a0f1e;
    --surface:    rgba(255,255,255,0.04);
    --border:     rgba(255,255,255,0.09);
    --red:        #DD0031;
    --cyan:       #2C8C99;
    --green:      #10b981;
    --amber:      #f59e0b;
    --blue:       #3b82f6;
    --text:       #e2e8f0;
    --muted:      #64748b;
    --font:       'Outfit', 'Inter', system-ui, sans-serif;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { background: var(--bg); color: var(--text); font-family: var(--font);
         font-size: 14px; min-height: 100vh; padding: 0; }

  /* Top bar */
  header {
    display: flex; align-items: center; justify-content: space-between;
    padding: 18px 32px;
    background: rgba(10,15,30,0.95);
    border-bottom: 1px solid var(--border);
    backdrop-filter: blur(20px);
    position: sticky; top: 0; z-index: 100;
  }
  .logo { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
  .logo span { color: var(--red); }
  .stats { display: flex; gap: 24px; }
  .stat { text-align: center; }
  .stat-value { font-size: 20px; font-weight: 700; color: var(--text); }
  .stat-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
  .ts { font-size: 12px; color: var(--muted); }

  /* Layout */
  main { padding: 32px; max-width: 1280px; margin: 0 auto; }
  h2 { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase;
       letter-spacing: 1px; margin-bottom: 16px; }

  /* Conflict banner */
  .conflict-banner {
    background: rgba(221,0,49,0.12); border: 1px solid rgba(221,0,49,0.4);
    border-radius: 10px; padding: 14px 20px; margin-bottom: 28px;
    display: flex; align-items: center; gap: 12px;
    font-size: 13px; font-weight: 500;
  }
  .conflict-banner .icon { font-size: 18px; }

  /* App grid */
  .app-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
              gap: 20px; margin-bottom: 40px; }

  .app-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 14px;
    overflow: hidden;
    backdrop-filter: blur(16px);
    transition: border-color 0.2s, box-shadow 0.2s;
  }
  .app-card:hover {
    border-color: rgba(44,140,153,0.4);
    box-shadow: 0 4px 24px rgba(44,140,153,0.1);
  }
  .app-card.has-conflict { border-color: rgba(221,0,49,0.4); }

  .app-header {
    display: flex; align-items: center; justify-content: space-between;
    padding: 14px 18px;
    border-bottom: 1px solid var(--border);
    background: rgba(255,255,255,0.03);
  }
  .app-name { font-size: 15px; font-weight: 700; color: var(--text); }
  .app-badge {
    font-size: 11px; font-weight: 600; padding: 3px 10px;
    border-radius: 20px; background: rgba(44,140,153,0.2);
    color: var(--cyan); border: 1px solid rgba(44,140,153,0.3);
  }

  /* Service table */
  .service-list { padding: 6px 0; }
  .service-row {
    display: grid; grid-template-columns: 18px 1fr 72px 110px 36px;
    align-items: center; gap: 10px;
    padding: 9px 18px;
    border-bottom: 1px solid rgba(255,255,255,0.04);
    transition: background 0.15s;
  }
  .service-row:last-child { border-bottom: none; }
  .service-row:hover { background: rgba(255,255,255,0.03); }

  .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
  .dot.active    { background: var(--green); box-shadow: 0 0 6px rgba(16,185,129,0.6); }
  .dot.free      { background: var(--muted); }
  .dot.conflict  { background: var(--red);   box-shadow: 0 0 6px rgba(221,0,49,0.6); }

  .svc-name  { font-weight: 500; color: var(--text); font-size: 13px; }
  .svc-port  { font-size: 15px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
  .svc-type  { font-size: 11px; color: var(--muted); background: rgba(255,255,255,0.06);
               padding: 2px 8px; border-radius: 4px; width: fit-content; }
  .svc-copy  { cursor: pointer; color: var(--muted); font-size: 13px; opacity: 0.5;
               transition: opacity 0.15s; user-select: none; text-align: center; }
  .svc-copy:hover { opacity: 1; color: var(--cyan); }

  /* Card footer */
  .app-footer { padding: 10px 18px; border-top: 1px solid var(--border);
                display: flex; gap: 8px; }
  .btn-sm {
    font-size: 11px; font-weight: 600; padding: 5px 12px; border-radius: 6px;
    border: 1px solid var(--border); background: rgba(255,255,255,0.06);
    color: var(--muted); cursor: pointer; transition: all 0.15s;
    font-family: var(--font); letter-spacing: 0.3px;
  }
  .btn-sm:hover { background: rgba(44,140,153,0.2); color: var(--cyan);
                  border-color: rgba(44,140,153,0.4); }

  /* Range bars */
  .ranges-section { margin-top: 8px; }
  .range-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
  .range-card {
    background: var(--surface); border: 1px solid var(--border);
    border-radius: 10px; padding: 14px 16px;
  }
  .range-header { display: flex; justify-content: space-between; margin-bottom: 10px;
                  font-size: 12px; }
  .range-type  { font-weight: 600; color: var(--text); }
  .range-ports { color: var(--muted); }
  .range-bar   { height: 6px; border-radius: 3px; background: rgba(255,255,255,0.08); overflow: hidden; }
  .range-fill  { height: 100%; border-radius: 3px;
                 background: linear-gradient(90deg, var(--cyan), var(--blue)); transition: width 0.4s; }
  .range-usage { font-size: 11px; color: var(--muted); margin-top: 6px; }

  /* Toast */
  #toast {
    position: fixed; bottom: 28px; right: 28px;
    background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.4);
    color: var(--green); padding: 10px 18px; border-radius: 8px; font-size: 13px;
    font-weight: 500; opacity: 0; transform: translateY(12px);
    transition: all 0.25s; pointer-events: none; z-index: 999;
  }
  #toast.show { opacity: 1; transform: translateY(0); }

  /* Command ref */
  .cmd-section { margin-top: 40px; }
  .cmd-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px; }
  .cmd-item { background: var(--surface); border: 1px solid var(--border);
              border-radius: 8px; padding: 12px 16px; }
  .cmd-code { font-family: monospace; font-size: 13px; color: var(--cyan); font-weight: 600; }
  .cmd-desc { font-size: 12px; color: var(--muted); margin-top: 4px; }

  /* Empty */
  .empty { text-align: center; padding: 80px 0; color: var(--muted); }
  .empty-icon { font-size: 48px; margin-bottom: 16px; }
  .empty-text { font-size: 16px; margin-bottom: 8px; }
  .empty-sub  { font-size: 13px; color: var(--muted); opacity: 0.7; }

  /* Modal */
  .modal-overlay {
    display: none; position: fixed; inset: 0;
    background: rgba(0,0,0,0.7); z-index: 200;
    align-items: center; justify-content: center;
  }
  .modal-overlay.open { display: flex; }
  .modal {
    background: #111827; border: 1px solid var(--border);
    border-radius: 14px; padding: 28px; min-width: 440px; max-width: 560px;
    box-shadow: 0 20px 60px rgba(0,0,0,0.6);
  }
  .modal h3 { font-size: 16px; font-weight: 700; margin-bottom: 16px; }
  .modal pre {
    background: rgba(0,0,0,0.5); border: 1px solid var(--border);
    border-radius: 8px; padding: 16px; font-size: 12px;
    font-family: monospace; line-height: 1.7; color: #a5f3fc;
    overflow-x: auto; white-space: pre;
  }
  .modal-close {
    margin-top: 16px; float: right; cursor: pointer;
    background: rgba(255,255,255,0.08); border: 1px solid var(--border);
    color: var(--text); padding: 7px 18px; border-radius: 7px;
    font-family: var(--font); font-size: 13px; font-weight: 600;
    transition: background 0.15s;
  }
  .modal-close:hover { background: rgba(255,255,255,0.14); }
</style>
</head>
<body>

<header>
  <div class="logo">port<span>man</span></div>
  <div class="stats">
    <div class="stat">
      <div class="stat-value" id="stat-apps">0</div>
      <div class="stat-label">Apps</div>
    </div>
    <div class="stat">
      <div class="stat-value" id="stat-services">0</div>
      <div class="stat-label">Services</div>
    </div>
    <div class="stat">
      <div class="stat-value" id="stat-active" style="color:var(--green)">0</div>
      <div class="stat-label">Active</div>
    </div>
    <div class="stat">
      <div class="stat-value" id="stat-conflicts" style="color:var(--red)">0</div>
      <div class="stat-label">Conflicts</div>
    </div>
  </div>
  <div class="ts" id="gen-ts"></div>
</header>

<main>

  <!-- Conflict banner (shown if conflicts exist) -->
  <div class="conflict-banner" id="conflict-banner" style="display:none">
    <div class="icon">⚠</div>
    <div id="conflict-detail">Port conflicts detected.</div>
  </div>

  <!-- App cards -->
  <h2>Apps &amp; Services</h2>
  <div class="app-grid" id="app-grid">
    <div class="empty" id="empty-state">
      <div class="empty-icon">🗂</div>
      <div class="empty-text">No ports registered yet</div>
      <div class="empty-sub">Run <code>portman new &lt;app&gt;</code> to get started</div>
    </div>
  </div>

  <!-- Port range usage -->
  <div class="ranges-section">
    <h2>Port Range Usage</h2>
    <div class="range-grid" id="range-grid"></div>
  </div>

  <!-- CLI reference -->
  <div class="cmd-section">
    <h2>CLI Quick Reference</h2>
    <div class="cmd-grid">
      <div class="cmd-item"><div class="cmd-code">portman list</div><div class="cmd-desc">List all registered ports</div></div>
      <div class="cmd-item"><div class="cmd-code">portman new &lt;app&gt;</div><div class="cmd-desc">Auto-assign standard services for a new app</div></div>
      <div class="cmd-item"><div class="cmd-code">portman assign svc --app app</div><div class="cmd-desc">Register a specific service (auto-picks port)</div></div>
      <div class="cmd-item"><div class="cmd-code">portman release svc --app app</div><div class="cmd-desc">Free a port assignment</div></div>
      <div class="cmd-item"><div class="cmd-code">portman check</div><div class="cmd-desc">Detect registry conflicts</div></div>
      <div class="cmd-item"><div class="cmd-code">portman scan</div><div class="cmd-desc">Show what is actually listening on your Mac</div></div>
      <div class="cmd-item"><div class="cmd-code">portman export &lt;app&gt;</div><div class="cmd-desc">Print docker-compose ports snippet</div></div>
      <div class="cmd-item"><div class="cmd-code">portman rm &lt;app&gt;</div><div class="cmd-desc">Delete entire app from registry</div></div>
      <div class="cmd-item"><div class="cmd-code">portman dashboard</div><div class="cmd-desc">Regenerate and open this dashboard</div></div>
    </div>
  </div>

</main>

<!-- Export modal -->
<div class="modal-overlay" id="modal" onclick="closeModal(event)">
  <div class="modal">
    <h3 id="modal-title">docker-compose ports</h3>
    <pre id="modal-body"></pre>
    <button class="modal-close" onclick="document.getElementById('modal').classList.remove('open')">Close</button>
  </div>
</div>

<div id="toast">Copied!</div>

<script>
// ── Embedded registry data ────────────────────────────────────────────────────
const REGISTRY   = __REGISTRY_JSON__;
const GENERATED  = "__GENERATED__";
const LIVE_PORTS = __LIVE_PORTS_JSON__;   // set of ports actually in use at gen-time
const INTERNAL_PORTS = __INTERNAL_PORTS_JSON__;
const PORT_RANGES    = __PORT_RANGES_JSON__;

// ── Helpers ───────────────────────────────────────────────────────────────────
function allAssigned() {
  const map = {};
  for (const [app, services] of Object.entries(REGISTRY.apps || {})) {
    for (const [svc, info] of Object.entries(services)) {
      map[info.port] = `${app}/${svc}`;
    }
  }
  return map;
}

function findConflicts() {
  const seen = {}, conflicts = [];
  for (const [app, services] of Object.entries(REGISTRY.apps || {})) {
    for (const [svc, info] of Object.entries(services)) {
      const p = info.port;
      if (seen[p]) conflicts.push({ port: p, a: seen[p], b: `${app}/${svc}` });
      else seen[p] = `${app}/${svc}`;
    }
  }
  return conflicts;
}

function genExport(appName, services) {
  const lines = [
    `# docker-compose.yml — ports for '${appName}'`,
    `# Generated by portman on ${GENERATED.slice(0,10)}`,
    "",
  ];
  const sorted = Object.entries(services).sort((a, b) => a[1].port - b[1].port);
  for (const [svc, info] of sorted) {
    const internal = INTERNAL_PORTS[info.type] || info.port;
    lines.push(`  ${svc}:`);
    lines.push(`    ports:`);
    lines.push(`      - "${info.port}:${internal}"`);
    lines.push("");
  }
  return lines.join("\\n");
}

function toast(msg) {
  const el = document.getElementById("toast");
  el.textContent = msg;
  el.classList.add("show");
  setTimeout(() => el.classList.remove("show"), 1800);
}

function copyPort(port) {
  navigator.clipboard.writeText(String(port)).then(() => toast(`Copied :${port}`));
}

function showExport(appName) {
  const services = REGISTRY.apps[appName];
  document.getElementById("modal-title").textContent = `docker-compose ports — ${appName}`;
  document.getElementById("modal-body").textContent  = genExport(appName, services);
  document.getElementById("modal").classList.add("open");
}

function closeModal(e) {
  if (e.target.id === "modal") document.getElementById("modal").classList.remove("open");
}

// ── Render ────────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", () => {
  const apps = REGISTRY.apps || {};
  document.getElementById("gen-ts").textContent = "Snapshot: " + GENERATED.slice(0, 16).replace("T", " ");

  const conflicts    = findConflicts();
  const conflictPorts = new Set(conflicts.map(c => c.port));
  const appNames     = Object.keys(apps).sort();
  let totalSvcs = 0;
  let totalActive = 0;

  // Stats
  document.getElementById("stat-apps").textContent      = appNames.length;
  document.getElementById("stat-conflicts").textContent = conflicts.length;

  // Conflict banner
  if (conflicts.length) {
    const banner = document.getElementById("conflict-banner");
    banner.style.display = "flex";
    document.getElementById("conflict-detail").innerHTML =
      `<strong>${conflicts.length} port conflict(s):</strong> ` +
      conflicts.map(c => `<strong>:${c.port}</strong> → ${c.a} &amp; ${c.b}`).join(" · ");
  }

  // App grid
  const grid  = document.getElementById("app-grid");
  const empty = document.getElementById("empty-state");
  if (appNames.length === 0) {
    empty.style.display = "block";
  } else {
    empty.style.display = "none";
    for (const appName of appNames) {
      const services = apps[appName];
      const svcs     = Object.entries(services).sort((a, b) => a[1].port - b[1].port);
      const hasConf  = svcs.some(([, i]) => conflictPorts.has(i.port));
      totalSvcs     += svcs.length;

      const rows = svcs.map(([svc, info]) => {
        const active = LIVE_PORTS.includes(info.port);
        const conf   = conflictPorts.has(info.port);
        if (active) totalActive++;
        const dotClass = conf ? "conflict" : active ? "active" : "free";
        return `
          <div class="service-row">
            <div class="dot ${dotClass}"></div>
            <div class="svc-name">${svc}</div>
            <div class="svc-port">:${info.port}</div>
            <div class="svc-type">${info.type || "generic"}</div>
            <div class="svc-copy" onclick="copyPort(${info.port})" title="Copy port">⎘</div>
          </div>`;
      }).join("");

      const card = document.createElement("div");
      card.className = `app-card${hasConf ? " has-conflict" : ""}`;
      card.innerHTML = `
        <div class="app-header">
          <div class="app-name">${appName}</div>
          <div class="app-badge">${svcs.length} service${svcs.length !== 1 ? "s" : ""}</div>
        </div>
        <div class="service-list">${rows}</div>
        <div class="app-footer">
          <button class="btn-sm" onclick="showExport('${appName}')">⎘ docker-compose</button>
        </div>`;
      grid.appendChild(card);
    }

    document.getElementById("stat-services").textContent = totalSvcs;
    document.getElementById("stat-active").textContent   = totalActive;
  }

  // Port range usage bars
  const assigned = allAssigned();
  const rangeGrid = document.getElementById("range-grid");
  for (const [type, [low, high]] of Object.entries(PORT_RANGES)) {
    const total = high - low + 1;
    const used  = Object.keys(assigned).filter(p => parseInt(p) >= low && parseInt(p) <= high).length;
    const pct   = Math.round((used / total) * 100);
    const card  = document.createElement("div");
    card.className = "range-card";
    card.innerHTML = `
      <div class="range-header">
        <span class="range-type">${type}</span>
        <span class="range-ports">${low}–${high}</span>
      </div>
      <div class="range-bar"><div class="range-fill" style="width:${pct}%"></div></div>
      <div class="range-usage">${used} / ${total} ports used (${pct}%)</div>`;
    rangeGrid.appendChild(card);
  }
});
</script>
</body>
</html>'''


def cmd_dashboard(args):
    """Generate a fresh snapshot HTML dashboard and open it in the browser."""
    registry  = load_registry()
    live      = [p for p in range(1, 65536) if is_port_in_use(p)]  # too slow; use registry ports only
    # Only check ports we actually care about
    all_ports = list(all_assigned_ports(registry).keys())
    live      = [p for p in all_ports if is_port_in_use(p)]

    html = DASHBOARD_TEMPLATE \
        .replace("__REGISTRY_JSON__",      json.dumps(registry, indent=2)) \
        .replace('"__GENERATED__"',        json.dumps(datetime.now().isoformat())) \
        .replace("__LIVE_PORTS_JSON__",    json.dumps(live)) \
        .replace("__INTERNAL_PORTS_JSON__", json.dumps(INTERNAL_PORTS)) \
        .replace("__PORT_RANGES_JSON__",   json.dumps(
            {k: list(v) for k, v in PORT_RANGES.items()}
        ))

    dashboard_path = PORTMAN_DIR / "dashboard.html"
    with open(dashboard_path, "w") as f:
        f.write(html)

    print(col(f"\n  ✓  Dashboard generated → {dashboard_path}", C.GREEN))
    print(col("  Opening in browser…\n", C.DIM))
    webbrowser.open(f"file://{dashboard_path}")


def cmd_workspace_init(args):
    """Auto-discover projects in workspace and assign ports."""
    workspace = args.workspace or os.path.expanduser("~/Sandbox")
    if not os.path.isdir(workspace):
        print(col(f"\n  ✗  Workspace not found: {workspace}\n", C.RED))
        sys.exit(1)

    banner(f"workspace init: {workspace}")
    registry = load_registry()
    
    projects = discover_projects(workspace)
    if not projects:
        print(col("  No docker-compose.yml files found in workspace.\n", C.DIM))
        return
    
    assigned_count = 0
    print(f"  Discovered {len(projects)} project(s):\n")
    
    for proj in projects:
        if proj in registry.get("apps", {}):
            print(col(f"  ✓ {proj:<30} (already registered)", C.DIM))
        else:
            registry["apps"][proj] = {}
            for svc_name, svc_type in NEW_APP_SERVICES:
                try:
                    port = next_free_port(svc_type, registry)
                    registry["apps"][proj][svc_name] = {
                        "port": port,
                        "type": svc_type,
                        "assigned": datetime.now().isoformat(),
                    }
                    assigned_count += 1
                except RuntimeError:
                    print(col(f"  ⚠  {proj}: no free ports for {svc_name}", C.YELLOW))
            print(col(f"  ✓ {proj:<30} (5 services)", C.GREEN))
    
    _save(registry)
    print()
    divider()
    print(col(f"  ✓  Assigned ports for {assigned_count // 5} project(s)", C.GREEN))
    print(col(f"  Run `portman list` to view all assignments.", C.DIM))
    print()


def cmd_workspace_list(args):
    """List all projects in workspace with their port counts."""
    workspace = args.workspace or os.path.expanduser("~/Sandbox")
    if not os.path.isdir(workspace):
        print(col(f"\n  ✗  Workspace not found: {workspace}\n", C.RED))
        sys.exit(1)

    banner(f"workspace projects: {workspace}")
    registry = load_registry()
    apps = registry.get("apps", {})
    
    projects = discover_projects(workspace)
    if not projects:
        print(col("  No docker-compose.yml files found.\n", C.DIM))
        return
    
    print(col(f"  {'PROJECT':<30} {'SERVICES':<12} STATUS", C.BOLD))
    divider()
    
    for proj in projects:
        svc_count = len(apps.get(proj, {}))
        if svc_count == 0:
            status = col("not registered", C.YELLOW)
        elif svc_count == 5:
            status = col("✓ ready", C.GREEN)
        else:
            status = col(f"partial ({svc_count}/5)", C.DIM)
        
        print(f"  {proj:<30} {svc_count:<12} {status}")
    
    divider()
    unregistered = len([p for p in projects if p not in apps])
    print(col(f"  {len(projects)} total projects  ·  {unregistered} unregistered", C.DIM))
    print()


def cmd_workspace_report(args):
    """Generate a workspace port assignment report."""
    workspace = args.workspace or os.path.expanduser("~/Sandbox")
    if not os.path.isdir(workspace):
        print(col(f"\n  ✗  Workspace not found: {workspace}\n", C.RED))
        sys.exit(1)

    banner(f"workspace report: {workspace}")
    registry = load_registry()
    apps = registry.get("apps", {})
    
    projects = discover_projects(workspace)
    if not projects:
        print(col("  No projects found.\n", C.DIM))
        return
    
    print(col(f"  {'PROJECT':<25} {'SERVICE':<15} {'PORT':<8} STATUS", C.BOLD))
    divider()
    
    for proj in projects:
        services = apps.get(proj, {})
        if not services:
            print(f"  {col(proj, C.YELLOW):<25} (unregistered)")
            continue
        
        sorted_svcs = sorted(services.items(), key=lambda x: x[1]["port"])
        for i, (svc_name, info) in enumerate(sorted_svcs):
            port = info["port"]
            active = is_port_in_use(port)
            status = col("● active", C.GREEN) if active else col("○ free", C.DIM)
            
            proj_display = proj if i == 0 else ""
            print(f"  {proj_display:<25} {svc_name:<15} {col(str(port), C.BOLD):<8} {status}")
    
    print()


def cmd_sync(args):
    """Sync docker-compose.yml files with portman port registry."""
    workspace = args.workspace or os.path.expanduser("~/Sandbox")
    if not os.path.isdir(workspace):
        print(col(f"\n  ✗  Workspace not found: {workspace}\n", C.RED))
        sys.exit(1)

    banner(f"sync: {workspace}")
    registry = load_registry()
    apps = registry.get("apps", {})
    
    projects = discover_projects(workspace)
    if not projects:
        print(col("  No projects found.\n", C.DIM))
        return
    
    total_changed = 0
    failed_projects = []
    
    for proj in projects:
        services = apps.get(proj, {})
        if not services:
            print(col(f"  ⊘  {proj:<30} (not in registry, skipped)", C.DIM))
            continue
        
        compose_path = Path(workspace) / proj / "docker-compose.yml"
        if not compose_path.exists():
            print(col(f"  ⊘  {proj:<30} (no docker-compose.yml)", C.DIM))
            continue
        
        try:
            with open(compose_path, 'r') as f:
                compose_data = yaml.safe_load(f) or {}
            
            if 'services' not in compose_data:
                compose_data['services'] = {}
            
            changed = False
            change_log = []
            
            # Map portman service names to docker-compose service names
            for portman_svc, info in services.items():
                port = info["port"]
                svc_type = info["type"]
                internal = INTERNAL_PORTS.get(svc_type, 8000)
                port_mapping = f"{port}:{internal}"
                
                # Try to find matching service in docker-compose
                # First, try exact match (service name matches portman key)
                compose_svc = None
                for svc_name in compose_data['services'].keys():
                    if svc_name == portman_svc:
                        compose_svc = svc_name
                        break
                
                # If no exact match, try fuzzy match (service type match)
                if not compose_svc:
                    for svc_name in compose_data['services'].keys():
                        if svc_type in svc_name.lower() or svc_name.lower() in svc_type:
                            compose_svc = svc_name
                            break
                
                # If still no match, skip this service
                if not compose_svc:
                    continue
                
                # Update the port mapping
                if 'ports' not in compose_data['services'][compose_svc]:
                    compose_data['services'][compose_svc]['ports'] = []
                
                # Remove old port mappings for this service type and update
                old_ports = compose_data['services'][compose_svc].get('ports', [])
                new_ports = []
                found_match = False
                port_range = PORT_RANGES.get(svc_type, (9500, 9999))
                
                for p in old_ports:
                    port_str = str(p).split(':')[0] if ':' in str(p) else str(p)
                    # Try to extract the port number
                    try:
                        port_num = int(port_str)
                        # Check if this port is in the service type's range
                        if port_range[0] <= port_num <= port_range[1]:
                            found_match = True
                            new_ports.append(port_mapping)
                            change_log.append(f"{compose_svc}: {p} → {port_mapping}")
                        else:
                            new_ports.append(p)
                    except (ValueError, IndexError):
                        new_ports.append(p)
                
                # If no old port was found in the range, just append
                if not found_match:
                    new_ports.append(port_mapping)
                    change_log.append(f"{compose_svc}: added {port_mapping}")
                
                compose_data['services'][compose_svc]['ports'] = new_ports
                changed = True
            
            if changed:
                with open(compose_path, 'w') as f:
                    yaml.dump(compose_data, f, default_flow_style=False, sort_keys=False)
                
                print(col(f"  ✓ {proj:<30}", C.GREEN), end="")
                print(col(f" ({len(change_log)} update{'s' if len(change_log) != 1 else ''})", C.DIM))
                for log_entry in change_log:
                    print(col(f"      • {log_entry}", C.DIM))
                total_changed += 1
            else:
                print(col(f"  ○ {proj:<30} (no changes needed)", C.DIM))
        
        except Exception as e:
            print(col(f"  ✗ {proj:<30} (error: {str(e)})", C.RED))
            failed_projects.append((proj, str(e)))
    
    divider()
    print(col(f"  ✓  Synced {total_changed} project{'s' if total_changed != 1 else ''}\n", C.GREEN))
    
    if failed_projects:
        print(col(f"  ✗  {len(failed_projects)} project{'s' if len(failed_projects) != 1 else ''} failed:", C.RED))
        for proj, err in failed_projects:
            print(col(f"      • {proj}: {err}", C.DIM))
        print()

# ─── CLI parser ───────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        prog="portman",
        description="Port Manager — track and assign ports across all your container projects",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    sub = parser.add_subparsers(dest="command")

    # list
    p_list = sub.add_parser("list", help="List all registered ports")
    p_list.add_argument("--app", help="Filter to one app")

    # new
    p_new = sub.add_parser("new", help="Auto-assign standard services for a new app")
    p_new.add_argument("app")

    # assign
    p_assign = sub.add_parser("assign", help="Register a service port")
    p_assign.add_argument("service")
    p_assign.add_argument("--app",  required=True)
    p_assign.add_argument("--port", type=int, help="Specific port (auto if omitted)")

    # release
    p_rel = sub.add_parser("release", help="Free a port registration")
    p_rel.add_argument("service")
    p_rel.add_argument("--app", required=True)

    # rm
    p_rm = sub.add_parser("rm", help="Delete an entire app from the registry")
    p_rm.add_argument("app")

    # check
    p_check = sub.add_parser("check", help="Detect conflicts in the registry")
    p_check.add_argument("--app", help="Filter to one app")

    # scan
    sub.add_parser("scan", help="Show what is actually listening on your Mac")

    # export
    p_exp = sub.add_parser("export", help="Print docker-compose ports snippet for an app")
    p_exp.add_argument("app")

    # dashboard
    sub.add_parser("dashboard", help="Generate and open the visual HTML dashboard")

    # sync
    p_sync = sub.add_parser("sync", help="Sync docker-compose.yml files with port registry")
    p_sync.add_argument("workspace", nargs="?", help="Workspace path (default: ~/Sandbox)")

    # workspace
    p_ws = sub.add_parser("workspace", help="Workspace management (auto-discover projects)")
    ws_sub = p_ws.add_subparsers(dest="workspace_cmd")
    
    p_ws_init = ws_sub.add_parser("init", help="Auto-discover projects and assign ports")
    p_ws_init.add_argument("workspace", nargs="?", help="Workspace path (default: ~/Sandbox)")
    
    p_ws_list = ws_sub.add_parser("list", help="List all projects in workspace")
    p_ws_list.add_argument("workspace", nargs="?", help="Workspace path (default: ~/Sandbox)")
    
    p_ws_report = ws_sub.add_parser("report", help="Generate workspace port report")
    p_ws_report.add_argument("workspace", nargs="?", help="Workspace path (default: ~/Sandbox)")

    args = parser.parse_args()

    dispatch = {
        "list":      cmd_list,
        "new":       cmd_new,
        "assign":    cmd_assign,
        "release":   cmd_release,
        "rm":        cmd_rm,
        "check":     cmd_check,
        "scan":      cmd_scan,
        "export":    cmd_export,
        "dashboard": cmd_dashboard,
        "sync":      cmd_sync,
    }

    workspace_dispatch = {
        "init":      cmd_workspace_init,
        "list":      cmd_workspace_list,
        "report":    cmd_workspace_report,
    }

    if args.command == "workspace":
        if args.workspace_cmd in workspace_dispatch:
            workspace_dispatch[args.workspace_cmd](args)
        else:
            p_ws.print_help()
    elif args.command in dispatch:
        dispatch[args.command](args)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()
