#!/usr/bin/env python3
"""
GENESIS BACKGROUND SCHEDULER
=============================
Advanced job scheduling with cron-like syntax and async support.

Features:
    - Cron expression parsing
    - Interval-based scheduling
    - One-time scheduled tasks
    - Async job support
    - Job dependencies
    - Retry with backoff
    - Job persistence
    - Concurrency control

Usage:
    scheduler = BackgroundScheduler()

    @scheduler.job(interval=60)
    def periodic_task():
        print("Running every 60 seconds")

    @scheduler.cron("0 * * * *")
    def hourly_task():
        print("Running every hour")

    scheduler.start()
"""

import asyncio
import heapq
import json
import re
import threading
import time
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from functools import wraps
from pathlib import Path
from typing import Dict, List, Any, Optional, Callable, Union, Set
import uuid


class JobState(Enum):
    """Job execution states."""
    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"
    PAUSED = "paused"


class ScheduleType(Enum):
    """Types of job schedules."""
    ONCE = "once"           # Run once at specific time
    INTERVAL = "interval"   # Run at fixed intervals
    CRON = "cron"          # Cron expression
    IMMEDIATE = "immediate" # Run immediately


@dataclass
class CronExpression:
    """Parsed cron expression."""
    minute: Set[int]    # 0-59
    hour: Set[int]      # 0-23
    day: Set[int]       # 1-31
    month: Set[int]     # 1-12
    weekday: Set[int]   # 0-6 (0=Sunday)

    @classmethod
    def parse(cls, expression: str) -> 'CronExpression':
        """Parse a cron expression string."""
        parts = expression.strip().split()
        if len(parts) != 5:
            raise ValueError(f"Invalid cron expression: {expression}")

        return cls(
            minute=cls._parse_field(parts[0], 0, 59),
            hour=cls._parse_field(parts[1], 0, 23),
            day=cls._parse_field(parts[2], 1, 31),
            month=cls._parse_field(parts[3], 1, 12),
            weekday=cls._parse_field(parts[4], 0, 6)
        )

    @staticmethod
    def _parse_field(field: str, min_val: int, max_val: int) -> Set[int]:
        """Parse a single cron field."""
        result = set()

        if field == '*':
            return set(range(min_val, max_val + 1))

        for part in field.split(','):
            if '/' in part:
                # Step value: */5 or 0-30/5
                base, step = part.split('/')
                step = int(step)
                if base == '*':
                    start, end = min_val, max_val
                elif '-' in base:
                    start, end = map(int, base.split('-'))
                else:
                    start = int(base)
                    end = max_val
                result.update(range(start, end + 1, step))

            elif '-' in part:
                # Range: 1-5
                start, end = map(int, part.split('-'))
                result.update(range(start, end + 1))

            else:
                # Single value
                result.add(int(part))

        return result

    def matches(self, dt: datetime) -> bool:
        """Check if datetime matches this cron expression."""
        return (
            dt.minute in self.minute and
            dt.hour in self.hour and
            dt.day in self.day and
            dt.month in self.month and
            dt.weekday() in self.weekday
        )

    def next_run(self, after: datetime = None) -> datetime:
        """Calculate next run time after given datetime."""
        if after is None:
            after = datetime.now()

        # Start from next minute
        current = after.replace(second=0, microsecond=0) + timedelta(minutes=1)

        # Search for next matching time (max 1 year ahead)
        max_date = current + timedelta(days=366)

        while current < max_date:
            if self.matches(current):
                return current
            current += timedelta(minutes=1)

        raise ValueError("No matching time found within 1 year")


@dataclass
class JobConfig:
    """Job configuration."""
    id: str
    name: str
    func: Callable
    schedule_type: ScheduleType
    interval_seconds: Optional[float] = None
    cron_expr: Optional[CronExpression] = None
    run_at: Optional[datetime] = None
    args: tuple = field(default_factory=tuple)
    kwargs: Dict[str, Any] = field(default_factory=dict)
    max_retries: int = 3
    retry_delay: float = 5.0
    timeout: float = None
    is_async: bool = False
    enabled: bool = True
    tags: List[str] = field(default_factory=list)
    max_instances: int = 1  # Concurrent instances allowed


@dataclass
class JobExecution:
    """Record of a job execution."""
    job_id: str
    execution_id: str
    started_at: str
    ended_at: Optional[str] = None
    state: JobState = JobState.PENDING
    result: Any = None
    error: Optional[str] = None
    retries: int = 0
    duration_ms: float = 0.0


@dataclass
class ScheduledJob:
    """A job scheduled for execution."""
    next_run: datetime
    config: JobConfig

    def __lt__(self, other):
        return self.next_run < other.next_run


class BackgroundScheduler:
    """
    Background job scheduler.
    """

    def __init__(
        self,
        max_workers: int = 4,
        persist_path: Path = None
    ):
        self.max_workers = max_workers
        self.persist_path = persist_path

        self._jobs: Dict[str, JobConfig] = {}
        self._heap: List[ScheduledJob] = []
        self._running: Dict[str, int] = {}  # job_id -> running count
        self._executions: List[JobExecution] = []

        self._executor = ThreadPoolExecutor(max_workers=max_workers)
        self._running_flag = False
        self._scheduler_thread: Optional[threading.Thread] = None
        self._lock = threading.RLock()

        # Callbacks
        self._on_job_start: List[Callable[[str], None]] = []
        self._on_job_complete: List[Callable[[JobExecution], None]] = []
        self._on_job_error: List[Callable[[str, Exception], None]] = []

        # Restore if path exists
        if persist_path and persist_path.exists():
            self._restore()

    def add_job(
        self,
        func: Callable,
        name: str = None,
        schedule_type: ScheduleType = ScheduleType.INTERVAL,
        interval: float = None,
        cron: str = None,
        run_at: datetime = None,
        args: tuple = None,
        kwargs: Dict = None,
        max_retries: int = 3,
        timeout: float = None,
        tags: List[str] = None,
        enabled: bool = True
    ) -> str:
        """Add a job to the scheduler."""
        with self._lock:
            job_id = str(uuid.uuid4())[:8]

            # Determine schedule type
            if cron:
                schedule_type = ScheduleType.CRON
                cron_expr = CronExpression.parse(cron)
            else:
                cron_expr = None

            if run_at:
                schedule_type = ScheduleType.ONCE

            if interval:
                schedule_type = ScheduleType.INTERVAL

            config = JobConfig(
                id=job_id,
                name=name or func.__name__,
                func=func,
                schedule_type=schedule_type,
                interval_seconds=interval,
                cron_expr=cron_expr,
                run_at=run_at,
                args=args or (),
                kwargs=kwargs or {},
                max_retries=max_retries,
                timeout=timeout,
                is_async=asyncio.iscoroutinefunction(func),
                enabled=enabled,
                tags=tags or []
            )

            self._jobs[job_id] = config
            self._running[job_id] = 0

            # Schedule first run
            if enabled:
                self._schedule_next(config)

            return job_id

    def job(
        self,
        interval: float = None,
        cron: str = None,
        name: str = None,
        **kwargs
    ):
        """Decorator to register a job."""
        def decorator(func):
            self.add_job(
                func,
                name=name or func.__name__,
                interval=interval,
                cron=cron,
                **kwargs
            )
            return func
        return decorator

    def cron(self, expression: str, name: str = None, **kwargs):
        """Decorator for cron-scheduled jobs."""
        return self.job(cron=expression, name=name, **kwargs)

    def interval(self, seconds: float, name: str = None, **kwargs):
        """Decorator for interval-scheduled jobs."""
        return self.job(interval=seconds, name=name, **kwargs)

    def remove_job(self, job_id: str) -> bool:
        """Remove a job from the scheduler."""
        with self._lock:
            if job_id in self._jobs:
                del self._jobs[job_id]
                if job_id in self._running:
                    del self._running[job_id]
                # Remove from heap
                self._heap = [j for j in self._heap if j.config.id != job_id]
                heapq.heapify(self._heap)
                return True
            return False

    def pause_job(self, job_id: str) -> bool:
        """Pause a job."""
        with self._lock:
            if job_id in self._jobs:
                self._jobs[job_id].enabled = False
                return True
            return False

    def resume_job(self, job_id: str) -> bool:
        """Resume a paused job."""
        with self._lock:
            if job_id in self._jobs:
                self._jobs[job_id].enabled = True
                self._schedule_next(self._jobs[job_id])
                return True
            return False

    def run_now(self, job_id: str) -> bool:
        """Run a job immediately."""
        with self._lock:
            if job_id not in self._jobs:
                return False

            config = self._jobs[job_id]
            self._executor.submit(self._run_job, config)
            return True

    def _schedule_next(self, config: JobConfig):
        """Schedule the next run of a job."""
        with self._lock:
            if not config.enabled:
                return

            now = datetime.now()

            if config.schedule_type == ScheduleType.ONCE:
                if config.run_at and config.run_at > now:
                    next_run = config.run_at
                else:
                    return  # Already passed

            elif config.schedule_type == ScheduleType.INTERVAL:
                next_run = now + timedelta(seconds=config.interval_seconds or 60)

            elif config.schedule_type == ScheduleType.CRON:
                if config.cron_expr:
                    next_run = config.cron_expr.next_run(now)
                else:
                    return

            elif config.schedule_type == ScheduleType.IMMEDIATE:
                next_run = now

            else:
                return

            scheduled = ScheduledJob(next_run=next_run, config=config)
            heapq.heappush(self._heap, scheduled)

    def _run_job(self, config: JobConfig):
        """Execute a job."""
        execution_id = str(uuid.uuid4())[:8]
        execution = JobExecution(
            job_id=config.id,
            execution_id=execution_id,
            started_at=datetime.now().isoformat(),
            state=JobState.RUNNING
        )

        # Track running instance
        with self._lock:
            if self._running.get(config.id, 0) >= config.max_instances:
                return  # Max instances reached
            self._running[config.id] = self._running.get(config.id, 0) + 1

        # Notify start
        for callback in self._on_job_start:
            try:
                callback(config.id)
            except Exception:
                pass

        start_time = time.time()
        retries = 0

        while retries <= config.max_retries:
            try:
                # Execute
                if config.is_async:
                    loop = asyncio.new_event_loop()
                    asyncio.set_event_loop(loop)
                    try:
                        result = loop.run_until_complete(
                            asyncio.wait_for(
                                config.func(*config.args, **config.kwargs),
                                timeout=config.timeout
                            ) if config.timeout else config.func(*config.args, **config.kwargs)
                        )
                    finally:
                        loop.close()
                else:
                    result = config.func(*config.args, **config.kwargs)

                # Success
                execution.state = JobState.COMPLETED
                execution.result = result
                execution.ended_at = datetime.now().isoformat()
                execution.duration_ms = (time.time() - start_time) * 1000
                execution.retries = retries

                break

            except Exception as e:
                retries += 1
                if retries > config.max_retries:
                    execution.state = JobState.FAILED
                    execution.error = str(e)
                    execution.ended_at = datetime.now().isoformat()
                    execution.duration_ms = (time.time() - start_time) * 1000
                    execution.retries = retries - 1

                    # Notify error
                    for callback in self._on_job_error:
                        try:
                            callback(config.id, e)
                        except Exception:
                            pass
                else:
                    time.sleep(config.retry_delay * retries)

        # Record execution
        with self._lock:
            self._executions.append(execution)
            if len(self._executions) > 1000:
                self._executions = self._executions[-1000:]

            self._running[config.id] = max(0, self._running.get(config.id, 1) - 1)

        # Notify completion
        for callback in self._on_job_complete:
            try:
                callback(execution)
            except Exception:
                pass

        # Schedule next run (if repeating)
        if config.schedule_type in (ScheduleType.INTERVAL, ScheduleType.CRON):
            self._schedule_next(config)

    def _scheduler_loop(self):
        """Main scheduler loop."""
        while self._running_flag:
            try:
                with self._lock:
                    now = datetime.now()

                    # Check for jobs to run
                    while self._heap and self._heap[0].next_run <= now:
                        scheduled = heapq.heappop(self._heap)
                        config = scheduled.config

                        if config.id not in self._jobs:
                            continue  # Job was removed

                        if not config.enabled:
                            continue  # Job is paused

                        # Submit for execution
                        self._executor.submit(self._run_job, config)

                # Sleep briefly before next check
                time.sleep(0.1)

            except Exception:
                pass

    def start(self):
        """Start the scheduler."""
        if self._running_flag:
            return

        self._running_flag = True
        self._scheduler_thread = threading.Thread(
            target=self._scheduler_loop,
            daemon=True,
            name="genesis-scheduler"
        )
        self._scheduler_thread.start()

    def stop(self, wait: bool = True):
        """Stop the scheduler."""
        self._running_flag = False

        if self._scheduler_thread:
            self._scheduler_thread.join(timeout=5)

        if wait:
            self._executor.shutdown(wait=True)
        else:
            self._executor.shutdown(wait=False)

        if self.persist_path:
            self._persist()

    def on_job_start(self, callback: Callable[[str], None]):
        """Register job start callback."""
        self._on_job_start.append(callback)

    def on_job_complete(self, callback: Callable[[JobExecution], None]):
        """Register job completion callback."""
        self._on_job_complete.append(callback)

    def on_job_error(self, callback: Callable[[str, Exception], None]):
        """Register job error callback."""
        self._on_job_error.append(callback)

    def get_job(self, job_id: str) -> Optional[JobConfig]:
        """Get job configuration."""
        return self._jobs.get(job_id)

    def list_jobs(self, tag: str = None) -> List[JobConfig]:
        """List all jobs, optionally filtered by tag."""
        jobs = list(self._jobs.values())
        if tag:
            jobs = [j for j in jobs if tag in j.tags]
        return jobs

    def get_executions(
        self,
        job_id: str = None,
        limit: int = 100,
        state: JobState = None
    ) -> List[JobExecution]:
        """Get execution history."""
        executions = self._executions.copy()

        if job_id:
            executions = [e for e in executions if e.job_id == job_id]
        if state:
            executions = [e for e in executions if e.state == state]

        return executions[-limit:]

    def get_status(self) -> Dict:
        """Get scheduler status."""
        return {
            "running": self._running_flag,
            "job_count": len(self._jobs),
            "pending_count": len(self._heap),
            "active_executions": sum(self._running.values()),
            "execution_history": len(self._executions),
            "workers": self.max_workers
        }

    def get_next_runs(self, limit: int = 10) -> List[Dict]:
        """Get upcoming scheduled runs."""
        with self._lock:
            runs = []
            for scheduled in sorted(self._heap)[:limit]:
                runs.append({
                    "job_id": scheduled.config.id,
                    "job_name": scheduled.config.name,
                    "next_run": scheduled.next_run.isoformat(),
                    "seconds_until": (scheduled.next_run - datetime.now()).total_seconds()
                })
            return runs

    def _persist(self):
        """Save scheduler state to disk."""
        if not self.persist_path:
            return

        self.persist_path.parent.mkdir(parents=True, exist_ok=True)

        data = {
            "jobs": {
                job_id: {
                    "name": config.name,
                    "schedule_type": config.schedule_type.value,
                    "interval_seconds": config.interval_seconds,
                    "enabled": config.enabled,
                    "tags": config.tags
                }
                for job_id, config in self._jobs.items()
            },
            "executions": [
                {
                    "job_id": e.job_id,
                    "execution_id": e.execution_id,
                    "started_at": e.started_at,
                    "ended_at": e.ended_at,
                    "state": e.state.value,
                    "error": e.error,
                    "retries": e.retries,
                    "duration_ms": e.duration_ms
                }
                for e in self._executions[-100:]
            ]
        }

        with open(self.persist_path, 'w') as f:
            json.dump(data, f, indent=2)

    def _restore(self):
        """Restore scheduler state from disk."""
        # Note: Full restore requires re-registering functions
        pass


# Global scheduler
_scheduler: Optional[BackgroundScheduler] = None


def get_scheduler() -> BackgroundScheduler:
    """Get global scheduler."""
    global _scheduler
    if _scheduler is None:
        _scheduler = BackgroundScheduler()
    return _scheduler


def main():
    """CLI and demo for background scheduler."""
    import argparse
    parser = argparse.ArgumentParser(description="Genesis Background Scheduler")
    parser.add_argument("command", choices=["demo", "cron", "status"])
    args = parser.parse_args()

    if args.command == "demo":
        print("Background Scheduler Demo")
        print("=" * 40)

        scheduler = BackgroundScheduler(max_workers=2)

        # Counter for demo
        counter = {"value": 0}

        # Interval job
        @scheduler.interval(2, name="counter")
        def increment_counter():
            counter["value"] += 1
            print(f"  Counter: {counter['value']}")
            return counter["value"]

        # One-time job
        def delayed_message():
            print("  Delayed message executed!")

        scheduler.add_job(
            delayed_message,
            name="delayed",
            run_at=datetime.now() + timedelta(seconds=3)
        )

        # Job with retries
        fail_count = {"value": 0}

        def flaky_job():
            fail_count["value"] += 1
            if fail_count["value"] < 3:
                raise Exception("Simulated failure")
            print("  Flaky job succeeded!")
            return "success"

        scheduler.add_job(
            flaky_job,
            name="flaky",
            schedule_type=ScheduleType.IMMEDIATE,
            max_retries=3,
            retry_delay=0.5
        )

        # Callbacks
        scheduler.on_job_complete(
            lambda e: print(f"  [Complete] {e.job_id}: {e.state.value} ({e.duration_ms:.0f}ms)")
        )
        scheduler.on_job_error(
            lambda job_id, e: print(f"  [Error] {job_id}: {e}")
        )

        print("\nStarting scheduler...")
        scheduler.start()

        print("\nRunning for 8 seconds...")
        time.sleep(8)

        print("\nScheduler status:")
        print(f"  {json.dumps(scheduler.get_status(), indent=4)}")

        print("\nUpcoming runs:")
        for run in scheduler.get_next_runs(5):
            print(f"  {run['job_name']}: in {run['seconds_until']:.1f}s")

        print("\nExecution history:")
        for e in scheduler.get_executions(limit=10):
            print(f"  [{e.started_at[:19]}] {e.job_id}: {e.state.value}")

        print("\nStopping scheduler...")
        scheduler.stop()
        print("Done!")

    elif args.command == "cron":
        print("Cron Expression Demo")
        print("=" * 40)

        expressions = [
            "* * * * *",      # Every minute
            "*/5 * * * *",    # Every 5 minutes
            "0 * * * *",      # Every hour
            "0 0 * * *",      # Every day at midnight
            "0 9-17 * * 1-5", # 9-5 weekdays
            "0 0 1 * *",      # First of every month
        ]

        now = datetime.now()

        for expr in expressions:
            try:
                cron = CronExpression.parse(expr)
                next_run = cron.next_run(now)
                diff = next_run - now
                print(f"\n  {expr}")
                print(f"    Next run: {next_run.strftime('%Y-%m-%d %H:%M')}")
                print(f"    In: {diff.total_seconds() / 60:.0f} minutes")
            except Exception as e:
                print(f"\n  {expr}")
                print(f"    Error: {e}")

    elif args.command == "status":
        scheduler = get_scheduler()
        print(json.dumps(scheduler.get_status(), indent=2))


if __name__ == "__main__":
    main()
