from __future__ import annotations

import asyncio
import json
import threading
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Callable
from ._exceptions import SchedulerError

TaskFunc = Callable[[], Any]

@dataclass(slots=True)
class ScheduledJob:
    name: str
    mode: str
    interval_seconds: float | None = None
    cron: str | None = None
    next_run: float = 0.0
    enabled: bool = True

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)

def _parse_cron_field(value: str, *, min_value: int, max_value: int) -> set[int]:
    if value == "*":
        return set(range(min_value, max_value + 1))
    result: set[int] = set()
    for part in value.split(","):
        part = part.strip()
        if "/" in part:
            base, step_str = part.split("/", 1)
            step = int(step_str)
            if step <= 0:
                raise ValueError("cron step must be > 0")
            base_values = _parse_cron_field(base, min_value=min_value, max_value=max_value) if base != "*" else set(range(min_value, max_value + 1))
            result.update(v for v in sorted(base_values) if (v - min_value) % step == 0)
            continue
        if "-" in part:
            start, end = part.split("-", 1)
            result.update(range(int(start), int(end) + 1))
            continue
        result.add(int(part))
    invalid = [v for v in result if v < min_value or v > max_value]
    if invalid:
        raise ValueError(f"cron values out of range: {invalid}")
    return result

def _matches_cron(expr: str, ts: float) -> bool:
    minute, hour, day, month, weekday = expr.split()
    dt = time.localtime(ts)
    weekday_python = (dt.tm_wday + 1) % 7
    return (
        dt.tm_min in _parse_cron_field(minute, min_value=0, max_value=59)
        and dt.tm_hour in _parse_cron_field(hour, min_value=0, max_value=23)
        and dt.tm_mday in _parse_cron_field(day, min_value=1, max_value=31)
        and dt.tm_mon in _parse_cron_field(month, min_value=1, max_value=12)
        and weekday_python in _parse_cron_field(weekday, min_value=0, max_value=6)
    )

def _next_cron_run(expr: str, after_ts: float) -> float:
    probe = int(after_ts) + 60 - (int(after_ts) % 60)
    deadline = probe + 366 * 24 * 60 * 60
    while probe <= deadline:
        if _matches_cron(expr, probe):
            return float(probe)
        probe += 60
    raise SchedulerError(f"Could not find next cron run for expression: {expr}")

class TaskScheduler:
    def __init__(self, *, persistence_path: str | None = None, poll_interval: float = 0.25) -> None:
        self.persistence_path = Path(persistence_path) if persistence_path else None
        self.poll_interval = poll_interval
        self._jobs: dict[str, ScheduledJob] = {}
        self._registry: dict[str, TaskFunc] = {}
        self._lock = threading.RLock()
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        if self.persistence_path and self.persistence_path.exists():
            self._load()

    def register_task(self, name: str, func: TaskFunc) -> None:
        if not name:
            raise ValueError("Task name must be non-empty")
        if not callable(func):
            raise TypeError("func must be callable")
        self._registry[name] = func

    def every(self, *, name: str, func: TaskFunc | None = None, seconds: float | None = None, minutes: float | None = None, hours: float | None = None) -> ScheduledJob:
        interval = 0.0
        if seconds:
            interval += seconds
        if minutes:
            interval += minutes * 60
        if hours:
            interval += hours * 3600
        if interval <= 0:
            raise ValueError("At least one positive interval unit must be provided")
        if func is not None:
            self.register_task(name, func)
        if name not in self._registry:
            raise SchedulerError(f"Task '{name}' is not registered")
        job = ScheduledJob(name=name, mode="interval", interval_seconds=float(interval), next_run=time.time() + float(interval))
        with self._lock:
            self._jobs[name] = job
            self._save()
        return job

    def cron(self, *, name: str, expr: str, func: TaskFunc | None = None) -> ScheduledJob:
        if len(expr.split()) != 5:
            raise ValueError("cron expression must have 5 fields: minute hour day month weekday")
        if func is not None:
            self.register_task(name, func)
        if name not in self._registry:
            raise SchedulerError(f"Task '{name}' is not registered")
        job = ScheduledJob(name=name, mode="cron", cron=expr, next_run=_next_cron_run(expr, time.time()))
        with self._lock:
            self._jobs[name] = job
            self._save()
        return job

    def remove(self, name: str) -> bool:
        with self._lock:
            removed = self._jobs.pop(name, None) is not None
            self._save()
            return removed

    def list_jobs(self) -> list[ScheduledJob]:
        with self._lock:
            return [ScheduledJob(**job.to_dict()) for job in self._jobs.values()]

    def start(self) -> None:
        if self._thread and self._thread.is_alive():
            return
        self._stop.clear()
        self._thread = threading.Thread(target=self._run_loop, name="TaskScheduler", daemon=True)
        self._thread.start()

    def stop(self) -> None:
        self._stop.set()
        if self._thread:
            self._thread.join(timeout=2.0)

    def run_pending(self) -> None:
        now = time.time()
        due_jobs: list[ScheduledJob] = []
        with self._lock:
            for job in self._jobs.values():
                if job.enabled and job.next_run <= now:
                    due_jobs.append(job)
        for job in due_jobs:
            self._run_job(job)

    def _run_job(self, job: ScheduledJob) -> None:
        func = self._registry.get(job.name)
        if func is None:
            return
        try:
            result = func()
            if asyncio.iscoroutine(result):
                asyncio.run(result)
        finally:
            with self._lock:
                current = self._jobs.get(job.name)
                if current is None:
                    return
                if current.mode == "interval":
                    current.next_run = time.time() + float(current.interval_seconds or 0)
                else:
                    current.next_run = _next_cron_run(current.cron or "* * * * *", time.time())
                self._save()

    def _run_loop(self) -> None:
        while not self._stop.wait(self.poll_interval):
            self.run_pending()

    def _save(self) -> None:
        if self.persistence_path is None:
            return
        self.persistence_path.parent.mkdir(parents=True, exist_ok=True)
        payload = [job.to_dict() for job in self._jobs.values()]
        self.persistence_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")

    def _load(self) -> None:
        if self.persistence_path is None or not self.persistence_path.exists():
            return
        payload = json.loads(self.persistence_path.read_text(encoding="utf-8"))
        if not isinstance(payload, list):
            raise SchedulerError("Scheduler persistence file is malformed")
        for item in payload:
            job = ScheduledJob(**item)
            self._jobs[job.name] = job