"""DAG (Directed Acyclic Graph) execution support for Oclawma job queue.

This module provides:
- DAGJob: Extended job model with dependency tracking
- DAG: Graph construction and validation with cycle detection
- DAGExecutor: Parallel execution with topological sorting
- Progress tracking and status reporting
"""

from __future__ import annotations

import asyncio
import logging
import threading
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Callable

logger = logging.getLogger(__name__)


class DAGStatus(str, Enum):
    """DAG execution status."""

    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"


class DAGError(Exception):
    """Base exception for DAG operations."""

    pass


class DAGCycleError(DAGError):
    """Raised when a cycle is detected in the DAG."""

    pass


class DAGNotFoundError(DAGError):
    """Raised when a job is not found in the DAG."""

    pass


@dataclass
class DAGJob:
    """A job within a DAG workflow.

    Attributes:
        id: Unique identifier for the job
        payload: Job data/payload
        priority: Job priority
        depends_on: List of job IDs this job depends on
        max_retries: Maximum retry attempts
        job_type: Type of job
        metadata: Additional metadata
    """

    id: str
    payload: dict[str, Any] = field(default_factory=dict)
    priority: Any = None  # JobPriority, resolved at runtime
    depends_on: list[str] = field(default_factory=list)
    max_retries: int = 3
    job_type: str = "default"
    metadata: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self):
        """Validate job configuration."""
        if not self.id:
            raise ValueError("Job ID cannot be empty")
        if self.max_retries < 0:
            raise ValueError("max_retries must be non-negative")
        # Set default priority if not provided
        if self.priority is None:
            from oclawma.queue.models import JobPriority

            self.priority = JobPriority.NORMAL


@dataclass
class DAGJobResult:
    """Result of a DAG job execution.

    Attributes:
        job_id: ID of the job
        success: Whether the job succeeded
        result: Output data from the job
        error: Error message if failed
        started_at: When execution started
        completed_at: When execution completed
        retry_count: Number of retries performed
    """

    job_id: str
    success: bool
    result: Any = None
    error: str | None = None
    started_at: datetime | None = None
    completed_at: datetime | None = None
    retry_count: int = 0

    @property
    def duration_seconds(self) -> float | None:
        """Calculate execution duration in seconds."""
        if self.started_at and self.completed_at:
            return (self.completed_at - self.started_at).total_seconds()
        return None


@dataclass
class DAGProgress:
    """Progress tracking for DAG execution.

    Attributes:
        total_jobs: Total number of jobs in DAG
        completed_jobs: Number of completed jobs
        failed_jobs: Number of failed jobs
        running_jobs: Number of currently running jobs
        pending_jobs: Number of pending jobs
        skipped_jobs: Number of skipped jobs (dependencies failed)
        start_time: When execution started
        end_time: When execution completed
    """

    total_jobs: int = 0
    completed_jobs: int = 0
    failed_jobs: int = 0
    running_jobs: int = 0
    pending_jobs: int = 0
    skipped_jobs: int = 0
    start_time: datetime | None = None
    end_time: datetime | None = None

    @property
    def percent_complete(self) -> float:
        """Calculate percentage of completion."""
        if self.total_jobs == 0:
            return 0.0
        return (self.completed_jobs + self.failed_jobs + self.skipped_jobs) / self.total_jobs * 100

    @property
    def is_complete(self) -> bool:
        """Check if all jobs are finished."""
        return (self.completed_jobs + self.failed_jobs + self.skipped_jobs) >= self.total_jobs

    @property
    def duration_seconds(self) -> float | None:
        """Calculate execution duration in seconds."""
        if self.start_time:
            end = self.end_time or datetime.utcnow()
            return (end - self.start_time).total_seconds()
        return None


@dataclass
class DAGResult:
    """Result of a DAG execution.

    Attributes:
        dag_id: ID of the executed DAG
        status: Final status of the DAG
        job_results: Mapping of job ID to result
        progress: Final progress information
        error: Error message if DAG failed
    """

    dag_id: str
    status: DAGStatus
    job_results: dict[str, DAGJobResult] = field(default_factory=dict)
    progress: DAGProgress = field(default_factory=DAGProgress)
    error: str | None = None

    @property
    def all_succeeded(self) -> bool:
        """Check if all jobs succeeded."""
        return self.status == DAGStatus.COMPLETED and all(
            r.success for r in self.job_results.values()
        )

    @property
    def failed_job_ids(self) -> list[str]:
        """Get list of failed job IDs."""
        return [jid for jid, r in self.job_results.items() if not r.success]


class DAG:
    """Directed Acyclic Graph for job dependency management.

    This class handles:
    - Adding jobs and dependencies
    - Validating the graph (cycle detection)
    - Topological sorting for execution order
    - Tracking job relationships
    """

    def __init__(self, dag_id: str | None = None):
        """Initialize a new DAG.

        Args:
            dag_id: Optional unique identifier for this DAG
        """
        self.dag_id = dag_id or f"dag_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
        self._jobs: dict[str, DAGJob] = {}
        self._dependencies: dict[str, set[str]] = {}  # job_id -> set of dependencies
        self._dependents: dict[str, set[str]] = {}  # job_id -> set of dependents
        self._lock = threading.RLock()

    def add_job(
        self,
        job_id: str,
        payload: dict[str, Any] | None = None,
        priority: Any = None,
        depends_on: list[str] | None = None,
        max_retries: int = 3,
        job_type: str = "default",
        metadata: dict[str, Any] | None = None,
    ) -> DAGJob:
        """Add a job to the DAG.

        Args:
            job_id: Unique identifier for the job
            payload: Job data/payload
            priority: Job priority
            depends_on: List of job IDs this job depends on
            max_retries: Maximum retry attempts
            job_type: Type of job
            metadata: Additional metadata

        Returns:
            The created DAGJob

        Raises:
            DAGError: If job with same ID already exists
        """
        with self._lock:
            if job_id in self._jobs:
                raise DAGError(f"Job with ID '{job_id}' already exists")

            # Import here to avoid circular import
            if priority is None:
                from oclawma.queue.models import JobPriority

                priority = JobPriority.NORMAL

            job = DAGJob(
                id=job_id,
                payload=payload or {},
                priority=priority,
                depends_on=list(depends_on) if depends_on else [],
                max_retries=max_retries,
                job_type=job_type,
                metadata=metadata or {},
            )

            self._jobs[job_id] = job
            self._dependencies[job_id] = set(job.depends_on)
            self._dependents[job_id] = set()

            # Update dependents for dependencies
            for dep_id in job.depends_on:
                if dep_id not in self._dependents:
                    self._dependents[dep_id] = set()
                self._dependents[dep_id].add(job_id)

            return job

    def add_dependency(self, job_id: str, depends_on: str) -> None:
        """Add a dependency between two jobs.

        Args:
            job_id: The job that has the dependency
            depends_on: The job that must complete first

        Raises:
            DAGNotFoundError: If either job doesn't exist
            DAGError: If dependency already exists
        """
        with self._lock:
            if job_id not in self._jobs:
                raise DAGNotFoundError(f"Job '{job_id}' not found")
            if depends_on not in self._jobs:
                raise DAGNotFoundError(f"Dependency '{depends_on}' not found")
            if depends_on == job_id:
                raise DAGError("Job cannot depend on itself")

            if depends_on in self._dependencies[job_id]:
                raise DAGError(f"Dependency '{depends_on}' already exists for job '{job_id}'")

            self._dependencies[job_id].add(depends_on)
            self._dependents[depends_on].add(job_id)
            self._jobs[job_id].depends_on.append(depends_on)

    def remove_job(self, job_id: str) -> None:
        """Remove a job from the DAG.

        Args:
            job_id: ID of the job to remove

        Raises:
            DAGNotFoundError: If job doesn't exist
            DAGError: If job has dependents
        """
        with self._lock:
            if job_id not in self._jobs:
                raise DAGNotFoundError(f"Job '{job_id}' not found")

            if self._dependents[job_id]:
                raise DAGError(
                    f"Cannot remove job '{job_id}' - it has dependents: "
                    f"{self._dependents[job_id]}"
                )

            # Remove from dependencies of other jobs
            for dep_id in self._dependencies[job_id]:
                if dep_id in self._dependents:
                    self._dependents[dep_id].discard(job_id)

            del self._jobs[job_id]
            del self._dependencies[job_id]
            del self._dependents[job_id]

    def get_job(self, job_id: str) -> DAGJob:
        """Get a job by ID.

        Args:
            job_id: ID of the job

        Returns:
            The DAGJob

        Raises:
            DAGNotFoundError: If job doesn't exist
        """
        with self._lock:
            if job_id not in self._jobs:
                raise DAGNotFoundError(f"Job '{job_id}' not found")
            return self._jobs[job_id]

    def get_dependencies(self, job_id: str) -> set[str]:
        """Get all dependencies for a job.

        Args:
            job_id: ID of the job

        Returns:
            Set of dependency job IDs
        """
        with self._lock:
            return set(self._dependencies.get(job_id, set()))

    def get_dependents(self, job_id: str) -> set[str]:
        """Get all jobs that depend on this job.

        Args:
            job_id: ID of the job

        Returns:
            Set of dependent job IDs
        """
        with self._lock:
            return set(self._dependents.get(job_id, set()))

    def validate(self) -> None:
        """Validate the DAG for cycles.

        Raises:
            DAGCycleError: If a cycle is detected
        """
        with self._lock:
            # Check for missing dependencies
            for job_id, deps in self._dependencies.items():
                for dep_id in deps:
                    if dep_id not in self._jobs:
                        raise DAGError(f"Job '{job_id}' depends on non-existent job '{dep_id}'")

            # Kahn's algorithm for cycle detection
            in_degree = {jid: len(deps) for jid, deps in self._dependencies.items()}
            queue = deque([jid for jid, deg in in_degree.items() if deg == 0])
            processed = 0

            while queue:
                current = queue.popleft()
                processed += 1

                for dependent in self._dependents.get(current, set()):
                    in_degree[dependent] -= 1
                    if in_degree[dependent] == 0:
                        queue.append(dependent)

            if processed != len(self._jobs):
                # Find jobs in cycle
                cycle_jobs = [jid for jid, deg in in_degree.items() if deg > 0]
                raise DAGCycleError(f"Cycle detected in DAG involving jobs: {cycle_jobs}")

    def topological_sort(self) -> list[list[str]]:
        """Perform topological sort, grouping independent jobs.

        Returns:
            List of job ID groups where each group can run in parallel

        Raises:
            DAGCycleError: If a cycle is detected
        """
        with self._lock:
            self.validate()

            in_degree = {jid: len(deps) for jid, deps in self._dependencies.items()}
            result: list[list[str]] = []
            current_level: list[str] = []

            # Start with jobs that have no dependencies
            queue = deque([jid for jid, deg in in_degree.items() if deg == 0])

            while queue:
                level_size = len(queue)
                current_level = []

                for _ in range(level_size):
                    current = queue.popleft()
                    current_level.append(current)

                    for dependent in self._dependents.get(current, set()):
                        in_degree[dependent] -= 1
                        if in_degree[dependent] == 0:
                            queue.append(dependent)

                if current_level:
                    result.append(current_level)

            return result

    def get_ready_jobs(self, completed: set[str], failed: set[str]) -> list[str]:
        """Get jobs that are ready to run.

        A job is ready when all its dependencies are completed.

        Args:
            completed: Set of completed job IDs
            failed: Set of failed job IDs

        Returns:
            List of job IDs ready to run
        """
        with self._lock:
            ready = []
            for job_id, _job in self._jobs.items():
                if job_id in completed or job_id in failed:
                    continue

                deps = self._dependencies.get(job_id, set())
                if deps.issubset(completed):
                    ready.append(job_id)

            return ready

    def get_jobs_with_failed_deps(self, failed: set[str]) -> list[str]:
        """Get jobs that have failed dependencies.

        Args:
            failed: Set of failed job IDs

        Returns:
            List of job IDs that should be skipped
        """
        with self._lock:
            skipped = []
            for job_id, deps in self._dependencies.items():
                if job_id in failed:
                    continue
                if deps.intersection(failed):
                    skipped.append(job_id)
            return skipped

    @property
    def jobs(self) -> dict[str, DAGJob]:
        """Get all jobs in the DAG."""
        with self._lock:
            return dict(self._jobs)

    @property
    def job_count(self) -> int:
        """Get total number of jobs."""
        with self._lock:
            return len(self._jobs)

    def __len__(self) -> int:
        """Return number of jobs in DAG."""
        return self.job_count

    def __contains__(self, job_id: str) -> bool:
        """Check if a job ID exists in the DAG."""
        with self._lock:
            return job_id in self._jobs


class DAGExecutor:
    """Executor for running DAG workflows.

    This class handles parallel execution of jobs based on their dependencies,
    with progress tracking and error handling.
    """

    def __init__(
        self,
        max_workers: int = 4,
        continue_on_error: bool = False,
        retry_failed: bool = True,
    ):
        """Initialize the DAG executor.

        Args:
            max_workers: Maximum number of parallel workers
            continue_on_error: Whether to continue executing independent jobs on failure
            retry_failed: Whether to retry failed jobs
        """
        self.max_workers = max_workers
        self.continue_on_error = continue_on_error
        self.retry_failed = retry_failed
        self._cancelled = False
        self._lock = threading.RLock()
        self._progress = DAGProgress()
        self._results: dict[str, DAGJobResult] = {}
        self._callbacks: list[Callable[[DAGProgress], None]] = []

    def on_progress(self, callback: Callable[[DAGProgress], None]) -> None:
        """Register a progress callback.

        Args:
            callback: Function to call with progress updates
        """
        self._callbacks.append(callback)

    def _update_progress(self) -> None:
        """Notify all progress callbacks."""
        for callback in self._callbacks:
            try:
                callback(self._progress)
            except Exception as e:
                logger.warning(f"Progress callback failed: {e}")

    def _execute_job(
        self,
        job: DAGJob,
        handler: Callable[[DAGJob], Any],
    ) -> DAGJobResult:
        """Execute a single job with retry logic.

        Args:
            job: The job to execute
            handler: Function to execute the job

        Returns:
            DAGJobResult with execution details
        """
        started_at = datetime.utcnow()
        retry_count = 0
        last_error: str | None = None

        while retry_count <= job.max_retries:
            if self._cancelled:
                return DAGJobResult(
                    job_id=job.id,
                    success=False,
                    error="Cancelled",
                    started_at=started_at,
                    completed_at=datetime.utcnow(),
                    retry_count=retry_count,
                )

            try:
                result = handler(job)
                return DAGJobResult(
                    job_id=job.id,
                    success=True,
                    result=result,
                    started_at=started_at,
                    completed_at=datetime.utcnow(),
                    retry_count=retry_count,
                )
            except Exception as e:
                last_error = str(e)
                logger.warning(f"Job {job.id} failed (attempt {retry_count + 1}): {e}")
                retry_count += 1

                if retry_count <= job.max_retries and self.retry_failed:
                    # Exponential backoff
                    import time

                    backoff = min(2 ** (retry_count - 1), 60)  # Cap at 60 seconds
                    time.sleep(backoff)

        return DAGJobResult(
            job_id=job.id,
            success=False,
            error=last_error,
            started_at=started_at,
            completed_at=datetime.utcnow(),
            retry_count=retry_count - 1,
        )

    def execute_dag(
        self,
        dag: DAG,
        handler: Callable[[DAGJob], Any],
    ) -> DAGResult:
        """Execute a DAG with parallel job execution.

        Args:
            dag: The DAG to execute
            handler: Function to execute each job

        Returns:
            DAGResult with execution results

        Raises:
            DAGCycleError: If the DAG contains a cycle
        """
        dag.validate()

        with self._lock:
            self._cancelled = False
            self._results = {}
            self._progress = DAGProgress(
                total_jobs=dag.job_count,
                pending_jobs=dag.job_count,
                start_time=datetime.utcnow(),
            )

        completed: set[str] = set()
        failed: set[str] = set()
        skipped: set[str] = set()
        running: set[str] = set()

        logger.info(f"Starting DAG execution: {dag.dag_id} with {dag.job_count} jobs")

        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            futures: dict[Any, str] = {}

            while len(completed) + len(failed) + len(skipped) < dag.job_count:
                if self._cancelled:
                    break

                # Submit ready jobs
                ready = dag.get_ready_jobs(completed, failed.union(skipped))
                ready = [jid for jid in ready if jid not in running and jid not in completed]

                for job_id in ready:
                    if len(running) >= self.max_workers:
                        break

                    job = dag.get_job(job_id)
                    future = executor.submit(self._execute_job, job, handler)
                    futures[future] = job_id
                    running.add(job_id)

                    with self._lock:
                        self._progress.running_jobs += 1
                        self._progress.pending_jobs -= 1
                    self._update_progress()

                if not futures:
                    # No jobs running and none ready - we're done or stuck
                    break

                # Wait for at least one job to complete
                done_futures = []
                for future in list(futures.keys()):
                    if future.done():
                        done_futures.append(future)

                if not done_futures:
                    # Wait for the first future to complete
                    import concurrent.futures

                    done, _ = concurrent.futures.wait(
                        futures.keys(), return_when=concurrent.futures.FIRST_COMPLETED
                    )
                    done_futures = list(done)

                # Process completed futures
                for future in done_futures:
                    job_id = futures.pop(future)
                    running.remove(job_id)

                    try:
                        result = future.result()
                        self._results[job_id] = result

                        if result.success:
                            completed.add(job_id)
                            with self._lock:
                                self._progress.completed_jobs += 1
                        else:
                            failed.add(job_id)
                            with self._lock:
                                self._progress.failed_jobs += 1

                            if not self.continue_on_error:
                                # Mark dependent jobs as skipped
                                for skipped_id in dag.get_jobs_with_failed_deps(failed):
                                    if skipped_id not in skipped:
                                        skipped.add(skipped_id)
                                        self._results[skipped_id] = DAGJobResult(
                                            job_id=skipped_id,
                                            success=False,
                                            error="Skipped due to failed dependency",
                                        )
                                        with self._lock:
                                            self._progress.skipped_jobs += 1

                    except Exception as e:
                        logger.error(f"Unexpected error processing job {job_id}: {e}")
                        failed.add(job_id)
                        self._results[job_id] = DAGJobResult(
                            job_id=job_id,
                            success=False,
                            error=str(e),
                        )
                        with self._lock:
                            self._progress.failed_jobs += 1

                    with self._lock:
                        self._progress.running_jobs -= 1
                    self._update_progress()

        with self._lock:
            self._progress.end_time = datetime.utcnow()
            self._progress.pending_jobs = 0

        # Determine final status
        if self._cancelled:
            status = DAGStatus.CANCELLED
        elif failed or skipped:
            status = DAGStatus.FAILED if failed else DAGStatus.COMPLETED
        else:
            status = DAGStatus.COMPLETED

        result = DAGResult(
            dag_id=dag.dag_id,
            status=status,
            job_results=self._results,
            progress=self._progress,
            error=f"Failed jobs: {failed}" if failed else None,
        )

        logger.info(
            f"DAG execution completed: {dag.dag_id} - "
            f"completed={len(completed)}, failed={len(failed)}, skipped={len(skipped)}"
        )

        return result

    async def execute_dag_async(
        self,
        dag: DAG,
        handler: Callable[[DAGJob], Any],
    ) -> DAGResult:
        """Execute a DAG asynchronously.

        Args:
            dag: The DAG to execute
            handler: Async function to execute each job

        Returns:
            DAGResult with execution results
        """
        dag.validate()

        with self._lock:
            self._cancelled = False
            self._results = {}
            self._progress = DAGProgress(
                total_jobs=dag.job_count,
                pending_jobs=dag.job_count,
                start_time=datetime.utcnow(),
            )

        completed: set[str] = set()
        failed: set[str] = set()
        skipped: set[str] = set()

        logger.info(f"Starting async DAG execution: {dag.dag_id}")

        # Create semaphore for limiting concurrent jobs
        semaphore = asyncio.Semaphore(self.max_workers)

        async def run_job_with_limit(job: DAGJob) -> tuple[str, DAGJobResult]:
            """Run a job with semaphore limit."""
            async with semaphore:
                started_at = datetime.utcnow()
                retry_count = 0
                last_error: str | None = None

                with self._lock:
                    self._progress.running_jobs += 1
                    self._progress.pending_jobs -= 1
                self._update_progress()

                while retry_count <= job.max_retries:
                    if self._cancelled:
                        return (
                            job.id,
                            DAGJobResult(
                                job_id=job.id,
                                success=False,
                                error="Cancelled",
                                started_at=started_at,
                                completed_at=datetime.utcnow(),
                                retry_count=retry_count,
                            ),
                        )

                    try:
                        if asyncio.iscoroutinefunction(handler):
                            result = await handler(job)
                        else:
                            result = handler(job)

                        job_result = DAGJobResult(
                            job_id=job.id,
                            success=True,
                            result=result,
                            started_at=started_at,
                            completed_at=datetime.utcnow(),
                            retry_count=retry_count,
                        )

                        with self._lock:
                            self._progress.running_jobs -= 1
                        self._update_progress()

                        return job.id, job_result
                    except Exception as e:
                        last_error = str(e)
                        logger.warning(f"Job {job.id} failed (attempt {retry_count + 1}): {e}")
                        retry_count += 1

                        if retry_count <= job.max_retries and self.retry_failed:
                            await asyncio.sleep(min(2 ** (retry_count - 1), 60))

                job_result = DAGJobResult(
                    job_id=job.id,
                    success=False,
                    error=last_error,
                    started_at=started_at,
                    completed_at=datetime.utcnow(),
                    retry_count=retry_count - 1,
                )

                with self._lock:
                    self._progress.running_jobs -= 1
                self._update_progress()

                return job.id, job_result

        # Process jobs level by level
        for level in dag.topological_sort():
            if self._cancelled:
                break

            # Filter out jobs whose dependencies failed
            valid_jobs = []
            for job_id in level:
                deps = dag.get_dependencies(job_id)
                if deps.intersection(failed):
                    skipped.add(job_id)
                    self._results[job_id] = DAGJobResult(
                        job_id=job_id,
                        success=False,
                        error="Skipped due to failed dependency",
                    )
                    with self._lock:
                        self._progress.skipped_jobs += 1
                else:
                    valid_jobs.append(job_id)

            if not valid_jobs:
                continue

            # Run jobs in this level concurrently
            tasks = [run_job_with_limit(dag.get_job(job_id)) for job_id in valid_jobs]
            results = await asyncio.gather(*tasks, return_exceptions=True)

            for item in results:
                if isinstance(item, BaseException):
                    logger.error(f"Unexpected error: {item}")
                    continue

                job_id, job_result = item  # type: ignore[misc]
                self._results[job_id] = job_result

                if job_result.success:
                    completed.add(job_id)
                    with self._lock:
                        self._progress.completed_jobs += 1
                else:
                    failed.add(job_id)
                    with self._lock:
                        self._progress.failed_jobs += 1

                    if not self.continue_on_error:
                        # Cancel remaining tasks
                        self._cancelled = True
                        break

                self._update_progress()

            if self._cancelled and not self.continue_on_error:
                break

        with self._lock:
            self._progress.end_time = datetime.utcnow()
            self._progress.pending_jobs = 0

        # Determine final status
        if self._cancelled:
            status = DAGStatus.CANCELLED
        elif failed or skipped:
            status = DAGStatus.FAILED if failed else DAGStatus.COMPLETED
        else:
            status = DAGStatus.COMPLETED

        return DAGResult(
            dag_id=dag.dag_id,
            status=status,
            job_results=self._results,
            progress=self._progress,
            error=f"Failed jobs: {failed}" if failed else None,
        )

    def cancel(self) -> None:
        """Cancel ongoing DAG execution."""
        with self._lock:
            self._cancelled = True

    def get_progress(self) -> DAGProgress:
        """Get current execution progress."""
        with self._lock:
            return DAGProgress(
                total_jobs=self._progress.total_jobs,
                completed_jobs=self._progress.completed_jobs,
                failed_jobs=self._progress.failed_jobs,
                running_jobs=self._progress.running_jobs,
                pending_jobs=self._progress.pending_jobs,
                skipped_jobs=self._progress.skipped_jobs,
                start_time=self._progress.start_time,
                end_time=self._progress.end_time,
            )


# Convenience function for simple DAG execution
def execute_dag(
    dag: DAG,
    handler: Callable[[DAGJob], Any],
    max_workers: int = 4,
    continue_on_error: bool = False,
) -> DAGResult:
    """Execute a DAG with the given handler.

    Args:
        dag: The DAG to execute
        handler: Function to execute each job
        max_workers: Maximum number of parallel workers
        continue_on_error: Whether to continue on job failure

    Returns:
        DAGResult with execution results
    """
    executor = DAGExecutor(
        max_workers=max_workers,
        continue_on_error=continue_on_error,
    )
    return executor.execute_dag(dag, handler)


async def execute_dag_async(
    dag: DAG,
    handler: Callable[[DAGJob], Any],
    max_workers: int = 4,
    continue_on_error: bool = False,
) -> DAGResult:
    """Execute a DAG asynchronously.

    Args:
        dag: The DAG to execute
        handler: Async function to execute each job
        max_workers: Maximum number of parallel workers
        continue_on_error: Whether to continue on job failure

    Returns:
        DAGResult with execution results
    """
    executor = DAGExecutor(
        max_workers=max_workers,
        continue_on_error=continue_on_error,
    )
    return await executor.execute_dag_async(dag, handler)
