"""Daily planner -- generates time-blocked schedules from calendar + projects."""
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

from dobby.models import DailyPlan, TimeBlock

logger = logging.getLogger(__name__)

# Google Calendar color IDs for block types
BLOCK_COLORS = {
    "focus": "9",     # Blueberry
    "meeting": "7",   # Peacock
    "break": "2",     # Sage
    "lunch": "6",     # Tangerine
    "buffer": "8",    # Graphite
}


@dataclass
class PlannerPrefs:
    """User preferences for the daily planner."""
    work_start: str = "09:00"
    work_end: str = "18:00"
    min_focus_minutes: int = 60
    break_minutes: int = 15
    break_every_minutes: int = 120
    lunch_start: str = "12:00"
    lunch_minutes: int = 60
    buffer_before_meeting: int = 5
    planner_time: str = "07:30"
    calendar_ids: list[str] = field(default_factory=lambda: ["primary"])
    create_events: bool = True
    post_to_slack: bool = True

    @classmethod
    def from_preferences(cls, prefs: list[dict]) -> PlannerPrefs:
        """Build from Dobby user preferences (key-value pairs)."""
        kwargs: dict = {}
        mapping = {
            "planner-work-start": "work_start",
            "planner-work-end": "work_end",
            "planner-min-focus": "min_focus_minutes",
            "planner-break-duration": "break_minutes",
            "planner-break-every": "break_every_minutes",
            "planner-lunch-start": "lunch_start",
            "planner-lunch-duration": "lunch_minutes",
            "planner-buffer-before-meeting": "buffer_before_meeting",
            "planner-time": "planner_time",
            "planner-calendar-ids": "calendar_ids",
            "planner-create-events": "create_events",
            "planner-post-to-slack": "post_to_slack",
        }
        for p in prefs:
            key = p.get("key", "").replace("pref:", "")
            if key in mapping:
                val = p.get("value", "")
                attr = mapping[key]
                if attr in ("min_focus_minutes", "break_minutes", "break_every_minutes",
                            "lunch_minutes", "buffer_before_meeting"):
                    kwargs[attr] = int(val)
                elif attr in ("create_events", "post_to_slack"):
                    kwargs[attr] = val.lower() in ("true", "1", "yes")
                elif attr == "calendar_ids":
                    kwargs[attr] = [s.strip() for s in val.split(",")]
                else:
                    kwargs[attr] = val
        return cls(**kwargs)


def _parse_time(hhmm: str, date: str, tz: ZoneInfo) -> datetime:
    """Parse 'HH:MM' + 'YYYY-MM-DD' into a timezone-aware datetime."""
    h, m = map(int, hhmm.split(":"))
    return datetime.fromisoformat(f"{date}T{h:02d}:{m:02d}:00").replace(tzinfo=tz)


def _parse_event_time(event: dict, key: str, tz: ZoneInfo) -> datetime:
    """Extract start/end datetime from a Google Calendar event dict."""
    time_obj = event.get(key, {})
    if "dateTime" in time_obj:
        return datetime.fromisoformat(time_obj["dateTime"])
    elif "date" in time_obj:
        return datetime.fromisoformat(time_obj["date"]).replace(tzinfo=tz)
    raise ValueError(f"Cannot parse {key} from event: {event}")


class PlannerEngine:
    """Generates optimal daily time blocks from calendar events + projects."""

    def __init__(self, prefs: PlannerPrefs | None = None):
        self.prefs = prefs or PlannerPrefs()

    def generate_blocks(
        self,
        date: str,
        tz_name: str,
        calendar_events: list[dict],
        projects: list[dict],
        todos: list[dict],
    ) -> list[TimeBlock]:
        tz = ZoneInfo(tz_name)
        work_start = _parse_time(self.prefs.work_start, date, tz)
        work_end = _parse_time(self.prefs.work_end, date, tz)
        # Handle cross-midnight schedules (e.g., work_end="01:00" means next day)
        if work_end <= work_start:
            work_end += timedelta(days=1)
        blocks: list[TimeBlock] = []

        # 1. Parse calendar events into meeting blocks
        meetings: list[TimeBlock] = []
        for ev in calendar_events:
            try:
                start = _parse_event_time(ev, "start", tz)
                end = _parse_event_time(ev, "end", tz)
                if (end - start).total_seconds() >= 86400:
                    continue
                start = max(start, work_start)
                end = min(end, work_end)
                if start >= end:
                    continue
                meetings.append(TimeBlock(
                    start=start, end=end,
                    block_type="meeting",
                    title=ev.get("summary", "Meeting"),
                    source="calendar",
                    color_id=BLOCK_COLORS["meeting"],
                ))
            except (ValueError, KeyError) as e:
                logger.warning("Skipping unparseable calendar event: %s", e)

        meetings.sort(key=lambda b: b.start)
        blocks.extend(meetings)

        # 2. Add lunch block
        lunch_start = _parse_time(self.prefs.lunch_start, date, tz)
        lunch_end = lunch_start + timedelta(minutes=self.prefs.lunch_minutes)
        lunch_start, lunch_end = self._fit_slot(
            lunch_start, lunch_end, meetings, work_start, work_end
        )
        if lunch_start and lunch_end:
            blocks.append(TimeBlock(
                start=lunch_start, end=lunch_end,
                block_type="lunch", title="Lunch",
                source="planner", color_id=BLOCK_COLORS["lunch"],
            ))

        # 3. Find free slots
        blocks.sort(key=lambda b: b.start)
        free_slots = self._find_free_slots(blocks, work_start, work_end)

        # 4. Compute project weights (boosted by todos)
        project_weights = self._compute_project_weights(projects, todos, date)

        # 5. Allocate focus blocks
        focus_blocks = self._allocate_focus_blocks(free_slots, project_weights)
        blocks.extend(focus_blocks)

        blocks.sort(key=lambda b: b.start)
        return blocks

    def _fit_slot(self, start, end, fixed, _work_start, work_end):
        duration = end - start
        if not any(start < f.end and end > f.start for f in fixed):
            return start, end
        for f in fixed:
            cs = f.end
            ce = cs + duration
            if ce <= work_end and not any(cs < g.end and ce > g.start for g in fixed):
                return cs, ce
        return None, None

    def _find_free_slots(self, fixed_blocks, work_start, work_end):
        slots = []
        cursor = work_start
        for block in fixed_blocks:
            if block.start > cursor:
                gap = (block.start - cursor).total_seconds() / 60
                if gap >= self.prefs.min_focus_minutes:
                    slots.append((cursor, block.start))
            cursor = max(cursor, block.end)
        if cursor < work_end:
            gap = (work_end - cursor).total_seconds() / 60
            if gap >= self.prefs.min_focus_minutes:
                slots.append((cursor, work_end))
        return slots

    def _compute_project_weights(self, projects, todos, date):
        weights = []
        for p in projects:
            w = p.get("weight", 1)
            for t in todos:
                if t.get("project") == p["name"]:
                    due = t.get("due_date", "")
                    if due and due[:10] <= date:
                        w += 1
            weights.append({**p, "effective_weight": w})
        return weights

    def _allocate_focus_blocks(self, free_slots, project_weights):
        if not project_weights or not free_slots:
            return []

        total_weight = sum(p["effective_weight"] for p in project_weights)
        if total_weight == 0:
            return []

        total_minutes = sum((end - start).total_seconds() / 60 for start, end in free_slots)

        # Assign minutes per project proportionally
        project_minutes = []
        for p in project_weights:
            frac = p["effective_weight"] / total_weight
            mins = total_minutes * frac
            project_minutes.append({"project": p, "remaining": mins})

        project_minutes.sort(key=lambda x: -x["project"]["effective_weight"])

        blocks: list[TimeBlock] = []
        break_interval = self.prefs.break_every_minutes
        focus_since_break = 0

        for slot_start, slot_end in free_slots:
            cursor = slot_start
            for pm in project_minutes:
                if pm["remaining"] <= 0 or cursor >= slot_end:
                    continue

                # Insert break if needed
                if focus_since_break >= break_interval:
                    break_end = cursor + timedelta(minutes=self.prefs.break_minutes)
                    if break_end < slot_end:
                        blocks.append(TimeBlock(
                            start=cursor, end=break_end,
                            block_type="break", title="Break",
                            source="planner", color_id=BLOCK_COLORS["break"],
                        ))
                        cursor = break_end
                        focus_since_break = 0

                available = (slot_end - cursor).total_seconds() / 60
                alloc = min(pm["remaining"], available)
                if alloc < self.prefs.min_focus_minutes:
                    continue

                block_end = cursor + timedelta(minutes=alloc)
                p = pm["project"]
                blocks.append(TimeBlock(
                    start=cursor, end=block_end,
                    block_type="focus",
                    title=f"{p['name'].upper()} \u2014 Deep Work",
                    project=p["name"],
                    source="planner",
                    color_id=BLOCK_COLORS["focus"],
                ))
                pm["remaining"] -= alloc
                focus_since_break += alloc
                cursor = block_end

        # Cleanup pass: fill remaining gaps with projects that still have
        # unallocated time, using a relaxed minimum (15 min) so tail-end
        # slots don't go to waste.
        all_blocks = blocks[:]
        all_blocks.sort(key=lambda b: b.start)
        remaining_projects = [pm for pm in project_minutes if pm["remaining"] >= 15]
        if remaining_projects:
            occupied = [(b.start, b.end) for b in all_blocks]
            for slot_start, slot_end in free_slots:
                cursor = slot_start
                for occ_start, occ_end in occupied:
                    if occ_start <= cursor < occ_end:
                        cursor = occ_end
                available = (slot_end - cursor).total_seconds() / 60
                if available < 15:
                    continue
                for pm in remaining_projects:
                    if pm["remaining"] < 15 or cursor >= slot_end:
                        continue
                    avail_now = (slot_end - cursor).total_seconds() / 60
                    alloc = min(pm["remaining"], avail_now)
                    if alloc < 15:
                        continue
                    block_end = cursor + timedelta(minutes=alloc)
                    p = pm["project"]
                    blocks.append(TimeBlock(
                        start=cursor, end=block_end,
                        block_type="focus",
                        title=f"{p['name'].upper()} — Deep Work",
                        project=p["name"],
                        source="planner",
                        color_id=BLOCK_COLORS["focus"],
                    ))
                    pm["remaining"] -= alloc
                    cursor = block_end

        return blocks

    def generate_plan(self, date, tz_name, calendar_events, projects, todos):
        blocks = self.generate_blocks(date, tz_name, calendar_events, projects, todos)
        return DailyPlan(
            date=date, timezone=tz_name,
            blocks=blocks, calendar_events=calendar_events,
        )
