# source: https://raw.githubusercontent.com/ikonclast/bob_der_botmeister/da05d31149b2f7577580f600e6bb591310274fe0/execution/deployers/paper_deployer.py
#!/usr/bin/env python3
"""
Paper Trading Deployer
======================

Automated deployment of qualified candidates to dry-run/paper trading.

Features:
    - Per-candidate isolated database
    - Docker container management (start/stop/monitor)
    - Health checks and auto-restart
    - State tracking and logging
    - Integration with ranking system

Usage:
    from execution.deployers import PaperDeployer, DeployConfig
    
    config = DeployConfig(
        candidate_id="run_001",
        strategy_name="BreakoutV1",
        params={"stoploss": -0.05},
        pairs=["BTC/USDT", "ETH/USDT"],
        dry_run=True
    )
    
    deployer = PaperDeployer(config)
    deployment = deployer.deploy()
    
    if deployment.state == DeploymentState.RUNNING:
        print(f"✅ Deployed: {deployment.container_id}")

Safety:
    - dry_run=True enforced (no live trading)
    - Isolated DB per candidate
    - Resource limits (CPU, RAM)
    - Automatic cleanup on failure
"""

import json
import logging
import re
import subprocess
import time
import hashlib
from dataclasses import dataclass, field, asdict
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple


class DeploymentState(Enum):
    """Deployment lifecycle states."""
    PENDING = "pending"
    CREATING = "creating"
    STARTING = "starting"
    RUNNING = "running"
    HEALTHY = "healthy"
    DEGRADED = "degraded"
    STOPPING = "stopping"
    STOPPED = "stopped"
    FAILED = "failed"
    CLEANED = "cleaned"


@dataclass
class DeployConfig:
    """
    Configuration for paper trading deployment.
    
    All candidates MUST have dry_run=True (enforced in code).
    """
    # Identification
    candidate_id: str
    strategy_name: str
    run_id: Optional[str] = None
    
    # Strategy config
    params: Dict[str, Any] = field(default_factory=dict)
    pairs: List[str] = field(default_factory=lambda: ["BTC/USDT", "ETH/USDT"])
    timeframe: str = "5m"
    
    # Deployment settings
    dry_run: bool = True  # ALWAYS True, enforced in code
    stake_amount: float = 100.0
    max_open_trades: int = 5
    wallet_balance: float = 1000.0
    
    # Resource limits
    memory_limit: str = "512m"
    cpu_limit: float = 1.0
    
    # Paths (auto-generated if not provided)
    base_dir: Optional[Path] = None
    data_dir: Path = Path("/opt/docker/freqtrade/shared_data/user_data/data")
    
    # Docker settings
    image: str = "freqtradeorg/freqtrade:stable"
    docker_host: str = "tcp://socket-proxy:2375"
    
    # Monitoring
    health_check_interval: int = 60
    auto_restart: bool = True
    max_restarts: int = 3
    
    def __post_init__(self):
        """Ensure safety constraints."""
        # FORCE dry_run - never allow live trading
        if not self.dry_run:
            raise ValueError("dry_run must be True - live trading not allowed")
        
        # Generate base_dir if not provided
        if self.base_dir is None:
            self.base_dir = Path(f"/opt/docker/freqtrade/paper_deployments/{self.candidate_id}")
        else:
            self.base_dir = Path(self.base_dir)
        
        # Generate run_id if not provided
        if self.run_id is None:
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            hash_suffix = hashlib.md5(self.candidate_id.encode()).hexdigest()[:6]
            self.run_id = f"paper_{timestamp}_{hash_suffix}"
        
        # Validate pairs
        if not self.pairs:
            self.pairs = ["BTC/USDT", "ETH/USDT"]


@dataclass
class DeploymentStatus:
    """Current deployment status."""
    state: DeploymentState
    container_id: Optional[str] = None
    container_name: Optional[str] = None
    started_at: Optional[str] = None
    last_check: Optional[str] = None
    health_checks_passed: int = 0
    health_checks_failed: int = 0
    restarts: int = 0
    error_message: Optional[str] = None
    
    # Performance metrics (if available)
    current_profit: Optional[float] = None
    trade_count: Optional[int] = None
    open_trades: Optional[int] = None
    
    def is_active(self) -> bool:
        """Check if deployment is actively running."""
        return self.state in [DeploymentState.RUNNING, DeploymentState.HEALTHY]
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary."""
        return asdict(self)


class PaperDeployer:
    """
    Deploys qualified candidates to dry-run trading.
    
    Builds on existing setup_paper_trading.py patterns but with:
    - Better isolation (per-candidate DB)
    - State management
    - Health monitoring
    - Clean start/stop lifecycle
    """
    
    def __init__(self, config: DeployConfig):
        """
        Initialize deployer.
        
        Args:
            config: Deployment configuration
        """
        self.config = config
        self.status = DeploymentStatus(state=DeploymentState.PENDING)
        
        # Setup logging
        self.logger = logging.getLogger(f"paper_deployer.{config.candidate_id}")
        
        # Directories
        self.user_data_dir = self.config.base_dir / "user_data"
        self.configs_dir = self.user_data_dir / "configs"
        self.strategies_dir = self.user_data_dir / "strategies"
        self.logs_dir = self.user_data_dir / "logs"
        self.db_dir = self.user_data_dir / "db"
        
        # Container name (deterministic)
        self.container_name = f"bob-paper-{self.config.candidate_id[:20]}"
        
        # Tracked for cleanup
        self._created_files: List[Path] = []
        self._created_dirs: List[Path] = []
    
    def _run_docker(self, command: List[str], **kwargs) -> Tuple[int, str, str]:
        """
        Execute Docker command.
        
        Args:
            command: Docker command and arguments
            **kwargs: Additional subprocess args
            
        Returns:
            Tuple of (exit_code, stdout, stderr)
        """
        env = {"DOCKER_HOST": self.config.docker_host}
        
        try:
            result = subprocess.run(
                command,
                capture_output=True,
                text=True,
                env=env,
                timeout=kwargs.get('timeout', 300)
            )
            return result.returncode, result.stdout, result.stderr
        except subprocess.TimeoutExpired:
            return -1, "", "Docker command timeout"
        except Exception as e:
            return -2, "", str(e)
    
    def _create_directories(self) -> bool:
        """Create isolated directory structure."""
        try:
            self.logger.info(f"📁 Creating directories in {self.config.base_dir}")
            
            dirs = [
                self.user_data_dir,
                self.configs_dir,
                self.strategies_dir,
                self.logs_dir,
                self.db_dir
            ]
            
            for d in dirs:
                d.mkdir(parents=True, exist_ok=True)
                self._created_dirs.append(d)
                self.logger.debug(f"  Created: {d}")
            
            return True
            
        except Exception as e:
            self.logger.error(f"❌ Failed to create directories: {e}")
            self.status.error_message = str(e)
            return False
    
    def _generate_config(self) -> bool:
        """Generate freqtrade configuration."""
        try:
            self.logger.info(f"⚙️  Generating config")
            
            config = {
                "max_open_trades": self.config.max_open_trades,
                "stake_currency": "USDT",
                "stake_amount": self.config.stake_amount,
                "dry_run": True,  # ENFORCED
                "dry_run_wallet": self.config.wallet_balance,
                "timeframe": self.config.timeframe,
                "fee": 0.001,
                "trading_mode": "spot",
                "exchange": {
                    "name": "binance",
                    "pair_whitelist": self.config.pairs,
                    "ccxt_config": {"enableRateLimit": True}
                },
                "pairlists": [
                    {"method": "StaticPairList"},
                    {"method": "AgeFilter", "min_days_listed": 30}
                ],
                "entry_pricing": {
                    "price_side": "other",
                    "use_order_book": True,
                    "order_book_top": 1
                },
                "exit_pricing": {
                    "price_side": "other",
                    "use_order_book": True,
                    "order_book_top": 1
                },
                "telegram": {"enabled": False},  # Disable for paper
                "api_server": {
                    "enabled": False  # Disable API for safety
                },
                "bot_name": self.container_name,
                "initial_state": "running",
                "force_entry_enable": False,
                "strategy": self.config.strategy_name
            }
            
            # Add stoploss/trailing from params
            if 'stoploss' in self.config.params:
                config['stoploss'] = self.config.params['stoploss']
            if 'trailing' in self.config.params:
                config['trailing_stop'] = True
                config['trailing_stop_positive'] = self.config.params.get('trailing', 0.015)
            
            # Database path (isolated per candidate)
            db_path = self.db_dir / f"{self.config.candidate_id}.sqlite"
            config['db_url'] = f"sqlite:///{db_path}"
            
            config_file = self.configs_dir / "config.json"
            with open(config_file, 'w') as f:
                json.dump(config, f, indent=2)
            
            self._created_files.append(config_file)
            self.logger.info(f"  Config: {config_file}")
            self.logger.info(f"  Database: {db_path}")
            
            return True
            
        except Exception as e:
            self.logger.error(f"❌ Failed to generate config: {e}")
            self.status.error_message = str(e)
            return False
    
    def _generate_strategy(self) -> bool:
        """Generate strategy file from stored code or create placeholder."""
        try:
            self.logger.info(f"📄 Generating strategy: Github_ikonclast_bob_der_botmeister__paper_deployer__20260716_095920")
            
            # Check if strategy file exists elsewhere
            strategy_source = self._find_strategy_source()
            
            if strategy_source:
                # Copy existing strategy
                import shutil
                dest = self.strategies_dir / f"Github_ikonclast_bob_der_botmeister__paper_deployer__20260716_095920.py"
                shutil.copy(strategy_source, dest)
                self._created_files.append(dest)
                self.logger.info(f"  Copied from: {strategy_source}")
            else:
                # Create placeholder (user must provide strategy)
                strategy_code = self._generate_placeholder_strategy()
                dest = self.strategies_dir / f"Github_ikonclast_bob_der_botmeister__paper_deployer__20260716_095920.py"
                with open(dest, 'w') as f:
                    f.write(strategy_code)
                self._created_files.append(dest)
                self.logger.info(f"  Created placeholder (needs real strategy)")
            
            return True
            
        except Exception as e:
            self.logger.error(f"❌ Failed to generate strategy: {e}")
            self.status.error_message = str(e)
            return False
    
    def _find_strategy_source(self) -> Optional[Path]:
        """Find strategy file in common locations."""
        search_paths = [
            Path("/opt/docker/freqtrade/user_data/strategies"),
            Path("/opt/docker/freqtrade/shared_data/user_data/strategies"),
            Path(__file__).parent.parent.parent / "strategies"
        ]
        
        for base in search_paths:
            candidate = base / f"Github_ikonclast_bob_der_botmeister__paper_deployer__20260716_095920.py"
            if candidate.exists():
                return candidate
        
        return None
    
    def _generate_placeholder_strategy(self) -> str:
        """Generate placeholder strategy template."""
        params_str = json.dumps(self.config.params, indent=4)
        
        return f'''"""Placeholder strategy - replace with real implementation"""
from freqtrade.strategy import IStrategy
from pandas import DataFrame

class Github_ikonclast_bob_der_botmeister__paper_deployer__20260716_095920(IStrategy):
    timeframe = '{self.config.timeframe}'
    stoploss = {self.config.params.get('stoploss', -0.10)}
    trailing_stop = {str('trailing' in self.config.params).lower()}
    
    minimal_roi = {{"0": 0.05}}
    
    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        raise NotImplementedError("Replace with real strategy")
    
    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        raise NotImplementedError("Replace with real strategy")
    
    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        raise NotImplementedError("Replace with real strategy")

# Config params: {params_str}
'''
    
    def _stop_existing(self) -> bool:
        """Stop and remove existing container with same name."""
        try:
            self.logger.info(f"🛑 Checking for existing container")
            
            # Check if container exists
            code, stdout, _ = self._run_docker(
                ["docker", "ps", "-a", "-q", "-f", f"name={self.container_name}"]
            )
            
            if stdout.strip():
                self.logger.info(f"  Stopping existing container...")
                self._run_docker(["docker", "stop", self.container_name], timeout=30)
                self._run_docker(["docker", "rm", self.container_name], timeout=30)
                self.logger.info(f"  ✅ Cleaned up")
            else:
                self.logger.info(f"  No existing container")
            
            return True
            
        except Exception as e:
            self.logger.warning(f"  Warning: Cleanup issue: {e}")
            return True  # Continue anyway
    
    def _start_container(self) -> bool:
        """Start Docker container."""
        try:
            self.logger.info(f"🚀 Starting container: {self.container_name}")
            self.status.state = DeploymentState.STARTING
            
            # Build command
            cmd = [
                "docker", "run", "-d",
                "--name", self.container_name,
                "--network", "host",
                "--memory", self.config.memory_limit,
                "--cpus", str(self.config.cpu_limit),
                "--restart", "unless-stopped" if self.config.auto_restart else "no",
                "-e", f"DOCKER_HOST={self.config.docker_host}",
                "-e", "PYTHONHASHSEED=0",
                "-v", f"{self.user_data_dir}:/freqtrade/user_data:rw",
                "-v", f"{self.config.data_dir}:/freqtrade/user_data/data:ro",
                self.config.image,
                "trade",
                "--strategy", self.config.strategy_name,
                "--config", "/freqtrade/user_data/configs/config.json",
                "--datadir", "/freqtrade/user_data/data/binance",
                "--db-url", f"sqlite:////freqtrade/user_data/db/{self.config.candidate_id}.sqlite"
            ]
            
            self.logger.debug(f"  Command: {' '.join(cmd[:20])}...")
            
            code, stdout, stderr = self._run_docker(cmd, timeout=60)
            
            if code != 0:
                self.status.state = DeploymentState.FAILED
                self.status.error_message = f"Docker failed: {stderr}"
                self.logger.error(f"❌ Container failed: {stderr}")
                return False
            
            container_id = stdout.strip()
            self.status.container_id = container_id[:12]
            self.status.container_name = self.container_name
            self.status.state = DeploymentState.RUNNING
            self.status.started_at = datetime.now().isoformat()
            
            self.logger.info(f"✅ Container started: {container_id[:12]}")
            
            # Wait a moment and verify
            time.sleep(2)
            return self._verify_container()
            
        except Exception as e:
            self.status.state = DeploymentState.FAILED
            self.status.error_message = str(e)
            self.logger.error(f"❌ Exception during start: {e}")
            return False
    
    def _verify_container(self) -> bool:
        """Verify container is actually running."""
        code, stdout, _ = self._run_docker(
            ["docker", "ps", "-q", "-f", f"name={self.container_name}"]
        )
        
        if stdout.strip():
            self.status.state = DeploymentState.RUNNING
            self.logger.info(f"✅ Container verified running")
            return True
        else:
            self.status.state = DeploymentState.FAILED
            self.status.error_message = "Container not found in docker ps"
            self.logger.error(f"❌ Container not running")
            return False
    
    def deploy(self) -> DeploymentStatus:
        """
        Execute full deployment.
        
        Returns:
            Current deployment status
        """
        self.logger.info(f"🎯 Starting deployment for {self.config.candidate_id}")
        self.status.state = DeploymentState.CREATING
        
        # 1. Create directories
        if not self._create_directories():
            self.status.state = DeploymentState.FAILED
            return self.status
        
        # 2. Generate config
        if not self._generate_config():
            self._cleanup()
            self.status.state = DeploymentState.FAILED
            return self.status
        
        # 3. Generate strategy
        if not self._generate_strategy():
            self._cleanup()
            self.status.state = DeploymentState.FAILED
            return self.status
        
        # 4. Stop existing
        self._stop_existing()
        
        # 5. Start container
        if not self._start_container():
            self._cleanup()
            return self.status
        
        self.logger.info(f"✅ Deployment complete: {self.status.container_id}")
        return self.status
    
    def get_status(self) -> DeploymentStatus:
        """Get current deployment status."""
        if self.status.container_id:
            code, stdout, _ = self._run_docker(
                ["docker", "ps", "-q", "-f", f"name={self.container_name}"]
            )
            
            if not stdout.strip():
                # Container stopped
                if self.status.state not in [DeploymentState.STOPPING, DeploymentState.STOPPED]:
                    self.status.state = DeploymentState.FAILED
                    self.status.error_message = "Container stopped unexpectedly"
        
        self.status.last_check = datetime.now().isoformat()
        return self.status
    
    def stop(self) -> bool:
        """Stop deployment."""
        self.logger.info(f"🛑 Stopping deployment")
        self.status.state = DeploymentState.STOPPING
        
        try:
            self._run_docker(["docker", "stop", self.container_name], timeout=30)
            self._run_docker(["docker", "rm", self.container_name], timeout=30)
            
            self.status.state = DeploymentState.STOPPED
            self.logger.info(f"✅ Deployment stopped")
            return True
            
        except Exception as e:
            self.logger.error(f"❌ Error stopping: {e}")
            return False
    
    def logs(self, tail: int = 100) -> str:
        """Get container logs."""
        if not self.status.container_id:
            return "Container not running"
        
        code, stdout, _ = self._run_docker(
            ["docker", "logs", "--tail", str(tail), self.container_name]
        )
        
        return stdout if code == 0 else f"Error: {stdout}"
    
    def _cleanup(self):
        """Clean up on failure."""
        self.logger.info(f"🧹 Cleaning up")
        
        try:
            # Stop container if running
            self._run_docker(["docker", "stop", self.container_name], timeout=10)
            self._run_docker(["docker", "rm", self.container_name], timeout=10)
            
            # Remove created files
            for f in self._created_files:
                if f.exists():
                    f.unlink()
                    self.logger.debug(f"  Removed: {f}")
            
        except Exception as e:
            self.logger.warning(f"  Cleanup warning: {e}")


def demo():
    """Demonstrate deployer usage."""
    logging.basicConfig(level=logging.INFO)
    
    print("=" * 70)
    print("PAPER DEPLOYER DEMO")
    print("=" * 70)
    
    config = DeployConfig(
        candidate_id="demo_candidate_001",
        strategy_name="EXPLORATION_TEST",
        params={"stoploss": -0.05, "trailing": 0.02},
        pairs=["BTC/USDT", "ETH/USDT"],
        dry_run=True  # Enforced
    )
    
    print(f"\nCandidate: {config.candidate_id}")
    print(f"Strategy: {config.strategy_name}")
    print(f"DB Path: {config.base_dir}/db/{config.candidate_id}.sqlite")
    print(f"Container: bob-paper-{config.candidate_id[:20]}")
    
    deployer = PaperDeployer(config)
    
    print("\n" + "-" * 70)
    print("Deployment would create:")
    print("-" * 70)
    print(f"  Directory: {config.base_dir}")
    print(f"  Config: {config.base_dir}/user_data/configs/config.json")
    print(f"  Strategy: {config.base_dir}/user_data/strategies/{config.strategy_name}.py")
    print(f"  Database: {config.base_dir}/user_data/db/{config.candidate_id}.sqlite")
    print(f"  Container: bob-paper-{config.candidate_id[:20]}")
    
    print("\n" + "-" * 70)
    print("Status tracking:")
    print("-" * 70)
    print(f"  state: {deployer.status.state.value}")
    print(f"  container: {deployer.status.container_name}")
    
    print("\n" + "-" * 70)
    print("Note: This was a dry-run demo (no actual deployment)")
    print("-" * 70)


if __name__ == "__main__":
    demo()
