from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import threading
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone, tzinfo
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
from urllib.error import URLError
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from icalendar import Calendar
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from py_toon_format import encode

CACHE_TTL = timedelta(hours=24)
CACHE_DIR = Path.home() / ".cache" / "timeedit-mcp"
MAX_LIMIT = 500
CACHE_SCHEMA_VERSION = 3

# Regex patterns assume Swedish/Nordic university course code and student group code formats.
# This differentiation assumes:
# - Course codes: 2-6 letters + 2-3 digits + optional 0-2 alphanumerics (e.g., "EITG20", "ABC123")
# - Group codes: 1 letter + 1 digit + either ".NN" or "-CODE" (e.g., "D3.07", "E4-KS")
# Key assumption: Course codes contain 2-3 consecutive digits; group codes have single digit groups.

COURSE_CODE_RE = re.compile(r"\b[A-ZÅÄÖ]{1,8}[A-Z0-9ÅÄÖ.\-]*\d[A-Z0-9ÅÄÖ.\-]*\b")
# Loose match to find candidate code tokens (contains course code or group code patterns)

STRICT_COURSE_CODE_RE = re.compile(r"^[A-ZÅÄÖ]{2,6}\d{2,3}[A-Z0-9ÅÄÖ]{0,2}$")
# Strict course code: assumes 2-6 letter prefix + 2-3 digit core + optional 0-2 suffix alphanumerics
# Rejects tokens without sufficient consecutive digits or wrong structure

GROUP_CODE_RE = re.compile(r"^[A-ZÅÄÖ]\d(?:\.\d{2}|-[A-Z0-9ÅÄÖ]{1,8})$")
# Group code: assumes single letter + single digit + either ".NN" format (e.g., D3.07)
# or "-CODE" format (e.g., E4-KS). Differentiates from course codes by single digit requirement.
TOKEN_RE = re.compile(r"[A-ZÅÄÖ0-9.\-]+")
PROGRAM_LINE_RE = re.compile(r"^(\d{5,6})\.\s*(.+)$")

_cache_lock = threading.Lock()


@dataclass(slots=True)
class ServerConfig:
    ics_url: str | None = None
    output_timezone: str | None = None


config = ServerConfig()
mcp = FastMCP("timeedit-mcp")


class ProgramTypeModel(BaseModel):
    model_config = ConfigDict(extra="forbid")

    id: str = ""
    name: str


class EventTypeModel(BaseModel):
    model_config = ConfigDict(extra="forbid")

    uid: str = ""
    summary: str = ""
    description: str = ""
    location: str = ""
    url: str = ""
    start: datetime
    end: datetime
    start_date: date
    course_code: str = ""
    course_codes: list[str] = Field(default_factory=list)
    groups: list[str] = Field(default_factory=list)
    course_names: list[str] = Field(default_factory=list)
    event_type: str = ""
    programs: list[ProgramTypeModel] = Field(default_factory=list)
    search_blob: str = ""


class IndexPayloadTypeModel(BaseModel):
    model_config = ConfigDict(extra="forbid")

    cache_schema_version: int
    source_url: str
    fetched_at: datetime
    events: list[EventTypeModel]


def _normalize(text: str | None) -> str:
    if not text:
        return ""
    return " ".join(text.lower().split())


def _safe_limit(limit: int) -> int:
    return max(1, min(limit, MAX_LIMIT))


def _tokenize(text: str | None) -> set[str]:
    if not text:
        return set()
    return {token for token in re.findall(r"[a-zåäö0-9]+", _normalize(text)) if token}


def _rows_to_toon_payload(
    title: str,
    rows: list[dict[str, Any]],
    columns: list[str],
) -> dict[str, Any]:
    def _toon_cell_value(value: Any) -> Any:
        if value is None:
            return ""
        if isinstance(value, (datetime, date)):
            return value.isoformat()
        if isinstance(value, (dict, list, tuple, set)):
            return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
        return value

    return {
        "dataset": title,
        "columns": columns,
        "rows": [
            [_toon_cell_value(row.get(column, "")) for column in columns]
            for row in rows
        ],
    }


def _format_list_response(
    rows: list[dict[str, Any]],
    title: str,
    columns: list[str],
) -> str:
    try:
        return encode(_rows_to_toon_payload(title=title, rows=rows, columns=columns))
    except Exception as exc:  # noqa: BLE001
        raise RuntimeError(f"Failed to encode TOON response: {exc}") from exc


def _parse_date_filter(raw: str | None) -> date | None:
    if not raw:
        return None
    try:
        return date.fromisoformat(raw)
    except ValueError as exc:
        raise ValueError(f"Invalid date format '{raw}'. Use YYYY-MM-DD.") from exc


def _as_datetime(value: Any) -> datetime:
    if isinstance(value, datetime):
        if value.tzinfo is None:
            return value.replace(tzinfo=timezone.utc)
        return value.astimezone(timezone.utc)
    if isinstance(value, date):
        return datetime.combine(value, time.min, tzinfo=timezone.utc)
    raise TypeError(f"Unsupported date/time value: {type(value)!r}")


def _split_description_lines(description: str) -> list[str]:
    normalized = description.replace("\\n", "\n")
    return [
        line.strip().lstrip(":").strip()
        for line in normalized.splitlines()
        if line.strip()
    ]


def _extract_programs(desc_lines: list[str]) -> list[dict[str, str]]:
    programs: dict[tuple[str, str], dict[str, str]] = {}

    for line in desc_lines:
        regex_match = PROGRAM_LINE_RE.match(line)
        if regex_match:
            program_id, program_name = (
                regex_match.group(1),
                regex_match.group(2).strip(),
            )
            key = (program_id, program_name.lower())
            programs[key] = {"id": program_id, "name": program_name}
            continue

        lowered = line.lower()
        if lowered.startswith(("program:", "programme:", "utbildning:")):
            _, _, value = line.partition(":")
            program_name = value.strip()
            if program_name:
                key = ("", program_name.lower())
                programs[key] = {"id": "", "name": program_name}

    return sorted(programs.values(), key=lambda x: (_normalize(x["name"]), x["id"]))


def _extract_code_tokens(summary: str, desc_lines: list[str]) -> set[str]:
    tokens: set[str] = set()
    haystacks = [summary, *desc_lines]

    for text in haystacks:
        for match in COURSE_CODE_RE.finditer(text.upper()):
            token = match.group(0).strip(".,;:()[]{}")
            if re.fullmatch(
                r"KL\.?\d{1,2}(?:[.:]\d{2})?(?:[\-–]\d{1,2}(?:[.:]\d{2})?)?",
                token,
            ):
                # Example false positive: KL.08.00
                continue
            if any(char.isdigit() for char in token) and len(token) >= 4:
                tokens.add(token)

    return tokens


def _is_probable_group_code(token: str) -> bool:
    # Assumption: Group codes follow strict patterns (single digit + dot/hyphen).
    # This specificity makes it safe to check first before the broader course code pattern.
    return bool(GROUP_CODE_RE.fullmatch(token))


def _is_probable_course_code(token: str) -> bool:
    # Assumption: Course codes have 2-6 letters + 2-3 consecutive digits.
    # Only called after group code check to avoid false positives.
    return bool(STRICT_COURSE_CODE_RE.fullmatch(token))


def _extract_course_and_group_codes(
    summary: str,
    desc_lines: list[str],
) -> tuple[list[str], list[str]]:
    # Classification strategy assumes:
    # 1. Group codes are checked first (more specific pattern: single digit + restrictions)
    # 2. Course codes are checked second (broader pattern: 2-6 letters + 2-3 digits)
    # This order prevents group codes from being misclassified as course codes.
    # Example differentiations:
    #   - "D3.07" → group (single digit + dot notation)
    #   - "E4-KS" → group (single digit + hyphen notation)
    #   - "EITG20" → course (2-6 letters + 2-3 digits, no single-digit pattern)
    #   - "ABC123" → course (matches strict pattern)

    course_codes: set[str] = set()
    groups: set[str] = set()

    for token in _extract_code_tokens(summary, desc_lines):
        if _is_probable_group_code(token):
            groups.add(token)
            continue
        if _is_probable_course_code(token):
            course_codes.add(token)

    for line in desc_lines:
        lowered = line.lower()
        if lowered.startswith(("studentgrupp:", "grupp:", "group:")):
            _, _, value = line.partition(":")
            for match in TOKEN_RE.findall(value.upper()):
                token = match.strip(".,;:()[]{}")
                if _is_probable_group_code(token):
                    groups.add(token)

    return sorted(course_codes), sorted(groups)


def _extract_event_type(summary: str, desc_lines: list[str]) -> str:
    """Extract event type from summary/description. Defaults to 'other'."""
    return "other"


def _resolve_output_timezone(output_timezone: str | None) -> tzinfo:
    if output_timezone and output_timezone.strip():
        tz_name = output_timezone.strip()
        try:
            return ZoneInfo(tz_name)
        except ZoneInfoNotFoundError as exc:
            raise ValueError(
                f"Invalid output_timezone '{tz_name}'. Use a valid IANA timezone, e.g. 'Europe/Stockholm'."
            ) from exc

    system_tz = datetime.now().astimezone().tzinfo
    if system_tz is None:
        return timezone.utc
    return system_tz


def _to_output_time(value: Any, output_tz: tzinfo) -> str:
    if not isinstance(value, str):
        return ""

    normalized = value.replace("Z", "+00:00")
    try:
        dt = datetime.fromisoformat(normalized)
    except ValueError:
        return value

    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)

    return dt.astimezone(output_tz).isoformat()


def _extract_course_names(summary: str, desc_lines: list[str]) -> list[str]:
    names: list[str] = []

    for line in desc_lines:
        lowered = line.lower()
        if lowered.startswith("kurs:"):
            _, _, value = line.partition(":")
            course_name = value.strip()
            if course_name:
                names.append(course_name)

    # Fallback: first non-metadata line in description is often the full course name.
    for line in desc_lines:
        lowered = line.lower()
        if lowered.startswith(("kurs:", "studentgrupp:", "titel:", "id ")):
            continue
        if PROGRAM_LINE_RE.match(line):
            continue
        if line:
            names.append(line)
            break

    if not names and summary:
        names.append(summary.split(",")[0].strip())

    unique_names = sorted({name for name in names if name})
    return unique_names


def _validate_index_payload(payload: dict[str, Any]) -> dict[str, Any]:
    validated_model = IndexPayloadTypeModel.model_validate(payload)
    return validated_model.model_dump(mode="json")


def _build_event(component: Any) -> dict[str, Any] | None:
    dtstart_raw = component.decoded("dtstart", None)
    dtend_raw = component.decoded("dtend", None)

    if dtstart_raw is None or dtend_raw is None:
        return None

    summary = str(component.get("summary", "") or "").strip()
    description = str(component.get("description", "") or "").strip()
    location = str(component.get("location", "") or "").strip()
    event_url = str(component.get("url", "") or "").strip()
    uid = str(component.get("uid", "") or "").strip()

    desc_lines = _split_description_lines(description)
    programs = _extract_programs(desc_lines)
    course_codes, groups = _extract_course_and_group_codes(summary, desc_lines)
    course_names = _extract_course_names(summary, desc_lines)
    event_type = _extract_event_type(summary, desc_lines)
    course_code = course_codes[0] if len(course_codes) == 1 else ""

    start_dt = _as_datetime(dtstart_raw)
    end_dt = _as_datetime(dtend_raw)

    blob_parts = [summary, description, location]
    blob_parts.extend(course_codes)
    blob_parts.extend(groups)
    blob_parts.extend(course_names)
    blob_parts.append(event_type)
    blob_parts.extend(program["name"] for program in programs)
    search_blob = _normalize(" ".join(blob_parts))

    try:
        event_model = EventTypeModel(
            uid=uid,
            summary=summary,
            description=description,
            location=location,
            url=event_url,
            start=start_dt,
            end=end_dt,
            start_date=start_dt.date(),
            course_code=course_code,
            course_codes=course_codes,
            groups=groups,
            course_names=course_names,
            event_type=event_type,
            programs=programs,
            search_blob=search_blob,
        )
    except ValidationError:
        return None

    return event_model.model_dump(mode="json")


def _cache_path_for_url(ics_url: str) -> Path:
    digest = hashlib.sha256(ics_url.encode("utf-8")).hexdigest()[:20]
    return CACHE_DIR / f"{digest}.json"


def _is_cache_valid(fetched_at_iso: str | None) -> bool:
    if not fetched_at_iso:
        return False
    try:
        fetched_at = datetime.fromisoformat(fetched_at_iso)
    except ValueError:
        return False
    if fetched_at.tzinfo is None:
        fetched_at = fetched_at.replace(tzinfo=timezone.utc)
    return datetime.now(timezone.utc) - fetched_at <= CACHE_TTL


def _read_cache(path: Path, source_url: str) -> dict[str, Any] | None:
    if not path.exists():
        return None

    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None

    try:
        payload = _validate_index_payload(payload)
    except ValidationError:
        return None

    if payload.get("source_url") != source_url:
        return None
    if payload.get("cache_schema_version") != CACHE_SCHEMA_VERSION:
        return None
    if not _is_cache_valid(payload.get("fetched_at")):
        return None
    if not isinstance(payload.get("events"), list):
        return None
    return payload


def _write_cache(path: Path, payload: dict[str, Any]) -> None:
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")


def _download_and_index(source_url: str) -> dict[str, Any]:
    request = Request(source_url, headers={"User-Agent": "timeedit-mcp/0.1"})
    try:
        with urlopen(request, timeout=90) as response:
            raw_ics = response.read()
    except URLError as exc:
        raise RuntimeError(f"Failed to fetch ICS URL: {exc}") from exc

    if not raw_ics:
        raise RuntimeError("Downloaded ICS payload is empty.")

    try:
        calendar = Calendar.from_ical(raw_ics)
    except Exception as exc:  # noqa: BLE001
        raise RuntimeError(f"Failed to parse ICS payload: {exc}") from exc

    events: list[dict[str, Any]] = []
    for component in calendar.walk("VEVENT"):
        event = _build_event(component)
        if event:
            events.append(event)

    payload = {
        "cache_schema_version": CACHE_SCHEMA_VERSION,
        "source_url": source_url,
        "fetched_at": datetime.now(timezone.utc),
        "events": events,
    }

    try:
        return _validate_index_payload(payload)
    except ValidationError as exc:
        raise RuntimeError(
            f"Type validation failed for indexed payload: {exc}"
        ) from exc


def _get_ics_url() -> str:
    if not config.ics_url:
        raise ValueError(
            "No ICS URL configured. Start the server with --ics-url <URL> "
            "or set TIMEEDIT_ICS_URL."
        )
    return config.ics_url


def _load_index() -> dict[str, Any]:
    source_url = _get_ics_url()
    cache_path = _cache_path_for_url(source_url)

    with _cache_lock:
        cached = _read_cache(cache_path, source_url)
        if cached is not None:
            return cached

        fresh = _download_and_index(source_url)
        _write_cache(cache_path, fresh)
        return fresh


def _format_event(
    event: dict[str, Any],
    include_uid: bool = False,
    include_program_id: bool = False,
) -> dict[str, Any]:
    output_tz = _resolve_output_timezone(config.output_timezone)
    programs = []
    for program in event["programs"]:
        program_payload = {"name": program["name"]}
        if include_program_id:
            program_payload["id"] = program["id"]
        programs.append(program_payload)

    payload = {
        "summary": event.get("summary", ""),
        "start": _to_output_time(event.get("start", ""), output_tz),
        "end": _to_output_time(event.get("end", ""), output_tz),
        "location": event.get("location", ""),
        "url": event.get("url", ""),
        "course_code": event.get("course_code", ""),
        "course_codes": event.get("course_codes", []),
        "groups": event.get("groups", []),
        "course_names": event.get("course_names", []),
        "programs": programs,
    }

    if include_uid:
        payload["uid"] = event.get("uid", "")

    return payload


def _in_date_window(
    event: dict[str, Any], start_date: date | None, end_date: date | None
) -> bool:
    event_day = date.fromisoformat(event["start_date"])
    if start_date and event_day < start_date:
        return False
    if end_date and event_day > end_date:
        return False
    return True


def _program_matches(program: dict[str, str], query: str) -> bool:
    q = _normalize(query)
    has_program_id = bool(program["id"])
    return q in _normalize(program["name"]) or (
        has_program_id and q == program["id"].lower()
    )


def _event_matches_course(event: dict[str, Any], query: str) -> bool:
    q = _normalize(query)
    if q in _normalize(event["summary"]):
        return True
    for name in event["course_names"]:
        if q in _normalize(name):
            return True
    for code in event["course_codes"]:
        if q in code.lower():
            return True
    return False


def _event_matches_program(event: dict[str, Any], query: str) -> bool:
    return any(_program_matches(program, query) for program in event["programs"])


def _event_matches_group(event: dict[str, Any], query: str) -> bool:
    q = _normalize(query)
    for group in event.get("groups", []):
        if q in group.lower():
            return True
    return False


def _course_catalog(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
    by_name: dict[str, dict[str, Any]] = {}

    for event in events:
        names = event["course_names"] or []
        codes = event["course_codes"] or []
        programs = event["programs"] or []

        if not names and not codes:
            continue

        labels = names if names else codes
        for label in labels:
            key = _normalize(label)
            if key not in by_name:
                by_name[key] = {
                    "course": label,
                    "codes": set(),
                    "programs": set(),
                    "events": 0,
                }

            by_name[key]["events"] += 1
            by_name[key]["codes"].update(codes)
            for program in programs:
                by_name[key]["programs"].add(program["name"])

    result = []
    for entry in by_name.values():
        result.append(
            {
                "course": entry["course"],
                "codes": sorted(entry["codes"]),
                "programs": sorted(entry["programs"]),
                "event_count": entry["events"],
            }
        )

    return sorted(result, key=lambda item: (_normalize(item["course"]), item["course"]))


@mcp.tool()
def list_programs(
    include_program_id: bool = False,
) -> str:
    """List all programs available in the configured TimeEdit ICS source."""
    events = _load_index()["events"]
    programs: dict[tuple[str, str], dict[str, Any]] = {}

    for event in events:
        for program in event["programs"]:
            key = (program["id"], _normalize(program["name"]))
            if key not in programs:
                programs[key] = {
                    "id": program["id"],
                    "name": program["name"],
                    "event_count": 0,
                    "courses": set(),
                }

            programs[key]["event_count"] += 1
            programs[key]["courses"].update(event["course_names"])
            programs[key]["courses"].update(event["course_codes"])

    result = []
    for program in programs.values():
        item = {
            "name": program["name"],
            "event_count": program["event_count"],
            "course_count": len(program["courses"]),
        }
        if include_program_id:
            item["id"] = program["id"]
        result.append(item)

    result = sorted(
        result,
        key=lambda item: (
            _normalize(item["name"]),
            item.get("id", ""),
        ),
    )

    columns = ["name", "event_count", "course_count"]
    if include_program_id:
        columns.insert(0, "id")

    return _format_list_response(
        rows=result,
        title="programs",
        columns=columns,
    )


@mcp.tool()
def list_courses_for_program(
    program_query: str,
    limit: int = 200,
) -> str:
    """List courses associated with a specific program query (name or numeric program id)."""
    if not program_query.strip():
        raise ValueError("program_query cannot be empty.")

    events = _load_index()["events"]
    filtered_events = [
        event for event in events if _event_matches_program(event, program_query)
    ]

    catalog = _course_catalog(filtered_events)
    rows = catalog[: _safe_limit(limit)]
    return _format_list_response(
        rows=rows,
        title="courses",
        columns=["course", "codes", "programs", "event_count"],
    )


@mcp.tool()
def search_courses_fuzzy(
    query: str,
    limit: int = 20,
) -> str:
    """Fuzzy search course names/codes in the configured calendar."""
    if not query.strip():
        raise ValueError("query cannot be empty.")

    query_norm = _normalize(query)
    query_tokens = _tokenize(query)
    candidates = _course_catalog(_load_index()["events"])

    scored: list[tuple[float, dict[str, Any]]] = []
    for course in candidates:
        name_norm = _normalize(course["course"])
        code_norm = " ".join(code.lower() for code in course["codes"])
        searchable = f"{name_norm} {code_norm}".strip()
        searchable_tokens = _tokenize(searchable)
        token_overlap = bool(query_tokens.intersection(searchable_tokens))

        ratio = SequenceMatcher(None, query_norm, searchable).ratio()
        if query_norm in searchable:
            ratio = max(ratio, 0.95)
            token_overlap = True

        if not token_overlap and ratio < 0.9:
            continue

        if ratio >= 0.45:
            result_item = {
                "score": round(ratio, 4),
                **course,
            }
            scored.append((ratio, result_item))

    scored.sort(key=lambda x: x[0], reverse=True)
    rows = [entry for _, entry in scored[: _safe_limit(limit)]]
    return _format_list_response(
        rows=rows,
        title="course_matches",
        columns=["score", "course", "codes", "programs", "event_count"],
    )


@mcp.tool()
def list_events_for_course(
    course_query: str,
    start_date: str | None = None,
    end_date: str | None = None,
    group_query: str | None = None,
    limit: int = 100,
    include_uid: bool = False,
    include_program_id: bool = False,
) -> str:
    """List events for a specific course code/name query."""
    if not course_query.strip():
        raise ValueError("course_query cannot be empty.")

    start = _parse_date_filter(start_date)
    end = _parse_date_filter(end_date)

    events = _load_index()["events"]
    matched = [
        _format_event(
            event,
            include_uid=include_uid,
            include_program_id=include_program_id,
        )
        for event in events
        if _event_matches_course(event, course_query)
        and (not group_query or _event_matches_group(event, group_query))
        and _in_date_window(event, start, end)
    ]
    rows = matched[: _safe_limit(limit)]
    columns = [
        "start",
        "end",
        "location",
        "url",
        "course_code",
        "event_type",
        "course_codes",
        "groups",
        "course_names",
        "programs",
        "summary",
    ]
    if include_uid:
        columns.insert(0, "uid")

    return _format_list_response(
        rows=rows,
        title="events",
        columns=columns,
    )


@mcp.tool()
def list_events_for_program(
    program_query: str,
    start_date: str | None = None,
    end_date: str | None = None,
    group_query: str | None = None,
    limit: int = 100,
    include_uid: bool = False,
    include_program_id: bool = False,
) -> str:
    """List events for a specific program query (name or id)."""
    if not program_query.strip():
        raise ValueError("program_query cannot be empty.")

    start = _parse_date_filter(start_date)
    end = _parse_date_filter(end_date)

    events = _load_index()["events"]
    matched = [
        _format_event(
            event,
            include_uid=include_uid,
            include_program_id=include_program_id,
        )
        for event in events
        if _event_matches_program(event, program_query)
        and (not group_query or _event_matches_group(event, group_query))
        and _in_date_window(event, start, end)
    ]
    rows = matched[: _safe_limit(limit)]
    columns = [
        "start",
        "end",
        "location",
        "url",
        "course_code",
        "event_type",
        "course_codes",
        "groups",
        "course_names",
        "programs",
        "summary",
    ]
    if include_uid:
        columns.insert(0, "uid")

    return _format_list_response(
        rows=rows,
        title="events",
        columns=columns,
    )


@mcp.tool()
def list_events_filtered(
    text_query: str | None = None,
    location_query: str | None = None,
    course_query: str | None = None,
    program_query: str | None = None,
    group_query: str | None = None,
    start_date: str | None = None,
    end_date: str | None = None,
    limit: int = 100,
    include_uid: bool = False,
    include_program_id: bool = False,
) -> str:
    """List events using custom filters; at least one non-date filter is required."""
    if not any([text_query, location_query, course_query, program_query, group_query]):
        raise ValueError(
            "At least one filter is required (text_query, location_query, "
            "course_query, program_query, or group_query)."
        )

    start = _parse_date_filter(start_date)
    end = _parse_date_filter(end_date)

    text_norm = _normalize(text_query)
    location_norm = _normalize(location_query)

    events = _load_index()["events"]
    matched: list[dict[str, Any]] = []

    for event in events:
        if not _in_date_window(event, start, end):
            continue

        if text_norm and text_norm not in event["search_blob"]:
            continue
        if location_norm and location_norm not in _normalize(event["location"]):
            continue
        if course_query and not _event_matches_course(event, course_query):
            continue
        if program_query and not _event_matches_program(event, program_query):
            continue
        if group_query and not _event_matches_group(event, group_query):
            continue

        matched.append(
            _format_event(
                event,
                include_uid=include_uid,
                include_program_id=include_program_id,
            )
        )
        if len(matched) >= _safe_limit(limit):
            break

    columns = [
        "start",
        "end",
        "location",
        "url",
        "course_code",
        "event_type",
        "course_codes",
        "groups",
        "course_names",
        "programs",
        "summary",
    ]
    if include_uid:
        columns.insert(0, "uid")

    return _format_list_response(
        rows=matched,
        title="events",
        columns=columns,
    )


def main() -> None:
    parser = argparse.ArgumentParser(
        description="TimeEdit MCP server with 24-hour ICS caching and filtering tools."
    )
    parser.add_argument(
        "--ics-url",
        default=os.getenv("TIMEEDIT_ICS_URL"),
        help="TimeEdit .ics URL to use as the server source (or set TIMEEDIT_ICS_URL).",
    )
    parser.add_argument(
        "--output-timezone",
        default=os.getenv("TIMEEDIT_OUTPUT_TIMEZONE"),
        help="IANA timezone for event times (e.g. 'Europe/Stockholm', defaults to system timezone). Set via TIMEEDIT_OUTPUT_TIMEZONE.",
    )
    args = parser.parse_args()

    if not args.ics_url:
        parser.error("You must provide --ics-url or set TIMEEDIT_ICS_URL.")

    config.ics_url = args.ics_url
    config.output_timezone = args.output_timezone
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
