#!/usr/bin/env python3
"""
kanban.py — Local kanban board server for Nicks_ToDo
Run: python3 kanban.py
Opens in browser at http://localhost:8089
Zero dependencies — stdlib only.
"""

import http.server
import json
import os
import re
import sys
import threading
import webbrowser
import glob
import urllib.parse
import urllib.request
import calendar
from datetime import datetime, timedelta, date
from functools import partial
from zoneinfo import ZoneInfo

PORT = 8089
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_FILE = os.path.join(SCRIPT_DIR, "kanban_data.json")
STATIC_DIR = os.path.join(SCRIPT_DIR, "static")
PROJECTS_DIR = os.path.dirname(os.path.dirname(os.path.dirname(SCRIPT_DIR)))  # 1_NR_CC Projects/

SKIP_FOLDERS = {"z_arch", "Z_Arch", "__pycache__", "node_modules", ".git",
                "static", "y_Neo_Resources"}

SYDNEY_TZ = ZoneInfo("Australia/Sydney")
CALENDAR_CONFIG = os.path.join(SCRIPT_DIR, "calendar_config.json")
CALENDAR_CACHE = os.path.join(SCRIPT_DIR, "calendar_cache.json")
_calendar_cache = {"events": [], "fetched_at": None}
_calendar_lock = threading.Lock()
CALENDAR_TTL = 900  # 15 minutes

# ─── ICS Parser (stdlib only) ──────────────────

def _parse_ics_datetime(dtstr, tzid=None):
    """Parse ICS datetime string to Sydney-local ISO string."""
    dtstr = dtstr.strip()
    if len(dtstr) == 8:  # DATE only: 20260303
        return {"date": dtstr[:4] + "-" + dtstr[4:6] + "-" + dtstr[6:8], "all_day": True}
    # Remove trailing Z and parse
    is_utc = dtstr.endswith("Z")
    dtstr = dtstr.rstrip("Z")
    try:
        dt = datetime.strptime(dtstr, "%Y%m%dT%H%M%S")
    except ValueError:
        return None
    if is_utc:
        dt = dt.replace(tzinfo=ZoneInfo("UTC")).astimezone(SYDNEY_TZ)
    elif tzid:
        try:
            dt = dt.replace(tzinfo=ZoneInfo(tzid)).astimezone(SYDNEY_TZ)
        except Exception:
            dt = dt.replace(tzinfo=SYDNEY_TZ)
    else:
        dt = dt.replace(tzinfo=SYDNEY_TZ)
    return {"date": dt.strftime("%Y-%m-%dT%H:%M"), "all_day": False}

def _parse_vevent(lines):
    """Parse a single VEVENT block into a dict."""
    props = {}
    exdates = []
    i = 0
    while i < len(lines):
        line = lines[i]
        # Handle line continuations (lines starting with space/tab)
        while i + 1 < len(lines) and lines[i + 1].startswith((" ", "\t")):
            i += 1
            line += lines[i].lstrip()
        if ":" in line:
            key_part, _, value = line.partition(":")
            key = key_part.split(";")[0].upper()
            params = key_part.upper()
            if key == "EXDATE":
                # Collect all EXDATE values (may have multiple dates comma-separated)
                for dval in value.split(","):
                    dval = dval.strip().rstrip("Z")
                    if len(dval) >= 8:
                        try:
                            exdates.append(dval[:4] + "-" + dval[4:6] + "-" + dval[6:8])
                        except Exception:
                            pass
            elif key not in props:
                props[key] = {"value": value, "params": params}
        i += 1

    summary = props.get("SUMMARY", {}).get("value", "").replace("\\,", ",").replace("\\n", " ")
    description = props.get("DESCRIPTION", {}).get("value", "").replace("\\,", ",").replace("\\n", "\n")[:200]
    location = props.get("LOCATION", {}).get("value", "").replace("\\,", ",").replace("\\n", " ")
    uid = props.get("UID", {}).get("value", "")

    # Parse start
    dtstart_raw = props.get("DTSTART", {})
    dtstart_params = dtstart_raw.get("params", "")
    dtstart_val = dtstart_raw.get("value", "")
    tzid_match = re.search(r"TZID=([^;:]+)", dtstart_params)
    tzid = tzid_match.group(1) if tzid_match else None
    is_date_only = "VALUE=DATE" in dtstart_params

    if is_date_only:
        start = _parse_ics_datetime(dtstart_val)
    else:
        start = _parse_ics_datetime(dtstart_val, tzid)

    # Parse end
    dtend_raw = props.get("DTEND", {})
    dtend_params = dtend_raw.get("params", "")
    dtend_val = dtend_raw.get("value", "")
    tzid_match_end = re.search(r"TZID=([^;:]+)", dtend_params)
    tzid_end = tzid_match_end.group(1) if tzid_match_end else tzid
    is_date_only_end = "VALUE=DATE" in dtend_params

    if dtend_val:
        if is_date_only_end or is_date_only:
            end = _parse_ics_datetime(dtend_val)
        else:
            end = _parse_ics_datetime(dtend_val, tzid_end)
    else:
        end = start

    if not start:
        return None

    rrule = props.get("RRULE", {}).get("value")

    return {
        "uid": uid,
        "title": summary,
        "description": description,
        "location": location,
        "start": start["date"],
        "end": end["date"] if end else start["date"],
        "all_day": start["all_day"],
        "rrule": rrule,
        "exdates": exdates,
    }

def _expand_rrule(event, window_start, window_end):
    """Expand a recurring event into instances within the window. Basic support."""
    rrule = event.get("rrule")
    if not rrule:
        return [event]

    parts = {}
    for p in rrule.split(";"):
        k, _, v = p.partition("=")
        parts[k.upper()] = v

    freq = parts.get("FREQ", "")
    until_str = parts.get("UNTIL")
    count = int(parts.get("COUNT", 0))
    interval = int(parts.get("INTERVAL", 1))
    byday = parts.get("BYDAY")
    bymonthday = parts.get("BYMONTHDAY")

    all_day = event["all_day"]
    if all_day:
        ev_start = date.fromisoformat(event["start"])
        ev_end = date.fromisoformat(event["end"]) if event["end"] != event["start"] else ev_start + timedelta(days=1)
        duration = ev_end - ev_start
    else:
        ev_start = datetime.fromisoformat(event["start"])
        ev_end = datetime.fromisoformat(event["end"]) if event["end"] != event["start"] else ev_start + timedelta(hours=1)
        duration = ev_end - ev_start

    if until_str:
        try:
            until_str = until_str.rstrip("Z")
            if len(until_str) == 8:
                until = date.fromisoformat(until_str[:4] + "-" + until_str[4:6] + "-" + until_str[6:8])
            else:
                until = datetime.strptime(until_str, "%Y%m%dT%H%M%S").date()
        except Exception:
            until = window_end
    else:
        until = window_end

    # Collect EXDATE set for filtering cancelled recurrences
    exdate_set = set(event.get("exdates", []))

    instances = []
    current = ev_start
    max_iterations = 3000
    n = 0
    total_occurrences = 0  # Track ALL generated occurrences, not just visible ones

    while n < max_iterations:
        check_date = current if isinstance(current, date) and not isinstance(current, datetime) else (current.date() if isinstance(current, datetime) else current)
        if check_date > until or check_date > window_end:
            break
        if count and total_occurrences >= count:
            break

        # Count every occurrence from the start of the recurrence
        total_occurrences += 1

        # Only add to results if within visible window AND not an EXDATE
        check_date_str = str(check_date)
        if check_date >= window_start and check_date_str not in exdate_set:
            new_end = current + duration
            inst = dict(event)
            inst["start"] = str(current) if all_day else current.strftime("%Y-%m-%dT%H:%M")
            inst["end"] = str(new_end) if all_day else new_end.strftime("%Y-%m-%dT%H:%M")
            inst["rrule"] = None
            instances.append(inst)

        if freq == "DAILY":
            current += timedelta(days=interval)
        elif freq == "WEEKLY":
            current += timedelta(weeks=interval)
        elif freq == "MONTHLY":
            if bymonthday:
                day = int(bymonthday)
                m = current.month + interval
                y = current.year + (m - 1) // 12
                m = (m - 1) % 12 + 1
                day = min(day, calendar.monthrange(y, m)[1])
                if all_day:
                    current = date(y, m, day)
                else:
                    current = current.replace(year=y, month=m, day=day)
            else:
                m = current.month + interval
                y = current.year + (m - 1) // 12
                m = (m - 1) % 12 + 1
                day = min(current.day, calendar.monthrange(y, m)[1])
                if all_day:
                    current = date(y, m, day)
                else:
                    current = current.replace(year=y, month=m, day=day)
        elif freq == "YEARLY":
            y = current.year + interval
            if all_day:
                try:
                    current = current.replace(year=y)
                except ValueError:
                    current = date(y, current.month, 28)
            else:
                try:
                    current = current.replace(year=y)
                except ValueError:
                    current = current.replace(year=y, day=28)
        else:
            break
        n += 1

    return instances

def _fetch_calendar_events(force=False):
    """Fetch and parse all ICS feeds. Returns list of events for the next 4 days."""
    global _calendar_cache

    with _calendar_lock:
        now = datetime.now(SYDNEY_TZ)
        if not force and _calendar_cache["fetched_at"]:
            age = (now - datetime.fromisoformat(_calendar_cache["fetched_at"]).replace(tzinfo=SYDNEY_TZ)).total_seconds()
            if age < CALENDAR_TTL:
                return _calendar_cache["events"]

    # Load config
    if not os.path.isfile(CALENDAR_CONFIG):
        return []
    with open(CALENDAR_CONFIG, "r") as f:
        config = json.load(f)

    today = now.date()
    window_start = today
    window_end = today + timedelta(days=4)

    all_events = []
    for cal in config.get("calendars", []):
        cal_name = cal.get("name", "")
        cal_color = cal.get("color", "#50A14F")
        ics_url = cal.get("ics_url", "")
        if not ics_url:
            continue
        try:
            req = urllib.request.Request(ics_url, headers={"User-Agent": "NicksKanban/1.0"})
            with urllib.request.urlopen(req, timeout=15) as resp:
                ics_text = resp.read().decode("utf-8", errors="replace")
        except Exception:
            continue

        # Split into VEVENT blocks
        in_event = False
        event_lines = []
        for line in ics_text.splitlines():
            if line.strip() == "BEGIN:VEVENT":
                in_event = True
                event_lines = []
            elif line.strip() == "END:VEVENT":
                in_event = False
                ev = _parse_vevent(event_lines)
                if ev:
                    ev["calendar_name"] = cal_name
                    ev["color"] = cal_color
                    # Expand recurring or check date range
                    if ev["rrule"]:
                        instances = _expand_rrule(ev, window_start, window_end)
                        for inst in instances:
                            all_events.append(inst)
                    else:
                        # Check if event falls in window
                        try:
                            if ev["all_day"]:
                                ev_date = date.fromisoformat(ev["start"])
                                ev_end_date = date.fromisoformat(ev["end"])
                            else:
                                ev_date = datetime.fromisoformat(ev["start"]).date()
                                ev_end_date = datetime.fromisoformat(ev["end"]).date()
                        except Exception:
                            continue
                        if ev_end_date >= window_start and ev_date < window_end:
                            all_events.append(ev)
            elif in_event:
                event_lines.append(line)

    # Sort by start
    all_events.sort(key=lambda e: e["start"])

    # Update cache
    with _calendar_lock:
        _calendar_cache = {
            "events": all_events,
            "fetched_at": now.isoformat()
        }

    # Also write to file for Dropbox sync
    try:
        with open(CALENDAR_CACHE, "w") as f:
            json.dump(_calendar_cache, f)
    except Exception:
        pass

    return all_events


# ─── Reminder helpers ──────────────────────────────────────

def _days_in_month(year, month):
    return calendar.monthrange(year, month)[1]


def calculate_next_fire(reminder):
    """Calculate the next fire time for a reminder, in Sydney timezone.
    Returns an ISO 8601 string with offset."""
    now = datetime.now(SYDNEY_TZ)
    h, m = map(int, reminder["time"].split(":"))
    freq = reminder["frequency"]
    interval = reminder.get("interval", 1) or 1

    if freq == "once":
        candidate = now.replace(hour=h, minute=m, second=0, microsecond=0)
        if candidate <= now:
            candidate += timedelta(days=1)
        return candidate.isoformat()

    if freq == "daily":
        candidate = now.replace(hour=h, minute=m, second=0, microsecond=0)
        if candidate <= now:
            candidate += timedelta(days=interval)
        return candidate.isoformat()

    if freq == "weekly":
        days_of_week = reminder.get("days_of_week", [0])
        if not days_of_week:
            days_of_week = [0]
        # Find the next matching day
        for offset in range(interval * 7 + 1):
            candidate = now.replace(hour=h, minute=m, second=0, microsecond=0) + timedelta(days=offset)
            if candidate > now and candidate.weekday() in days_of_week:
                # Check interval: only fire on weeks that are multiples of interval
                # from the start of the reminder
                return candidate.isoformat()
        # Fallback: next week
        candidate = now.replace(hour=h, minute=m, second=0, microsecond=0) + timedelta(days=7)
        return candidate.isoformat()

    if freq == "monthly":
        dom = reminder.get("day_of_month", 1) or 1
        actual_day = min(dom, _days_in_month(now.year, now.month))
        candidate = now.replace(day=actual_day, hour=h, minute=m, second=0, microsecond=0)
        if candidate <= now:
            # Advance by interval months
            year, month = now.year, now.month
            for _ in range(interval):
                month += 1
                if month > 12:
                    month = 1
                    year += 1
            actual_day = min(dom, _days_in_month(year, month))
            candidate = now.replace(year=year, month=month, day=actual_day,
                                    hour=h, minute=m, second=0, microsecond=0)
        return candidate.isoformat()

    if freq == "yearly":
        target_month = reminder.get("month", 1) or 1
        dom = reminder.get("day_of_month", 1) or 1
        actual_day = min(dom, _days_in_month(now.year, target_month))
        candidate = now.replace(month=target_month, day=actual_day,
                                hour=h, minute=m, second=0, microsecond=0)
        if candidate <= now:
            year = now.year + interval
            actual_day = min(dom, _days_in_month(year, target_month))
            candidate = candidate.replace(year=year, day=actual_day)
        return candidate.isoformat()

    return now.isoformat()


# ─── Data helpers ──────────────────────────────────────────

def load_data():
    with open(DATA_FILE, "r") as f:
        data = json.load(f)
    if "reminders" not in data:
        data["reminders"] = []
    if "archive" not in data:
        data["archive"] = []
    return data


def save_data(data):
    data["last_modified"] = datetime.now().isoformat(timespec="seconds")
    data["last_modified_by"] = "kanban_server"
    with open(DATA_FILE, "w") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)


def scan_project_folders():
    """Scan 1_NR_CC Projects/ for folders and subfolders."""
    projects = []
    if not os.path.isdir(PROJECTS_DIR):
        return projects

    for name in sorted(os.listdir(PROJECTS_DIR)):
        path = os.path.join(PROJECTS_DIR, name)
        if not os.path.isdir(path) or name in SKIP_FOLDERS or name.startswith("."):
            continue
        # Add parent folder as project
        proj_id = name.lower().replace(" ", "_").replace("!", "")
        projects.append({"id": proj_id, "name": name, "folder": name})

        # Add subfolders as "Parent (Child)"
        for sub in sorted(os.listdir(path)):
            subpath = os.path.join(path, sub)
            if not os.path.isdir(subpath) or sub in SKIP_FOLDERS or sub.startswith("."):
                continue
            sub_id = f"{proj_id}__{sub.lower().replace(' ', '_')}"
            sub_name = f"{name} ({sub})"
            projects.append({"id": sub_id, "name": sub_name, "folder": f"{name}/{sub}"})

    return projects


def sync_projects():
    """Sync folder-based projects into kanban_data.json."""
    data = load_data()
    folder_projects = scan_project_folders()

    existing_ids = {p["id"] for p in data["projects"]}
    added = 0
    for i, fp in enumerate(folder_projects):
        if fp["id"] not in existing_ids:
            data["projects"].append({
                "id": fp["id"],
                "name": fp["name"],
                "sort_order": len(data["projects"]) + i
            })
            added += 1

    if added > 0:
        save_data(data)
    return {"synced": added, "total": len(data["projects"])}


def check_conflicts():
    """Check for Dropbox conflict files."""
    pattern = os.path.join(SCRIPT_DIR, "kanban_data (*.json")
    conflicts = glob.glob(pattern)
    # Also check standard Dropbox conflict pattern
    pattern2 = os.path.join(SCRIPT_DIR, "kanban_data (*conflicted*).json")
    conflicts += glob.glob(pattern2)
    return list(set(conflicts))


# ─── HTTP Handler ──────────────────────────────────────────

class KanbanHandler(http.server.BaseHTTPRequestHandler):

    def log_message(self, format, *args):
        # Suppress default logging noise
        pass

    def send_json(self, data, status=200):
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def send_error_json(self, status, message):
        self.send_json({"error": message}, status)

    def read_body(self):
        length = int(self.headers.get("Content-Length", 0))
        if length == 0:
            return {}
        raw = self.rfile.read(length)
        return json.loads(raw.decode("utf-8"))

    # ─── GET ───

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        # API routes
        if path == "/api/tasks":
            return self.api_get_tasks()
        if path == "/api/ui_state":
            return self.api_get_ui_state()
        if path == "/api/archive":
            return self.api_get_archive()
        if path == "/api/conflicts":
            return self.api_get_conflicts()
        if path == "/api/reminders":
            return self.api_get_reminders()
        if path == "/api/reminders/due":
            return self.api_get_due_reminders()
        if path == "/api/calendar":
            return self.api_get_calendar()

        # Static files
        if path == "/" or path == "/index.html":
            return self.serve_file("index.html", "text/html")
        if path.startswith("/static/"):
            filename = path[len("/static/"):]
            ct = "text/css" if filename.endswith(".css") else \
                 "application/javascript" if filename.endswith(".js") else \
                 "text/html"
            return self.serve_file(filename, ct)

        self.send_error_json(404, "Not found")

    def serve_file(self, filename, content_type):
        filepath = os.path.join(STATIC_DIR, filename)
        if not os.path.isfile(filepath):
            self.send_error_json(404, f"File not found: {filename}")
            return
        with open(filepath, "rb") as f:
            body = f.read()
        self.send_response(200)
        self.send_header("Content-Type", content_type + "; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-cache")
        self.end_headers()
        self.wfile.write(body)

    def api_get_tasks(self):
        data = load_data()
        self.send_json(data)

    def api_get_ui_state(self):
        data = load_data()
        self.send_json(data.get("ui_state", {}))

    def api_save_ui_state(self):
        body = self.read_body()
        data = load_data()
        data["ui_state"] = body
        save_data(data)
        self.send_json({"ok": True})

    def api_get_archive(self):
        data = load_data()
        self.send_json(data.get("archive", []))

    def api_get_conflicts(self):
        conflicts = check_conflicts()
        self.send_json({"conflicts": conflicts})

    def api_get_reminders(self):
        data = load_data()
        self.send_json(data.get("reminders", []))

    def api_get_due_reminders(self):
        data = load_data()
        reminders = data.get("reminders", [])
        now = datetime.now(SYDNEY_TZ)
        due = []
        for r in reminders:
            if not r.get("active", False):
                continue
            try:
                next_fire = datetime.fromisoformat(r["next_fire_at"])
            except (KeyError, ValueError):
                continue
            if next_fire <= now:
                # Dedup: skip if fired within last 2 minutes
                if r.get("last_fired_at"):
                    try:
                        last_fired = datetime.fromisoformat(r["last_fired_at"])
                        if (now - last_fired).total_seconds() < 120:
                            continue
                    except (ValueError, TypeError):
                        pass
                due.append(r)
        self.send_json(due)

    # ─── POST ───

    def do_POST(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        if path == "/api/tasks":
            return self.api_create_task()
        if path == "/api/tasks/reorder":
            return self.api_reorder_tasks()
        if path == "/api/archive/clear":
            return self.api_clear_done()
        if path == "/api/archive/selected":
            return self.api_archive_selected()
        if path == "/api/restore":
            return self.api_restore()
        if path == "/api/projects":
            return self.api_create_project()
        if path == "/api/projects/sync":
            return self.api_sync_projects()
        if path == "/api/ui_state":
            return self.api_save_ui_state()
        if path == "/api/reminders":
            return self.api_create_reminder()
        if path == "/api/calendar/refresh":
            return self.api_refresh_calendar()

        # Fire endpoint: /api/reminders/<id>/fire
        fire_match = self._match_path(path, "/api/reminders/", "/fire")
        if fire_match:
            return self.api_fire_reminder(fire_match)

        # Move endpoint: /api/tasks/<id>/move
        move_match = self._match_path(path, "/api/tasks/", "/move")
        if move_match:
            return self.api_move_task(move_match)

        self.send_error_json(404, "Not found")

    def api_create_task(self):
        body = self.read_body()
        data = load_data()

        import uuid
        task = {
            "id": uuid.uuid4().hex[:8],
            "title": body.get("title", "New task"),
            "explainer": body.get("explainer"),
            "project_id": body.get("project_id", data["projects"][0]["id"] if data["projects"] else "general"),
            "priority": body.get("priority", "P2"),
            "status": body.get("status", "todo"),
            "done": False,
            "created_at": datetime.now().isoformat(timespec="seconds"),
            "updated_at": datetime.now().isoformat(timespec="seconds"),
            "completed_at": None,
            "sort_order": len(data["tasks"]),
            "substep": body.get("substep"),
            "timer_started_at": None,
            "timer_elapsed": 0,
            "timer_first_started": None,
            "timer_sessions": []
        }

        data["tasks"].append(task)
        save_data(data)
        self.send_json(task, 201)

    def api_move_task(self, task_id):
        body = self.read_body()
        data = load_data()

        task = next((t for t in data["tasks"] if t["id"] == task_id), None)
        if not task:
            # Check archive
            task = next((t for t in data["archive"] if t["id"] == task_id), None)
            if task:
                # Moving from archive back to tasks
                data["archive"].remove(task)
                data["tasks"].append(task)

        if not task:
            return self.send_error_json(404, "Task not found")

        # Update status, priority, or project
        if "status" in body:
            task["status"] = body["status"]
            task["done"] = body["status"] == "done"
            if task["done"]:
                task["completed_at"] = datetime.now().isoformat(timespec="seconds")
            else:
                task["completed_at"] = None
        if "priority" in body:
            task["priority"] = body["priority"]
        if "project_id" in body:
            task["project_id"] = body["project_id"]
        if "timer_started_at" in body:
            task["timer_started_at"] = body["timer_started_at"]
        if "timer_elapsed" in body:
            task["timer_elapsed"] = body["timer_elapsed"]
        if "timer_first_started" in body:
            task["timer_first_started"] = body["timer_first_started"]
        if "timer_sessions" in body:
            task["timer_sessions"] = body["timer_sessions"]
        if "sort_order" in body:
            new_order = body["sort_order"]
            # Shift other tasks at or below this position down to make room
            for t in data["tasks"]:
                if t["id"] != task["id"] and t.get("sort_order", 0) >= new_order:
                    t["sort_order"] = t.get("sort_order", 0) + 1
            task["sort_order"] = new_order

        task["updated_at"] = datetime.now().isoformat(timespec="seconds")
        save_data(data)
        self.send_json(task)

    def api_reorder_tasks(self):
        body = self.read_body()
        data = load_data()

        order_map = {item["id"]: item["sort_order"] for item in body.get("order", [])}
        for task in data["tasks"]:
            if task["id"] in order_map:
                task["sort_order"] = order_map[task["id"]]

        save_data(data)
        self.send_json({"ok": True})

    def api_restore(self):
        body = self.read_body()
        if "tasks" not in body or "projects" not in body:
            return self.send_error_json(400, "Invalid restore data")
        save_data(body)
        self.send_json({"ok": True})

    def api_clear_done(self):
        data = load_data()
        done_tasks = [t for t in data["tasks"] if t["done"]]
        data["tasks"] = [t for t in data["tasks"] if not t["done"]]
        data["archive"].extend(done_tasks)
        save_data(data)
        self.send_json({"archived": len(done_tasks)})

    def api_archive_selected(self):
        body = self.read_body()
        ids = set(body.get("ids", []))
        data = load_data()
        to_archive = [t for t in data["tasks"] if t["id"] in ids]
        data["tasks"] = [t for t in data["tasks"] if t["id"] not in ids]
        data["archive"].extend(to_archive)
        save_data(data)
        self.send_json({"archived": len(to_archive)})

    def api_create_project(self):
        body = self.read_body()
        data = load_data()

        import uuid
        project = {
            "id": body.get("id") or uuid.uuid4().hex[:8],
            "name": body.get("name", "New Project"),
            "sort_order": len(data["projects"])
        }

        data["projects"].append(project)
        save_data(data)
        self.send_json(project, 201)

    def api_sync_projects(self):
        result = sync_projects()
        self.send_json(result)

    # ─── Calendar ────────────────────────────────

    def api_get_calendar(self):
        events = _fetch_calendar_events()
        self.send_json(events)

    def api_refresh_calendar(self):
        events = _fetch_calendar_events(force=True)
        self.send_json({"refreshed": len(events)})

    # ─── Reminder CRUD ─────────────────────────

    def api_create_reminder(self):
        body = self.read_body()
        data = load_data()

        import uuid
        reminder = {
            "id": uuid.uuid4().hex[:8],
            "title": body.get("title", "New Reminder"),
            "details": body.get("details") or None,
            "time": body.get("time", "09:00"),
            "frequency": body.get("frequency", "daily"),
            "interval": body.get("interval", 1) or 1,
            "days_of_week": body.get("days_of_week", [0]),
            "day_of_month": body.get("day_of_month"),
            "month": body.get("month"),
            "ends": body.get("ends", "never"),
            "end_date": body.get("end_date"),
            "occurrences_left": body.get("occurrences_left"),
            "last_fired_at": None,
            "active": True,
            "created_at": datetime.now(SYDNEY_TZ).isoformat()
        }
        reminder["next_fire_at"] = calculate_next_fire(reminder)

        data["reminders"].append(reminder)
        save_data(data)
        self.send_json(reminder, 201)

    def api_fire_reminder(self, reminder_id):
        data = load_data()
        reminder = next((r for r in data["reminders"] if r["id"] == reminder_id), None)
        if not reminder:
            return self.send_error_json(404, "Reminder not found")

        now = datetime.now(SYDNEY_TZ)
        reminder["last_fired_at"] = now.isoformat()

        if reminder["frequency"] == "once":
            reminder["active"] = False
        else:
            # Advance to next fire time
            reminder["next_fire_at"] = calculate_next_fire(reminder)

            # Check end conditions
            ends = reminder.get("ends", "never")
            if ends == "on" and reminder.get("end_date"):
                try:
                    end_dt = datetime.fromisoformat(reminder["end_date"])
                    if not end_dt.tzinfo:
                        end_dt = end_dt.replace(tzinfo=SYDNEY_TZ)
                    next_dt = datetime.fromisoformat(reminder["next_fire_at"])
                    if next_dt > end_dt:
                        reminder["active"] = False
                except (ValueError, TypeError):
                    pass
            elif ends == "after" and reminder.get("occurrences_left") is not None:
                reminder["occurrences_left"] = max(0, reminder["occurrences_left"] - 1)
                if reminder["occurrences_left"] <= 0:
                    reminder["active"] = False

        save_data(data)
        self.send_json(reminder)

    # ─── PUT ───

    def do_PUT(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        # /api/tasks/<id>
        task_match = self._match_path(path, "/api/tasks/")
        if task_match:
            return self.api_update_task(task_match)

        # /api/reminders/<id>
        reminder_match = self._match_path(path, "/api/reminders/")
        if reminder_match:
            return self.api_update_reminder(reminder_match)

        # /api/projects/<id>
        proj_match = self._match_path(path, "/api/projects/")
        if proj_match:
            return self.api_update_project(proj_match)

        self.send_error_json(404, "Not found")

    def api_update_task(self, task_id):
        body = self.read_body()
        data = load_data()

        task = next((t for t in data["tasks"] if t["id"] == task_id), None)
        if not task:
            return self.send_error_json(404, "Task not found")

        for key in ("title", "explainer", "project_id", "priority", "status", "substep", "sort_order",
                    "timer_started_at", "timer_elapsed", "timer_first_started", "timer_sessions"):
            if key in body:
                task[key] = body[key]

        if "status" in body:
            task["done"] = body["status"] == "done"
            if task["done"] and not task.get("completed_at"):
                task["completed_at"] = datetime.now().isoformat(timespec="seconds")
            elif not task["done"]:
                task["completed_at"] = None

        task["updated_at"] = datetime.now().isoformat(timespec="seconds")
        save_data(data)
        self.send_json(task)

    def api_update_project(self, project_id):
        body = self.read_body()
        data = load_data()

        project = next((p for p in data["projects"] if p["id"] == project_id), None)
        if not project:
            return self.send_error_json(404, "Project not found")

        if "name" in body:
            project["name"] = body["name"]
        if "sort_order" in body:
            project["sort_order"] = body["sort_order"]
        if "visible" in body:
            project["visible"] = body["visible"]

        save_data(data)
        self.send_json(project)

    def api_update_reminder(self, reminder_id):
        body = self.read_body()
        data = load_data()

        reminder = next((r for r in data["reminders"] if r["id"] == reminder_id), None)
        if not reminder:
            return self.send_error_json(404, "Reminder not found")

        recalc_fields = {"time", "frequency", "interval", "days_of_week", "day_of_month", "month"}
        needs_recalc = False

        for key in ("title", "details", "time", "frequency", "interval", "days_of_week",
                     "day_of_month", "month", "ends", "end_date", "occurrences_left", "active"):
            if key in body:
                reminder[key] = body[key]
                if key in recalc_fields:
                    needs_recalc = True

        if needs_recalc:
            reminder["next_fire_at"] = calculate_next_fire(reminder)

        save_data(data)
        self.send_json(reminder)

    # ─── DELETE ───

    def do_DELETE(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        task_match = self._match_path(path, "/api/tasks/")
        if task_match:
            return self.api_delete_task(task_match)

        reminder_match = self._match_path(path, "/api/reminders/")
        if reminder_match:
            return self.api_delete_reminder(reminder_match)

        proj_match = self._match_path(path, "/api/projects/")
        if proj_match:
            return self.api_delete_project(proj_match)

        self.send_error_json(404, "Not found")

    def api_delete_task(self, task_id):
        data = load_data()
        before = len(data["tasks"])
        data["tasks"] = [t for t in data["tasks"] if t["id"] != task_id]
        if len(data["tasks"]) == before:
            return self.send_error_json(404, "Task not found")
        save_data(data)
        self.send_json({"ok": True})

    def api_delete_project(self, project_id):
        data = load_data()
        data["projects"] = [p for p in data["projects"] if p["id"] != project_id]
        # Orphan tasks get moved to first project or stay orphaned
        save_data(data)
        self.send_json({"ok": True})

    def api_delete_reminder(self, reminder_id):
        data = load_data()
        before = len(data.get("reminders", []))
        data["reminders"] = [r for r in data.get("reminders", []) if r["id"] != reminder_id]
        if len(data["reminders"]) == before:
            return self.send_error_json(404, "Reminder not found")
        save_data(data)
        self.send_json({"ok": True})

    # ─── OPTIONS (CORS) ───

    def do_OPTIONS(self):
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    # ─── Helpers ───

    def _match_path(self, path, prefix, suffix=None):
        """Extract ID from URL like /api/tasks/<id> or /api/tasks/<id>/move"""
        if not path.startswith(prefix):
            return None
        rest = path[len(prefix):]
        if suffix:
            if not rest.endswith(suffix):
                return None
            rest = rest[:-len(suffix)]
        return rest.strip("/") if rest.strip("/") else None


# ─── Main ──────────────────────────────────────────────────

def main():
    if not os.path.isfile(DATA_FILE):
        print(f"ERROR: {DATA_FILE} not found.")
        print("Run: python3 y_Neo_Resources/migrate_txt_to_json.py first.")
        sys.exit(1)

    handler = KanbanHandler
    server = http.server.HTTPServer(("127.0.0.1", PORT), handler)
    url = f"http://localhost:{PORT}"

    print(f"Kanban board running at {url}")
    print("Press Ctrl+C to stop.\n")

    webbrowser.open(url)

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down.")
        server.shutdown()


if __name__ == "__main__":
    main()
