"""
Doit Agent - Scheduler Engine
Natural language + cron scheduling with persistent state and crash recovery.
"""
from __future__ import annotations

import asyncio
import logging
import re
import uuid
from datetime import datetime, timezone, timedelta
from typing import Any, Optional

from persistence.database import get_db
from task_engine.engine import get_engine

logger = logging.getLogger("doit.scheduler")


def parse_natural_language_time(text: str) -> Optional[dict]:
    """
    Parse natural language scheduling expressions.
    Returns dict with interval_s or cron_expr or next_run.
    Examples:
      "every 5 minutes"              -> {interval_s: 300}
      "every day at 9am"            -> {cron_expr: "0 9 * * *"}
      "every monday at 8:30"        -> {cron_expr: "30 8 * * 1"}
      "every weekday at 9am"        -> {cron_expr: "0 9 * * 1-5"}
      "in 10 minutes"               -> {next_run: <iso>, one_shot: True}
      "every 2 hours"               -> {interval_s: 7200}
    """
    text = text.lower().strip()

    def _parse_time_of_day(h_str: str, m_str: Optional[str], ampm: Optional[str]) -> tuple[int, int]:
        """Parse hour/minute with optional AM/PM, return (hour_24, minute)."""
        h = int(h_str)
        m = int(m_str) if m_str else 0
        if ampm == "pm" and h != 12:
            h += 12
        elif ampm == "am" and h == 12:
            h = 0
        return h, m

    # "every N unit(s)"
    m = re.search(r'every\s+(\d+)\s+(second|minute|hour|day|week)s?', text)
    if m:
        n, unit = int(m.group(1)), m.group(2)
        multipliers = {"second": 1, "minute": 60, "hour": 3600, "day": 86400, "week": 604800}
        return {"interval_s": n * multipliers[unit], "cron_expr": None}

    # "every minute/hour/day/week" (singular, no number)
    m = re.search(r'^every (minute|hour|day|week)$', text)
    if m:
        unit = m.group(1)
        mult = {"minute": 60, "hour": 3600, "day": 86400, "week": 604800}
        return {"interval_s": mult[unit], "cron_expr": None}

    # "every weekday at HH:MM am/pm"
    m = re.search(r'every weekday(?:s)?(?: at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?', text)
    if m:
        if m.group(1):
            h, mins = _parse_time_of_day(m.group(1), m.group(2), m.group(3))
        else:
            h, mins = 9, 0
        return {"cron_expr": f"{mins} {h} * * 1-5", "interval_s": None}

    # "every weekend at HH:MM"
    m = re.search(r'every weekend(?: at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?', text)
    if m:
        if m.group(1):
            h, mins = _parse_time_of_day(m.group(1), m.group(2), m.group(3))
        else:
            h, mins = 10, 0
        return {"cron_expr": f"{mins} {h} * * 0,6", "interval_s": None}

    # "every day at HH:MM am/pm"
    m = re.search(r'every day(?: at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?', text)
    if m:
        if m.group(1):
            h, mins = _parse_time_of_day(m.group(1), m.group(2), m.group(3))
        else:
            h, mins = 9, 0
        return {"cron_expr": f"{mins} {h} * * *", "interval_s": None}

    # "every monday/tuesday/... at HH:MM am/pm"
    # cron dow: 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat
    days_map = {
        "sunday": 0, "monday": 1, "tuesday": 2, "wednesday": 3,
        "thursday": 4, "friday": 5, "saturday": 6,
    }
    for day_name, cron_dow in days_map.items():
        m = re.search(
            rf'every {day_name}(?: at (\d{{1,2}})(?::(\d{{2}}))?\s*(am|pm)?)?',
            text
        )
        if m:
            if m.group(1):
                h, mins = _parse_time_of_day(m.group(1), m.group(2), m.group(3))
            else:
                h, mins = 9, 0
            return {"cron_expr": f"{mins} {h} * * {cron_dow}", "interval_s": None}

    # "at HH:MM am/pm daily" / "daily at HH:MM"
    m = re.search(r'(?:daily|at)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s+daily)?', text)
    if m:
        h, mins = _parse_time_of_day(m.group(1), m.group(2), m.group(3))
        return {"cron_expr": f"{mins} {h} * * *", "interval_s": None}

    # "in N minutes/hours/seconds"
    m = re.search(r'in\s+(\d+)\s+(second|minute|hour)s?', text)
    if m:
        n, unit = int(m.group(1)), m.group(2)
        multipliers = {"second": 1, "minute": 60, "hour": 3600}
        delta = timedelta(seconds=n * multipliers[unit])
        next_run = (datetime.now(timezone.utc) + delta).isoformat()
        return {"next_run": next_run, "interval_s": None, "cron_expr": None, "one_shot": True}

    return None


def cron_next_run(cron_expr: str) -> Optional[str]:
    """
    Calculate next run time from a 5-field cron expression.
    Fields: minute hour dom month dow
    DOW: 0=Sunday, 1=Monday, ..., 6=Saturday  (standard cron convention)
    Python weekday(): 0=Monday, ..., 6=Sunday
    Mapping: cron_dow -> python_weekday = (cron_dow - 1) % 7, BUT cron 0=Sun -> python 6
    Correct mapping: python_weekday = (int(cron_dow) + 6) % 7
    """
    try:
        parts = cron_expr.strip().split()
        if len(parts) != 5:
            return None
        minute_f, hour_f, dom_f, month_f, dow_f = parts

        def _match(field: str, value: int) -> bool:
            if field == "*":
                return True
            # Support comma-separated values
            for part in field.split(","):
                part = part.strip()
                # Support ranges like 1-5
                if "-" in part:
                    lo, hi = part.split("-", 1)
                    if int(lo) <= value <= int(hi):
                        return True
                # Support step like */5
                elif part.startswith("*/"):
                    step = int(part[2:])
                    if value % step == 0:
                        return True
                elif int(part) == value:
                    return True
            return False

        now = datetime.now(timezone.utc).replace(second=0, microsecond=0)

        # Scan next 10080 minutes (one week) to find next match
        for i in range(1, 10081):
            candidate = now + timedelta(minutes=i)
            # cron dow: 0=Sun,1=Mon,...,6=Sat; Python: Mon=0,...,Sun=6
            # Convert: python_wd = (cron_dow + 6) % 7 -> check reverse
            # candidate.weekday() 0=Mon; cron 1=Mon -> candidate.weekday()+1 = cron for Mon-Sat; Sun is cron 0 = py 6
            cron_dow_of_candidate = (candidate.weekday() + 1) % 7  # 0=Sun,1=Mon,...,6=Sat

            if (_match(minute_f, candidate.minute) and
                    _match(hour_f, candidate.hour) and
                    _match(dom_f, candidate.day) and
                    _match(month_f, candidate.month) and
                    _match(dow_f, cron_dow_of_candidate)):
                return candidate.isoformat()
        return None
    except Exception as e:
        logger.warning("cron_next_run error for '%s': %s", cron_expr, e)
        return None


class Scheduler:
    """
    Persistent scheduler that survives crashes and power loss.
    Loads schedules from DB on startup and resumes execution.
    """

    def __init__(self):
        self._running = False
        self._task: Optional[asyncio.Task] = None

    async def start(self) -> None:
        logger.info("Scheduler starting")
        self._running = True
        self._task = asyncio.create_task(self._loop())

    async def stop(self) -> None:
        self._running = False
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass

    async def _loop(self) -> None:
        """Main scheduler loop — checks every 30 seconds."""
        while self._running:
            try:
                await self._tick()
            except Exception as e:
                logger.error("Scheduler tick error: %s", e)
            await asyncio.sleep(30)

    async def _tick(self) -> None:
        db = await get_db()
        engine = get_engine()
        schedules = await db.schedules_list()
        now = datetime.now(timezone.utc)

        for sched in schedules:
            if not sched["enabled"]:
                continue
            next_run_str = sched.get("next_run")
            if not next_run_str:
                continue
            try:
                next_run = datetime.fromisoformat(next_run_str)
                if next_run.tzinfo is None:
                    next_run = next_run.replace(tzinfo=timezone.utc)
            except ValueError:
                continue

            if now >= next_run:
                logger.info("Running schedule: %s", sched["name"])
                import json
                payload = json.loads(sched["task_payload"]) if isinstance(sched["task_payload"], str) else sched["task_payload"]
                await engine.submit(sched["task_type"], payload)

                # Update last_run and compute next_run
                last_run = now.isoformat()
                next_next = None

                if sched.get("cron_expr"):
                    next_next = cron_next_run(sched["cron_expr"])
                elif sched.get("interval_s"):
                    next_next = (now + timedelta(seconds=sched["interval_s"])).isoformat()
                # One-shot: leave next_run empty, disable
                if not next_next:
                    await db.schedule_upsert({**sched, "enabled": False, "last_run": last_run,
                                              "next_run": None, "run_count": sched["run_count"] + 1})
                else:
                    await db.schedule_upsert({**sched, "last_run": last_run,
                                              "next_run": next_next,
                                              "run_count": sched["run_count"] + 1})

    async def add_schedule(
        self,
        name: str,
        task_type: str,
        task_payload: dict,
        description: str = "",
        natural_language: str = None,
        cron_expr: str = None,
        interval_s: int = None,
    ) -> dict:
        """Add a new schedule. Returns schedule dict."""
        db = await get_db()

        # Parse natural language if provided
        if natural_language:
            parsed = parse_natural_language_time(natural_language)
            if parsed:
                cron_expr = parsed.get("cron_expr") or cron_expr
                interval_s = parsed.get("interval_s") or interval_s
                one_shot = parsed.get("one_shot", False)
                next_run = parsed.get("next_run")
            else:
                return {"error": f"Could not parse schedule: '{natural_language}'"}
        else:
            one_shot = False
            next_run = None

        # Compute first next_run
        if not next_run:
            if cron_expr:
                next_run = cron_next_run(cron_expr)
            elif interval_s:
                next_run = (datetime.now(timezone.utc) + timedelta(seconds=interval_s)).isoformat()

        sched_id = str(uuid.uuid4())
        import json
        sched = {
            "id": sched_id,
            "name": name,
            "description": description,
            "cron_expr": cron_expr,
            "interval_s": interval_s,
            "next_run": next_run,
            "last_run": None,
            "enabled": True,
            "task_type": task_type,
            "task_payload": json.dumps(task_payload),
            "run_count": 0,
        }
        await db.schedule_upsert(sched)
        await db.audit("schedule_add", target=sched_id, details={"name": name})
        logger.info("Schedule added: %s (next: %s)", name, next_run)
        return sched

    async def remove_schedule(self, schedule_id: str) -> bool:
        db = await get_db()
        ok = await db.schedule_delete(schedule_id)
        if ok:
            await db.audit("schedule_remove", target=schedule_id)
        return ok

    async def list_schedules(self) -> list[dict]:
        db = await get_db()
        return await db.schedules_list()


# Singleton
_scheduler: Optional[Scheduler] = None


def get_scheduler() -> Scheduler:
    global _scheduler
    if _scheduler is None:
        _scheduler = Scheduler()
    return _scheduler
