#!/usr/bin/env python3
"""Workspace setup, validation, and configuration dispatcher.

All subcommands are cloud-only. Routes through the unified client's relay
backend for provider operations.

Usage:
    python workspace.py validate
    python workspace.py list-labels
    python workspace.py refresh-labels [--fix]
    python workspace.py list-statuses
    python workspace.py list-providers
    python workspace.py set-provider linear
    python workspace.py list-teams
    python workspace.py create-team --name NAME [--key KEY] [--description DESC] [--parent ID]
    python workspace.py set-team TEAM_ID
    python workspace.py set-labels --defaults '["app"]' --type-map '{"feature":["Feature"]}'
    python workspace.py set-status-mapping --auto
    python workspace.py set-status-mapping --mapping '{"BACKLOG":"Backlog",...}'
    python workspace.py set-identity linear USER_ID
    python workspace.py set-preferences [--workspace ID] [--assignee SELF] [--display JSON]
    python workspace.py get-estimate-scale
    python workspace.py get-me
    python workspace.py set-active-project PROJECT_ID
    python workspace.py set-active-sprint SPRINT_ID|current|next|previous
    python workspace.py clear-active-sprint
    # add-active-project and remove-active-project removed (ADR-011: singular activeProjectId)
    python workspace.py clear-active-projects
"""

import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

sys.path.insert(0, str(Path(__file__).parent))
from flydocs_api import get_client, output_json, fail, find_project_root, stdin_has_data
from status_vocab import ALL_STATUSES, status_list


# ---------------------------------------------------------------------------
# Human-readable messages for validation checks
# ---------------------------------------------------------------------------

CHECK_MESSAGES: dict[str, str] = {
    "provider": "No provider connected — configure in FlyDocs dashboard",
    "team": "No team selected — configure in FlyDocs dashboard",
    "statusMapping": "Status mapping not configured — configure in FlyDocs dashboard",
    "labelConfig": "Label config not configured — configure in FlyDocs dashboard",
    "userIdentity": (
        "Provider identity not linked — link your identity in the FlyDocs dashboard "
        "profile page, or run: workspace.py set-identity <provider> <your-account-id>"
    ),
    "repos": "No repos linked — GitHub features won't work until you push and link a repo",
}

DEFAULT_MESSAGE = "Not configured — check FlyDocs dashboard"

VALID_PROVIDERS = ("linear", "jira")

# Fields owned by the server — overwritten from generate response
SERVER_OWNED_FIELDS = {
    "workspaceId",
    "setupComplete",
    "workspace",
    "issueLabels",
}

# Fields owned locally — never overwritten by generate
LOCAL_ONLY_FIELDS = {
    "version",
    "sourceRepo",
    "tier",
    "paths",
    "detectedStack",
    "skills",
    "designSystem",
    "aiLabor",
}


# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------

def _check_integrity(client: "FlyDocsClient") -> dict:
    """Check install integrity against .flydocs/integrity.json."""
    integrity_path = client.project_root / ".flydocs" / "integrity.json"
    if not integrity_path.exists():
        return {"checked": False, "reason": "integrity.json not found"}

    try:
        data = json.loads(integrity_path.read_text())
    except (json.JSONDecodeError, OSError):
        return {"checked": False, "reason": "integrity.json unreadable"}

    missing_files = []
    for f in data.get("ownedFiles", []):
        if not (client.project_root / f).exists():
            missing_files.append(f)

    missing_dirs = []
    for d in data.get("ownedDirectories", []):
        if not (client.project_root / d).exists():
            missing_dirs.append(d)

    return {
        "checked": True,
        "valid": len(missing_files) == 0 and len(missing_dirs) == 0,
        "version": data.get("version", "unknown"),
        "missingFiles": missing_files,
        "missingDirectories": missing_dirs,
    }


def _try_auto_resolve_identity(client: "FlyDocsClient") -> bool:
    """Attempt to auto-resolve provider identity via get-me. Returns True if resolved."""
    try:
        result = client.relay.get("/auth/me")
    except Exception:
        return False

    provider_id = result.get("providerId")
    if not provider_id:
        # Check providerIdentities array as fallback
        identities = result.get("providerIdentities", [])
        if identities:
            provider_id = identities[0].get("providerId")

    if not provider_id:
        return False

    # Write me.json — API returns "name" not "displayName"
    me_data = {
        "displayName": result.get("name") or result.get("displayName"),
        "email": result.get("email"),
        "providerId": provider_id,
        "provider": result.get("provider"),
        "providerIdentities": result.get("providerIdentities", []),
        "preferences": result.get("preferences", {}),
    }

    me_path = client.project_root / ".flydocs" / "me.json"
    me_path.parent.mkdir(parents=True, exist_ok=True)
    me_path.write_text(json.dumps(me_data, indent=2) + "\n")
    return True


def cmd_validate(args: argparse.Namespace) -> None:
    """Validate workspace setup via GET /auth/config."""
    client = get_client()
    client.require_cloud("validate")

    config_response = client.relay.get("/auth/config")

    is_valid = config_response.get("valid", False)
    missing_keys: list[str] = config_response.get("missing", [])
    warning_keys: list[str] = config_response.get("warnings", [])

    # Auto-resolve identity if missing — try get-me before requiring manual step
    if "userIdentity" in missing_keys or "userIdentity" in warning_keys:
        if _try_auto_resolve_identity(client):
            missing_keys = [k for k in missing_keys if k != "userIdentity"]
            warning_keys = [k for k in warning_keys if k != "userIdentity"]
            # Re-check validity — if userIdentity was the only missing item, we're valid now
            if not missing_keys:
                is_valid = True

    # Build structured missing/warning lists with messages
    missing = [
        {"check": k, "action": CHECK_MESSAGES.get(k, DEFAULT_MESSAGE)}
        for k in missing_keys
    ]
    warnings = [
        {"check": k, "action": CHECK_MESSAGES.get(k, DEFAULT_MESSAGE)}
        for k in warning_keys
    ]

    # Build checks map for cache
    all_keys = set(CHECK_MESSAGES.keys())
    checks: dict[str, bool] = {}
    for k in all_keys:
        checks[k] = k not in missing_keys and k not in warning_keys

    # Build workspace info from response
    workspace = config_response.get("workspace", {})
    provider_type = config_response.get("provider", {}).get("type", "unknown")

    # Write validation cache
    cache = {
        "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        "valid": is_valid,
        "workspace": {
            "id": workspace.get("id", ""),
            "name": workspace.get("name", ""),
        },
        "provider": provider_type,
        "checks": checks,
        "missing": missing_keys,
        "warnings": warning_keys,
    }

    cache_path = client.project_root / ".flydocs" / "validation-cache.json"
    cache_path.parent.mkdir(parents=True, exist_ok=True)
    with open(cache_path, "w") as f:
        json.dump(cache, f, indent=2)
        f.write("\n")

    # If all required checks pass, set setupComplete in config
    if is_valid:
        config_path = client.config_path
        if config_path.exists():
            with open(config_path, "r") as f:
                local_config = json.load(f)
        else:
            local_config = {}

        local_config["setupComplete"] = True
        with open(config_path, "w") as f:
            json.dump(local_config, f, indent=2)
            f.write("\n")

    # Check install integrity
    integrity = _check_integrity(client)

    # Check activeProjectId (ADR-011: singular, local validation)
    active_project_id = client.config.get("activeProjectId")
    # Migration: fall back to old activeProjects[0]
    if not active_project_id:
        ap = client.config.get("activeProjects", [])
        if ap:
            active_project_id = ap[0]

    # Output structured report
    report: dict = {
        "valid": is_valid,
        "checks": checks,
        "passed": [k for k, v in checks.items() if v],
        "integrity": integrity,
        "activeProjects": {
            "set": bool(active_project_id),
            "count": 1 if active_project_id else 0,
            "ids": [active_project_id] if active_project_id else [],
        },
    }
    if not active_project_id:
        report.setdefault("warnings", [])
        if isinstance(report["warnings"], list):
            report["warnings"].append({
                "check": "activeProjectId",
                "action": "No active project set — run: workspace.py set-active-project <PROJECT_ID>",
            })
    if missing:
        report["missing"] = missing
    if warnings:
        report["warnings"] = warnings
    if is_valid:
        report["setupComplete"] = True

    output_json(report)


def cmd_list_labels(args: argparse.Namespace) -> None:
    """List available team labels."""
    client = get_client()
    client.require_cloud("list-labels")
    result = client.relay.get("/labels")
    output_json(result)


def cmd_refresh_labels(args: argparse.Namespace) -> None:
    """Refresh label config — validate and optionally fix stale IDs."""
    client = get_client()
    client.require_cloud("refresh-labels")

    # Fetch current labels from relay
    labels = client.relay.get("/labels")
    label_map = {label["id"]: label["name"] for label in labels}
    label_by_name = {label["name"].lower(): label["id"] for label in labels}

    # Load local config
    config_path = client.config_path
    if not config_path.exists():
        fail("No .flydocs/config.json found")

    with open(config_path, "r") as f:
        config = json.load(f)

    issue_labels = config.get("issueLabels", {})
    stale: list[dict] = []
    valid: list[dict] = []

    # Check each label ID in config against relay
    for category, entries in issue_labels.items():
        if isinstance(entries, dict):
            for key, label_id in entries.items():
                if label_id in label_map:
                    valid.append({
                        "category": category,
                        "key": key,
                        "id": label_id,
                        "name": label_map[label_id],
                    })
                else:
                    # Try to find by key name
                    resolved = label_by_name.get(key.lower())
                    stale.append({
                        "category": category,
                        "key": key,
                        "staleId": label_id,
                        "resolvedId": resolved,
                        "resolvedName": key if resolved else None,
                    })

    if args.fix and stale:
        # Update stale IDs in config
        fixed = 0
        for item in stale:
            if item["resolvedId"]:
                issue_labels[item["category"]][item["key"]] = item["resolvedId"]
                fixed += 1

        with open(config_path, "w") as f:
            json.dump(config, f, indent=2)
            f.write("\n")

        output_json({
            "success": True,
            "valid": len(valid),
            "stale": len(stale),
            "fixed": fixed,
            "unfixable": len(stale) - fixed,
            "details": stale,
        })
    else:
        output_json({
            "valid": len(valid),
            "stale": len(stale),
            "totalProviderLabels": len(labels),
            "details": stale if stale else "All label IDs are current",
            "hint": "Run with --fix to update stale IDs" if stale else None,
        })


def cmd_list_statuses(args: argparse.Namespace) -> None:
    """List provider workflow states."""
    client = get_client()
    client.require_cloud("list-statuses")
    result = client.relay.get("/auth/statuses")
    output_json(result)


def cmd_list_providers(args: argparse.Namespace) -> None:
    """List available providers."""
    client = get_client()
    client.require_cloud("list-providers")
    result = client.relay.get("/providers")
    output_json(result)


def cmd_set_provider(args: argparse.Namespace) -> None:
    """Set provider preference."""
    client = get_client()
    client.require_cloud("set-provider")
    result = client.relay.post("/auth/provider", {"providerType": args.provider_type})
    output_json(result)


def cmd_list_teams(args: argparse.Namespace) -> None:
    """List available teams/projects."""
    client = get_client()
    client.require_cloud("list-teams")
    result = client.relay.get("/teams")
    output_json(result)


def cmd_create_team(args: argparse.Namespace) -> None:
    """Create a team/project."""
    client = get_client()
    client.require_cloud("create-team")

    body: dict = {"name": args.name}
    if args.key:
        body["key"] = args.key
    if args.description:
        body["description"] = args.description
    if args.parent:
        body["parentId"] = args.parent

    result = client.relay.post("/teams", body)
    output_json({
        "id": result["id"],
        "name": result["name"],
        "key": result.get("key", ""),
    })


def cmd_list_boards(args: argparse.Namespace) -> None:
    """List boards in the connected project (FLY-693).

    Calls GET /api/relay/boards. Returns boards with id, name, type
    (scrum/kanban/simple), and active sprint info for scrum boards.
    """
    client = get_client()
    client.require_cloud("list-boards")
    result = client.relay.get("/boards")
    boards = result if isinstance(result, list) else result.get("boards", [])
    output_json(boards)


def cmd_set_team(args: argparse.Namespace) -> None:
    """Set team/project preference."""
    client = get_client()
    client.require_cloud("set-team")
    result = client.relay.post("/auth/team", {"teamId": args.team_id})
    output_json(result)


def cmd_set_project_mapping(args: argparse.Namespace) -> None:
    """Push this repo's projectMapsTo to the relay (FLY-1149).

    Per-repo, not per-workspace: the Jira project is the workspace boundary, so
    how a FlyDocs project maps onto it belongs to the repo. With no argument the
    value is read from `.flydocs/config.json`, which is where a developer would
    naturally set it.
    """
    client = get_client()
    client.require_cloud("set-project-mapping")

    value = args.mapping
    if value is None:
        value = client.config.get("projectMapsTo")
        if not value:
            fail(
                "No mapping given and none in .flydocs/config.json. "
                'Pass one of: epic, component, none'
            )
        print(f"Using projectMapsTo from .flydocs/config.json: {value}",
              file=sys.stderr)

    if value not in ("epic", "component", "none"):
        fail(f'Invalid mapping "{value}" — must be one of: epic, component, none')

    result = client.relay.post("/config/project-mapping", {"projectMapsTo": value})
    output_json(result)


def cmd_set_labels(args: argparse.Namespace) -> None:
    """Set label config on the relay."""
    client = get_client()
    client.require_cloud("set-labels")

    # Build body from flags or stdin
    if args.defaults is not None or args.type_map is not None:
        body: dict = {}
        if args.defaults is not None:
            try:
                body["defaults"] = json.loads(args.defaults)
            except json.JSONDecodeError:
                fail("Invalid JSON for --defaults")
        if args.type_map is not None:
            try:
                body["typeMap"] = json.loads(args.type_map)
            except json.JSONDecodeError:
                fail("Invalid JSON for --type-map")
    elif stdin_has_data():
        # FLY-699: stdin_has_data() is non-blocking; isatty() returned False
        # on open-but-empty subprocess pipes and caused hangs.
        try:
            body = json.loads(sys.stdin.read().strip())
        except json.JSONDecodeError:
            fail("Invalid JSON on stdin")
    else:
        fail("Provide --defaults/--type-map flags or pipe JSON via stdin")

    result = client.relay.post("/auth/labels", body)
    output_json(result)


def cmd_set_status_mapping(args: argparse.Namespace) -> None:
    """Set status mapping on the relay."""
    client = get_client()
    client.require_cloud("set-status-mapping")

    if args.auto:
        body: dict = {"mapping": "auto"}
    elif args.mapping is not None:
        try:
            body = {"mapping": json.loads(args.mapping)}
        except json.JSONDecodeError:
            fail("Invalid JSON for --mapping")
    elif stdin_has_data():
        # FLY-699: Non-blocking stdin check (see cmd_set_labels).
        try:
            body = json.loads(sys.stdin.read().strip())
        except json.JSONDecodeError:
            fail("Invalid JSON on stdin")
    else:
        fail("Provide --auto, --mapping '{...}', or pipe JSON via stdin")

    _validate_mapping_keys(body.get("mapping"))

    result = client.relay.post("/auth/statuses", body)
    output_json(result)


def _validate_mapping_keys(mapping: object) -> None:
    """Reject a status mapping keyed on names FlyDocs does not know (FLY-1272).

    `statusMapping` is per-workspace data — which provider state each canonical
    status resolves to — so the values are none of our business. The keys are:
    they are the vocabulary in `status_vocab.py`. A typo'd or invented key is
    accepted silently by the relay and then never matches anything, which is
    how a workspace ends up with a mapping that looks configured and maps
    nothing. Partial mappings stay legal; only unknown keys fail.
    """
    if not isinstance(mapping, dict):
        return  # "auto", or a shape the relay will reject on its own terms
    unknown = [key for key in mapping if str(key).strip().upper() not in ALL_STATUSES]
    if unknown:
        fail(
            f"Unknown status name(s) in mapping: {', '.join(sorted(unknown))}\n"
            f"Keys must be canonical FlyDocs statuses: {status_list()}"
        )


def cmd_set_identity(args: argparse.Namespace) -> None:
    """Set provider identity and write me.json."""
    provider = args.provider.lower()
    if provider not in VALID_PROVIDERS:
        fail(f"Invalid provider: {provider}. Must be one of: {', '.join(VALID_PROVIDERS)}")

    provider_user_id = args.provider_user_id
    if not provider_user_id:
        fail("Provider user ID cannot be empty")

    client = get_client()
    client.require_cloud("set-identity")

    result = client.relay.post("/auth/identity", {
        "provider": provider,
        "providerId": provider_user_id,
    })

    # Write me.json for local identity resolution
    me_data = {
        "provider": result.get("provider", provider),
        "providerId": result.get("providerId", provider_user_id),
        "displayName": result.get("name") or result.get("displayName"),
        "email": result.get("email"),
    }
    me_path = client.project_root / ".flydocs" / "me.json"
    me_path.parent.mkdir(parents=True, exist_ok=True)
    me_path.write_text(json.dumps(me_data, indent=2) + "\n")

    output_json({
        "success": result.get("success", True),
        "provider": me_data["provider"],
        "providerId": me_data["providerId"],
        "meJson": str(me_path),
    })


def cmd_set_preferences(args: argparse.Namespace) -> None:
    """Get or set user preferences."""
    client = get_client()
    client.require_cloud("set-preferences")

    # If no flags provided, GET current preferences
    if args.workspace is None and args.assignee is None and args.display is None:
        result = client.relay.get("/auth/preferences")
        output_json(result)
        return

    # Build update body from provided flags
    body: dict = {}
    if args.workspace is not None:
        body["defaultWorkspaceId"] = args.workspace
    if args.assignee is not None:
        body["defaultAssignee"] = args.assignee
    if args.display is not None:
        body["displayPreferences"] = args.display

    result = client.relay.post("/auth/preferences", body)
    output_json({
        "success": result.get("success", True),
        "preferences": result.get("preferences", body),
    })


def cmd_get_estimate_scale(args: argparse.Namespace) -> None:
    """Get the provider's estimate scale."""
    client = get_client()
    client.require_cloud("get-estimate-scale")
    result = client.relay.get("/auth/estimates")
    output_json(result)



def _update_active_project(project_root: Path, config_path: Path,
                           operation: str, project_id: str | None = None) -> dict:
    """Read config, modify activeProjectId, write config.

    ADR-011: activeProjectId is a singular string (was activeProjects array).
    """
    if config_path.exists():
        with open(config_path, "r") as f:
            config = json.load(f)
    else:
        config = {}

    if operation == "set":
        new_value = project_id or None
    elif operation == "clear":
        new_value = None
    else:
        new_value = config.get("activeProjectId")

    config["activeProjectId"] = new_value
    # Clean up old array format
    config.pop("activeProjects", None)

    with open(config_path, "w") as f:
        json.dump(config, f, indent=2)
        f.write("\n")

    return {
        "success": True,
        "activeProjectId": new_value,
    }


def cmd_set_active_project(args: argparse.Namespace) -> None:
    """Set the active project."""
    client = get_client()
    # Validate project exists on cloud tier
    if client.is_cloud:
        projects = client.list_projects(show_all=True)
        if not any(p["id"] == args.project_id for p in projects):
            fail(f"Project not found: {args.project_id}")
    result = _update_active_project(
        client.project_root, client.config_path, "set", args.project_id
    )
    output_json(result)


def cmd_clear_active_projects(args: argparse.Namespace) -> None:
    """Clear the active project."""
    client = get_client()
    result = _update_active_project(
        client.project_root, client.config_path, "clear"
    )
    output_json(result)


def _update_active_sprint(
    project_root: Path, config_path: Path, sprint_id: str | None
) -> dict:
    """FLY-655/699: Read config, set activeSprintId, write config."""
    if config_path.exists():
        with open(config_path, "r") as f:
            config = json.load(f)
    else:
        config = {}
    fmt = config.get("configFormat", 1)
    if fmt >= 2:
        # v2/v3: top-level
        if sprint_id is None:
            config.pop("activeSprintId", None)
        else:
            config["activeSprintId"] = sprint_id
    else:
        workspace = config.setdefault("workspace", {})
        if sprint_id is None:
            workspace.pop("activeSprintId", None)
        else:
            workspace["activeSprintId"] = sprint_id
    config_path.parent.mkdir(parents=True, exist_ok=True)
    with open(config_path, "w") as f:
        json.dump(config, f, indent=2)
        f.write("\n")
    return {
        "success": True,
        "activeSprintId": sprint_id,
    }


def cmd_set_active_sprint(args: argparse.Namespace) -> None:
    """
    FLY-655/656: Set the active sprint for this workspace.

    Accepts a sprint ID or alias (current / next / previous).
    """
    client = get_client()
    sprint_id = args.sprint_id

    # Resolve aliases via the same helper semantics as assign-sprint
    if sprint_id and sprint_id.lower() in ("current", "next", "previous", "prev"):
        alias = sprint_id.lower()
        if alias == "current":
            sprints = client.list_sprints(active=True)
            if sprints:
                sprint_id = str(sprints[0].get("id", ""))
            else:
                current_sprints = client.list_sprints(current=True)
                if current_sprints:
                    sprint_id = str(current_sprints[0].get("id", ""))
                else:
                    fail(
                        "No currently-active sprint found. "
                        "Run 'flydocs list-sprints' to see available sprints."
                    )
        elif alias == "next":
            sprints = client.list_sprints(future=True)
            if sprints:
                sprint_id = str(sprints[0].get("id", ""))
            else:
                fail("No future sprint found.")
        elif alias in ("previous", "prev"):
            sprints = client.list_sprints(closed=True)
            if sprints:
                sprint_id = str(sprints[-1].get("id", ""))
            else:
                fail("No previous closed sprint found.")
        print(
            f"Note: Resolved '{args.sprint_id}' → sprint ID {sprint_id}",
            file=sys.stderr,
        )

    result = _update_active_sprint(
        client.project_root, client.config_path, sprint_id
    )
    output_json(result)


def cmd_clear_active_sprint(args: argparse.Namespace) -> None:
    """FLY-655/656: Clear the active sprint for this workspace."""
    client = get_client()
    result = _update_active_sprint(
        client.project_root, client.config_path, None
    )
    output_json(result)


def _load_api_key(project_root: Path) -> Optional[str]:
    """Load API key — same resolution as flydocs_api.py RelayBackend."""
    # 1. Environment variable
    if os.environ.get("FLYDOCS_API_KEY"):
        return os.environ["FLYDOCS_API_KEY"]
    # 2. Global credential file (written by flydocs init)
    cred_file = Path.home() / ".flydocs" / "credentials"
    if cred_file.exists():
        try:
            cred_data = json.loads(cred_file.read_text())
            key = cred_data.get("apiKey") or cred_data.get("api_key")
            if key:
                return key
        except (json.JSONDecodeError, OSError):
            pass
    # 3. Legacy per-project env files
    for name in [".env.local", ".env"]:
        env_file = project_root / name
        if env_file.exists():
            with open(env_file, "r") as f:
                for line in f:
                    line = line.strip()
                    if line.startswith("#") or "=" not in line:
                        continue
                    k, _, v = line.partition("=")
                    if k.strip() == "FLYDOCS_API_KEY":
                        v = v.strip().strip("\"'")
                        return v if v else None
    return None


def _resolve_base_url() -> str:
    """Resolve relay base URL from environment (for get-me direct HTTP)."""
    env_url = os.environ.get("FLYDOCS_RELAY_URL")
    if env_url:
        return env_url.rstrip("/")
    return "https://app.flydocs.ai/api/relay"


def cmd_get_me(args: argparse.Namespace) -> None:
    """Fetch current user identity via direct HTTP (no client/workspace needed)."""
    import urllib.request
    import urllib.error

    project_root = find_project_root()
    api_key = _load_api_key(project_root)
    if not api_key:
        fail(
            "API key not found.\n"
            "Checked: FLYDOCS_API_KEY env var, ~/.flydocs/credentials, .env.local\n"
            "Run `flydocs init` or `flydocs auth` to set up credentials."
        )

    base_url = _resolve_base_url()
    url = f"{base_url}/auth/me"

    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    }

    try:
        req = urllib.request.Request(url, headers=headers, method="GET")
        with urllib.request.urlopen(req, timeout=15) as resp:
            result = json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8") if e.fp else ""
        try:
            error_data = json.loads(error_body)
        except json.JSONDecodeError:
            error_data = {"error": error_body}
        fail(f"API error ({e.code}): {error_data.get('error', 'Unknown')}")
    except (urllib.error.URLError, TimeoutError):
        fail("Network error: unable to reach relay API")

    # Write me.json — API returns "name" not "displayName"
    me_data = {
        "displayName": result.get("name") or result.get("displayName"),
        "email": result.get("email"),
        "providerId": result.get("providerId"),
        "provider": result.get("provider"),
        "providerIdentities": result.get("providerIdentities", []),
        "preferences": result.get("preferences", {}),
    }

    me_path = project_root / ".flydocs" / "me.json"
    me_path.parent.mkdir(parents=True, exist_ok=True)
    me_path.write_text(json.dumps(me_data, indent=2) + "\n")

    output_json({
        "success": True,
        "displayName": me_data["displayName"],
        "email": me_data["email"],
        "provider": me_data["provider"],
        "meJson": str(me_path),
    })


# ---------------------------------------------------------------------------
# CLI parser
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(
        description="FlyDocs workspace setup and configuration"
    )
    sub = parser.add_subparsers(dest="command", required=True)

    # validate
    sub.add_parser("validate", help="Validate workspace setup")

    # list-labels
    sub.add_parser("list-labels", help="List available team labels")

    # refresh-labels
    rl = sub.add_parser("refresh-labels", help="Refresh label config from relay")
    rl.add_argument("--fix", action="store_true", help="Update stale label IDs")

    # list-statuses
    sub.add_parser("list-statuses", help="List provider workflow states")

    # list-providers
    sub.add_parser("list-providers", help="List available providers")

    # set-provider
    sp = sub.add_parser("set-provider", help="Set provider preference")
    sp.add_argument(
        "provider_type",
        choices=["linear", "jira"],
        help="Provider type",
    )

    # list-teams
    sub.add_parser("list-teams", help="List available teams/projects")

    # list-boards (FLY-693)
    sub.add_parser("list-boards", help="List boards in connected project (Jira)")

    # create-team
    ct = sub.add_parser("create-team", help="Create a team/project")
    ct.add_argument("--name", required=True, help="Team name")
    ct.add_argument("--key", default=None, help="Team key (e.g., PROD)")
    ct.add_argument("--description", default=None, help="Team description")
    ct.add_argument("--parent", default=None, help="Parent team ID")

    # set-team
    st = sub.add_parser("set-team", help="Set team/project preference")
    st.add_argument("team_id", help="Provider team/workspace UUID")

    # set-labels
    spm = sub.add_parser(
        "set-project-mapping",
        help="Set this repo's projectMapsTo on the relay (epic|component|none)",
    )
    spm.add_argument(
        "mapping", nargs="?", default=None,
        choices=["epic", "component", "none"],
        help="Mapping to set. Omit to read it from .flydocs/config.json",
    )
    sl = sub.add_parser("set-labels", help="Set label config on relay")
    sl.add_argument("--defaults", default=None, help="JSON array of default label names")
    sl.add_argument(
        "--type-map", default=None, dest="type_map",
        help="JSON object mapping issue types to label arrays",
    )

    # set-status-mapping
    ssm = sub.add_parser("set-status-mapping", help="Set status mapping on relay")
    ssm.add_argument(
        "--auto", action="store_true",
        help="Auto-map provider states to FlyDocs statuses",
    )
    ssm.add_argument("--mapping", default=None, help="JSON mapping object")

    # set-identity
    si = sub.add_parser("set-identity", help="Set provider identity")
    si.add_argument("provider", help="Provider type (linear, jira)")
    si.add_argument("provider_user_id", help="Provider-specific user ID")

    # set-preferences
    spref = sub.add_parser("set-preferences", help="Get or set user preferences")
    spref.add_argument("--workspace", default=None, help="Default workspace ID")
    spref.add_argument("--assignee", default=None, help="Default assignee")
    spref.add_argument("--display", default=None, help="Display preferences (JSON)")

    # get-estimate-scale
    sub.add_parser("get-estimate-scale", help="Get provider estimate scale")

    # get-me
    sub.add_parser("get-me", help="Fetch current user identity")

    # set-active-project (ADR-011: singular activeProjectId)
    sap = sub.add_parser("set-active-project", help="Set the active project")
    sap.add_argument("project_id", help="Project UUID")

    # clear-active-projects
    sub.add_parser("clear-active-projects", help="Clear the active project")

    # FLY-655/656: set-active-sprint
    sas = sub.add_parser(
        "set-active-sprint",
        help="Set the workspace's active sprint (id or current|next|previous)",
    )
    sas.add_argument("sprint_id", help="Sprint ID or alias")

    # FLY-655/656: clear-active-sprint
    sub.add_parser("clear-active-sprint", help="Clear the workspace's active sprint")

    args = parser.parse_args()

    commands = {
        "validate": cmd_validate,
        "list-labels": cmd_list_labels,
        "refresh-labels": cmd_refresh_labels,
        "list-statuses": cmd_list_statuses,
        "list-providers": cmd_list_providers,
        "set-provider": cmd_set_provider,
        "list-teams": cmd_list_teams,
        "list-boards": cmd_list_boards,
        "create-team": cmd_create_team,
        "set-team": cmd_set_team,
        "set-project-mapping": cmd_set_project_mapping,
        "set-labels": cmd_set_labels,
        "set-status-mapping": cmd_set_status_mapping,
        "set-identity": cmd_set_identity,
        "set-preferences": cmd_set_preferences,
        "get-estimate-scale": cmd_get_estimate_scale,
        "get-me": cmd_get_me,
        "set-active-project": cmd_set_active_project,
        "clear-active-projects": cmd_clear_active_projects,
        "set-active-sprint": cmd_set_active_sprint,
        "clear-active-sprint": cmd_clear_active_sprint,
    }
    commands[args.command](args)


if __name__ == "__main__":
    main()
