#!/usr/bin/env python3
"""MCA SDK CLI tools for project scaffolding and setup.

This module provides command-line tools for:
- Initializing new MCA SDK projects with interactive wizard (mca-init)
- Generating best-practice configuration files (mca.yaml, .env.mca)
- Setting up local development environments with mock collectors
- Providing example code for immediate testing

Interactive Mode (default):
    $ mca-init my-project
    # Prompts for Model ID, Team Name, etc.

Non-Interactive Mode (CI/CD):
    $ mca-init my-project --model-id mdl-001 --team clinical-ai --no-interactive
"""

import argparse
import json
import os
import re
import shlex
import shutil
import sys
import traceback
from pathlib import Path
from typing import Dict, Optional

# Module-level defaults (single source of truth)
DEFAULT_COLLECTOR_ENDPOINT = "http://localhost:4318"
DEFAULT_MODEL_ID = "mdl-001"
DEFAULT_TEAM_NAME = "my-team"
DEFAULT_REGISTRY_URL = ""


def yaml_quote(value: str) -> str:
    """Quote a string for YAML only if it contains special characters.

    Generates clean, idiomatic YAML by only quoting when necessary.
    Prevents YAML type coercion by quoting reserved words and numbers.

    Args:
        value: String to potentially quote for YAML

    Returns:
        Quoted or unquoted string suitable for YAML value

    Example:
        >>> yaml_quote("simple-value")
        'simple-value'
        >>> yaml_quote("true")  # Reserved word
        '"true"'
        >>> yaml_quote("123")  # Number
        '"123"'
    """
    # YAML special characters that require quoting
    special_chars = {
        ":",
        "#",
        "\n",
        "\r",
        "\t",
        "[",
        "]",
        "{",
        "}",
        ",",
        "&",
        "*",
        "!",
        "|",
        ">",
        "@",
        "`",
    }

    # YAML reserved words (case-insensitive) that would be interpreted as booleans/null
    yaml_reserved = {
        "true",
        "false",
        "yes",
        "no",
        "on",
        "off",
        "null",
        "none",
        "~",
        "y",
        "n",  # Single letter booleans
        ".nan",
        ".inf",
        "-.inf",
        "+.inf",  # YAML 1.1 float special values
    }

    # Check if value looks like a number using proper regex
    def looks_like_number(s: str) -> bool:
        if not s:
            return False
        # Regex for integers, floats, and scientific notation
        # Matches: 123, -45, +67, 3.14, -2.5, 1e10, 1.5e-3, etc.
        number_pattern = r"^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$"
        return bool(re.match(number_pattern, s))

    # Check if value starts/ends with quotes or whitespace
    needs_quoting = (
        not value  # Empty string
        or value[0] in {" ", "\t", '"', "'"}  # Starts with space or quote
        or value[-1] in {" ", "\t"}  # Ends with space
        or any(char in value for char in special_chars)  # Contains special chars
        or value.lower() in yaml_reserved  # YAML reserved word
        or looks_like_number(value)  # Looks like a number
    )

    if needs_quoting:
        # Use json.dumps for proper escaping but with ensure_ascii=False
        return json.dumps(value, ensure_ascii=False)
    else:
        # Return unquoted for clean YAML
        return value


def get_template_dir() -> Path:
    """Get the directory containing CLI templates."""
    # Templates are in the same directory as this module
    return Path(__file__).parent / "templates"


def collect_project_config(cli_args: Optional[Dict[str, Optional[str]]] = None) -> Dict[str, str]:
    """Collect project configuration via interactive prompts.

    Prompts user for fields not already provided via CLI arguments.
    Catches KeyboardInterrupt and EOFError to exit gracefully.

    Args:
        cli_args: Optional dict of CLI-provided values (model_id, team_name, etc.)
                  If provided, those fields are skipped in prompts.

    Returns:
        dict with keys: model_id, team_name, service_name, collector_endpoint, registry_url

    Raises:
        SystemExit: On KeyboardInterrupt or EOFError (user aborted setup)

    Example:
        >>> config = collect_project_config()
        >>> # User is prompted for values
        >>> print(config)
        {'model_id': 'mdl-001', 'team_name': 'clinical-ai', ...}
    """
    if cli_args is None:
        cli_args = {}

    try:
        print("=== MCA SDK Project Setup ===\n")

        # Required: Model ID
        _val = cli_args.get("model_id")
        if _val is not None:
            model_id = _val
            print(f"Model ID: {model_id} (from CLI)")
        else:
            model_id = input("Model ID (required, e.g., mdl-001): ").strip()
            while not model_id:
                print("Error: Model ID is required")
                model_id = input("Model ID (required, e.g., mdl-001): ").strip()

        # Required: Team Name
        _val = cli_args.get("team_name")
        if _val is not None:
            team_name = _val
            print(f"Team Name: {team_name} (from CLI)")
        else:
            team_name = input("Team Name (required, e.g., clinical-ai): ").strip()
            while not team_name:
                print("Error: Team name is required")
                team_name = input("Team Name (required, e.g., clinical-ai): ").strip()

        # Optional: Service Name (defaults to model_id)
        _val = cli_args.get("service_name")
        if _val is not None:
            service_name = _val
            print(f"Service Name: {service_name} (from CLI)")
        else:
            service_name = (
                input(f"Service Name (optional, default: {model_id}): ").strip() or model_id
            )

        # Optional: Collector Endpoint
        _val = cli_args.get("collector_endpoint")
        if _val is not None:
            collector_endpoint = _val
            print(f"Collector Endpoint: {collector_endpoint} (from CLI)")
        else:
            collector_endpoint = (
                input(
                    f"Collector Endpoint (optional, default: {DEFAULT_COLLECTOR_ENDPOINT}): "
                ).strip()
                or DEFAULT_COLLECTOR_ENDPOINT
            )

        # Optional: Registry URL
        _val = cli_args.get("registry_url")
        if _val is not None:
            registry_url = _val
            print(f"Registry URL: {registry_url} (from CLI)")
        else:
            registry_url = input("Registry URL (optional, default: none): ").strip() or ""

        return {
            "model_id": model_id,
            "team_name": team_name,
            "service_name": service_name,
            "collector_endpoint": collector_endpoint,
            "registry_url": registry_url,
        }
    except (KeyboardInterrupt, EOFError):
        print("\nSetup aborted by user.")
        sys.exit(1)


def init_project(
    project_dir: str,
    with_mock_collector: bool = True,
    config: Optional[Dict[str, str]] = None,
    interactive: bool = True,
) -> int:
    """Initialize a new MCA SDK project with scaffolding.

    Args:
        project_dir: Directory to create the project in
        with_mock_collector: Whether to include docker-compose.mock.yml
        config: Optional configuration dict with keys: model_id, team_name, service_name,
                collector_endpoint, registry_url. Partial configs are merged with defaults.
        interactive: Whether to prompt for overwrite (False in non-interactive/CI mode)

    Returns:
        Exit code (0 for success, 1 for failure)

    Example:
        >>> config = {"model_id": "mdl-001", "team_name": "test"}
        >>> init_project("/path/to/project", config=config)
        0
    """
    project_path = Path(project_dir).resolve()
    created_directory = False  # Track if we created the directory for rollback
    created_files = []  # Track individual files for granular rollback
    created_dirs = []  # Track created subdirectories for rollback

    # Check for debug mode (opt-in tracebacks)
    debug_mode = os.environ.get("MCA_DEBUG_MODE", "").lower() in ("true", "1", "yes")

    # Merge provided config with defaults (API-friendly)
    default_config = {
        "model_id": DEFAULT_MODEL_ID,
        "team_name": DEFAULT_TEAM_NAME,
        "service_name": "",  # Will be set to model_id if empty
        "collector_endpoint": DEFAULT_COLLECTOR_ENDPOINT,
        "registry_url": DEFAULT_REGISTRY_URL,
    }

    if config is None:
        config = default_config.copy()
    else:
        # Merge: provided values override defaults
        merged_config = default_config.copy()
        merged_config.update(config)
        config = merged_config

    # Set service_name to model_id if not provided
    if not config.get("service_name"):
        config["service_name"] = config["model_id"]

    # Check if path exists and validate it's a directory
    dir_exists = project_path.exists()
    if dir_exists:
        if not project_path.is_dir():
            print(
                f"Error: Path exists and is a file, cannot create project here: {project_path}",
                file=sys.stderr,
            )
            return 1

        # List all files that will be overwritten
        files_to_overwrite = ["mca.yaml", ".env.mca", "example_model.py", "README.md"]
        if with_mock_collector:
            files_to_overwrite.extend(["docker-compose.mock.yml", "config/otel-collector-mock.yml"])

        # In non-interactive mode, error out instead of prompting
        if not interactive:
            print(
                f"Error: Directory {project_path} already exists. "
                f"Cannot overwrite in non-interactive mode. "
                f"Remove the directory or use a different path.",
                file=sys.stderr,
            )
            return 1

        # Interactive mode: prompt with full file list
        files_list = ", ".join(files_to_overwrite)
        try:
            overwrite = (
                input(
                    f"Directory {project_path} already exists. "
                    f"Proceeding will overwrite: {files_list}. "
                    f"Proceed? (y/N): "
                )
                .strip()
                .lower()
            )
        except (EOFError, KeyboardInterrupt):
            print("\nAborted by user.")
            return 1  # Library function should return, not exit

        if overwrite != "y":
            print("Aborted.")
            return 1  # User declined
        else:
            print(f"Warning: Overwriting files in {project_path}")

    # Create project directory if it doesn't exist
    if not dir_exists:
        try:
            project_path.mkdir(parents=True, exist_ok=False)
            created_directory = True  # Mark for potential rollback
            print(f"Created project directory: {project_path}")
        except Exception as e:
            print(f"Error creating directory: {type(e).__name__}: {e}", file=sys.stderr)
            traceback.print_exc()
            return 1

    # Wrap all file operations in try/except for rollback on failure
    try:
        # Copy mock collector files if requested (Story 8.4)
        if with_mock_collector:
            try:
                # Find docker-compose.mock.yml in package
                pkg_root = Path(__file__).parent.parent
                mock_compose = pkg_root / "docker-compose.mock.yml"
                mock_config = pkg_root / "config" / "otel-collector-mock.yml"

                if mock_compose.exists():
                    dest_mock_compose = project_path / "docker-compose.mock.yml"
                    shutil.copy2(mock_compose, dest_mock_compose)
                    created_files.append(dest_mock_compose)  # Track for rollback
                    print("  ✓ Copied docker-compose.mock.yml")
                else:
                    print("  ⚠ docker-compose.mock.yml not found in package", file=sys.stderr)

                if mock_config.exists():
                    config_dir = project_path / "config"
                    config_dir.mkdir(exist_ok=True)
                    created_dirs.append(config_dir)  # Track directory for rollback

                    dest_mock_config = config_dir / "otel-collector-mock.yml"
                    shutil.copy2(mock_config, dest_mock_config)
                    created_files.append(dest_mock_config)  # Track for rollback
                    print("  ✓ Copied config/otel-collector-mock.yml")
                else:
                    print("  ⚠ otel-collector-mock.yml not found in package", file=sys.stderr)

            except Exception as e:
                print(
                    f"Warning: Could not copy mock collector files ({type(e).__name__}: {e})",
                    file=sys.stderr,
                )
                if debug_mode:
                    traceback.print_exc()

        # Create example configuration file
        config_file = project_path / "mca.yaml"
        # Use yaml_quote() for idiomatic YAML (only quotes when necessary)
        # Story 2.7 Fix: Comment out model_id by default to prevent log spam
        # But include model_id if explicitly provided via CLI
        if config["registry_url"]:
            registry_section = f"# Registry integration (optional)\nregistry_url: {yaml_quote(config['registry_url'])}\nmodel_id: {yaml_quote(config['model_id'])}\n"
        elif config.get("model_id") and config["model_id"] != DEFAULT_MODEL_ID:
            # Model ID provided via CLI but no registry URL
            registry_section = f"# Registry integration (optional in all environments)\n# registry_url: https://registry.example.com/api/v1\nmodel_id: {yaml_quote(config['model_id'])}\n"
        else:
            registry_section = "# Registry integration (optional in all environments)\n# registry_url: https://registry.example.com/api/v1\n# model_id: your-model-id\n"
        config_file.write_text(f"""# MCA SDK Configuration
# Story 8.4: Environment-aware configuration profiles

# Environment: staging (local testing), dev, or prod
environment: staging

# Service identification
service_name: {yaml_quote(config["service_name"])}
model_version: 1.0.0
team_name: {yaml_quote(config["team_name"])}

# Model classification
model_type: regression  # regression, classification, time-series, generative, agentic
model_category: internal  # internal or vendor

# OpenTelemetry Collector endpoint
collector_endpoint: {yaml_quote(config["collector_endpoint"])}

{registry_section}# registry_token: set via MCA_REGISTRY_TOKEN environment variable

# For more options, see: https://github.com/your-org/mca-sdk
""")
        created_files.append(config_file)  # Track for rollback
        print("  ✓ Created mca.yaml configuration template")

        # Create example Python file
        example_file = project_path / "example_model.py"
        # Use json.dumps with ensure_ascii=False for non-ASCII characters (Python strings need quotes)
        example_file.write_text(f'''"""Example instrumented model using MCA SDK."""

from mca_sdk import MCAClient

# Initialize MCA client (loads from mca.yaml and environment variables)
client = MCAClient(
    service_name={json.dumps(config["service_name"], ensure_ascii=False)},
    model_id={json.dumps(config["model_id"], ensure_ascii=False)},
    team_name={json.dumps(config["team_name"], ensure_ascii=False)}
)


@client.predict()
def predict(features: dict) -> dict:
    """Example prediction function with automatic instrumentation."""
    # Your model logic here
    prediction = {{"result": "positive", "confidence": 0.85}}
    return prediction


if __name__ == "__main__":
    # Example usage
    result = predict({{"feature1": 1.0, "feature2": 2.0}})
    print(f"Prediction: {{result}}")

    # Shutdown (flushes telemetry)
    client.shutdown()
''')
        created_files.append(example_file)  # Track for rollback
        print("  ✓ Created example_model.py")

        # Create .env.mca template
        env_file = project_path / ".env.mca"
        # Use shlex.quote() for proper shell escaping (prevents $() substitution)
        # Single quotes in shell prevent ALL expansions (unlike double quotes)

        # Handle registry URL edge case: if empty, use commented placeholder
        if config["registry_url"]:
            registry_line = f"MCA_REGISTRY_URL={shlex.quote(config['registry_url'])}"
        else:
            registry_line = "# MCA_REGISTRY_URL='https://registry.example.com/api/v1'"

        env_content = f"""# MCA SDK Environment Variables
# Copy this to .env and customize for your environment
# Do NOT commit .env to version control (add to .gitignore)

# Required: Model identification
MCA_MODEL_ID={shlex.quote(config["model_id"])}
MCA_TEAM_NAME={shlex.quote(config["team_name"])}
MCA_SERVICE_NAME={shlex.quote(config["service_name"])}
MCA_MODEL_VERSION='1.0.0'

# Model classification
MCA_MODEL_TYPE='regression'
MCA_MODEL_CATEGORY='internal'

# OpenTelemetry Collector endpoint
MCA_COLLECTOR_ENDPOINT={shlex.quote(config["collector_endpoint"])}

# Registry integration (optional - uncomment to enable)
{registry_line}
# MCA_REGISTRY_TOKEN='your-token-here'

# Debug mode (optional)
# MCA_DEBUG_MODE='true'

# Environment selection (staging, dev, prod)
MCA_ENV='staging'
"""
        env_file.write_text(env_content)
        created_files.append(env_file)  # Track for rollback
        print("  ✓ Created .env.mca environment template")

        # Create README
        readme = project_path / "README.md"
        readme.write_text(f"""# {project_path.name}

MCA SDK instrumented model project

## Quick Start

### Local Development (Staging)

1. Start the mock collector:
   ```bash
   docker compose -f docker-compose.mock.yml up -d
   ```

2. Run your model:
   ```bash
   export MCA_ENV=staging
   python example_model.py
   ```

3. View traces in Jaeger:
   http://localhost:16686

### Production Deployment

Set required environment variables:
```bash
export MCA_ENV=prod
export MCA_COLLECTOR_ENDPOINT=https://otel-collector.example.com:4318
export MCA_REGISTRY_URL=https://registry.example.com/api/v1
export MCA_REGISTRY_TOKEN=your-token-here
```

## Configuration

See `mca.yaml` for configuration options.
See `.env.mca` for environment variable template.

## Documentation

- [MCA SDK Documentation](https://github.com/your-org/mca-sdk)
- Story 8.4: Environment-Aware Configuration Profiles
""")
        created_files.append(readme)  # Track for rollback
        print("  ✓ Created README.md")

    except Exception as e:
        # Granular rollback: Remove only files we created
        print(f"\nError: Failed to initialize project: {type(e).__name__}: {e}", file=sys.stderr)
        if debug_mode:
            traceback.print_exc()

        # Remove individual files (safer than rmtree)
        for file_path in created_files:
            try:
                if file_path.exists():
                    file_path.unlink()
                    print(f"Rolled back: Removed {file_path.name}", file=sys.stderr)
            except Exception as cleanup_error:
                print(
                    f"Warning: Could not remove {file_path.name}: {cleanup_error}", file=sys.stderr
                )

        # Remove created subdirectories (in reverse order)
        for dir_path in reversed(created_dirs):
            try:
                if dir_path.exists():
                    dir_path.rmdir()  # Only succeeds if empty
                    print(f"Rolled back: Removed directory {dir_path.name}", file=sys.stderr)
            except OSError:
                # Directory not empty - skip
                print(f"Note: Directory {dir_path.name} not removed (not empty)", file=sys.stderr)

        # If we created the directory and it's now empty, remove it safely
        if created_directory and project_path.exists():
            try:
                project_path.rmdir()  # Only succeeds if empty - safe!
                print(f"Rolled back: Removed empty directory {project_path}", file=sys.stderr)
            except OSError:
                # Directory not empty (has other files) - don't remove
                print(
                    f"Note: Directory {project_path} not removed (contains other files)",
                    file=sys.stderr,
                )

        return 1

    print(f"\n✓ Project initialized successfully at {project_path}")
    print("\nNext steps:")
    print(f"  cd {project_path}")
    if with_mock_collector:
        print("  docker compose -f docker-compose.mock.yml up -d")
    print("  python example_model.py")
    return 0


def main() -> int:
    """Main entry point for mca-init CLI."""
    parser = argparse.ArgumentParser(
        description="Initialize a new MCA SDK project with scaffolding",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Interactive mode (default when stdin is TTY)
  mca-init my-project

  # Non-interactive mode (for CI/CD or scripts)
  mca-init my-project --model-id mdl-001 --team clinical-ai --no-interactive

  # Skip mock collector
  mca-init my-project --no-mock

For more information, see Story 8.3: MCA Init Scaffolding CLI
""",
    )
    parser.add_argument("project_dir", help="Directory to create the project in")
    parser.add_argument(
        "--no-mock",
        action="store_true",
        help="Skip docker-compose.mock.yml scaffolding",
    )
    parser.add_argument(
        "--interactive",
        action="store_true",
        default=sys.stdin.isatty(),
        help="Enable interactive prompts (default: auto-detect TTY)",
    )
    parser.add_argument(
        "--no-interactive",
        dest="interactive",
        action="store_false",
        help="Disable interactive prompts (use defaults or CLI args)",
    )

    # Non-interactive config options
    parser.add_argument("--model-id", help="Model ID (non-interactive mode)")
    parser.add_argument("--team", dest="team_name", help="Team name (non-interactive mode)")
    parser.add_argument("--service-name", help="Service name (non-interactive mode)")
    parser.add_argument("--collector-endpoint", help="Collector endpoint (non-interactive mode)")
    parser.add_argument("--registry-url", help="Registry URL (non-interactive mode)")

    parser.add_argument(
        "--debug",
        action="store_true",
        help="Enable debug mode (show full tracebacks on errors)",
    )
    parser.add_argument("--version", action="version", version="mca-init 0.4.0 (Story 8.3)")

    args = parser.parse_args()

    # Set debug mode environment variable if flag provided
    if args.debug:
        os.environ["MCA_DEBUG_MODE"] = "true"

    # Collect configuration
    if args.interactive:
        # Interactive mode: pass CLI args to respect them, prompt for missing fields
        cli_args = {
            "model_id": args.model_id,
            "team_name": args.team_name,
            "service_name": args.service_name,
            "collector_endpoint": args.collector_endpoint,
            "registry_url": args.registry_url,
        }
        config = collect_project_config(cli_args)
    else:
        # Non-interactive mode: require model_id and team_name
        if not args.model_id or not args.team_name:
            parser.error("--model-id and --team are required in non-interactive mode")

        # Use CLI args or defaults (unified with module constants)
        # Check 'is not None' to respect explicit empty strings
        model_id = args.model_id
        team_name = args.team_name
        service_name = args.service_name if args.service_name is not None else model_id
        collector_endpoint = (
            args.collector_endpoint
            if args.collector_endpoint is not None
            else DEFAULT_COLLECTOR_ENDPOINT
        )
        registry_url = args.registry_url if args.registry_url is not None else DEFAULT_REGISTRY_URL

        config = {
            "model_id": model_id,
            "team_name": team_name,
            "service_name": service_name,
            "collector_endpoint": collector_endpoint,
            "registry_url": registry_url,
        }

    return init_project(
        args.project_dir,
        with_mock_collector=not args.no_mock,
        config=config,
        interactive=args.interactive,
    )


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