"""transit — "when's the next bus/train" and on-demand route planning for voice
conversation, from docs/project-plan.md Phase 19.

Two different jobs, kept as two different data paths on purpose:
  - /departures — next-departure lookups from the small SQLite DB `sync_gtfs.py`
    refreshes weekly (this repo's own code, see that file).
  - /plan — real A-to-B route planning, proxied straight through to a self-hosted
    **OpenTripPlanner** (OTP) instance, NOT reimplemented here. Journey planning
    (transfers, walking legs, multi-modal routing) is a genuinely hard, well-studied
    problem with mature open-source engines already solving it; hand-rolling one
    would be a bad trade against just running OTP. See "Route planning scope" in
    README.md for what "Austria, possibly global" actually costs to run.

Published (like pantry-vision/identity), not compose-network-only (like
admin-canvas) — reachable at a fixed host port so Home Assistant's `rest_command`
can reach it regardless of HA's own networking mode (this stack's `homeassistant`
container runs `network_mode: host`, which cannot resolve plain container DNS names
— see identity/identity.env.example's identical note). Bearer-token gated the same
way every other published service in this project is.
"""

from __future__ import annotations

import json
import logging
import os
import sqlite3
import sys
import urllib.error
import urllib.request
from datetime import datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit

LOG = logging.getLogger("transit")

TOKEN = os.environ.get("TRANSIT_TOKEN", "")
DB_PATH = Path(os.environ.get("TRANSIT_DB_PATH", "/data/transit.db"))
DEFAULT_STOP = os.environ.get("TRANSIT_DEFAULT_STOP", "")

# OpenTripPlanner — optional, separate opt-in from the /departures path above (see
# ENABLE_TRIP_PLANNING in transit.env.example and README.md's "Route planning
# scope" section for why: a real OTP graph is a meaningfully bigger data/hardware
# commitment than the small filtered GTFS DB /departures uses). GraphQL path is
# OTP2's documented default — VERIFY against whichever OTP version you actually run;
# OTP1 uses a different REST shape entirely.
OTP_URL = os.environ.get("OTP_URL", "").rstrip("/")
OTP_GRAPHQL_PATH = os.environ.get("OTP_GRAPHQL_PATH", "/otp/gtfs/v1")


def _db() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def _active_service_ids(conn: sqlite3.Connection, today: datetime) -> set[str]:
    weekday_col = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"][today.weekday()]
    date_str = today.strftime("%Y%m%d")
    rows = conn.execute(
        f"SELECT service_id FROM calendar WHERE {weekday_col} = 1 AND start_date <= ? AND end_date >= ?",
        (date_str, date_str),
    ).fetchall()
    active = {r["service_id"] for r in rows}

    for row in conn.execute("SELECT service_id, exception_type FROM calendar_dates WHERE date = ?", (date_str,)):
        if row["exception_type"] == 1:
            active.add(row["service_id"])
        elif row["exception_type"] == 2:
            active.discard(row["service_id"])
    return active


def departures(stop_query: str, limit: int) -> dict:
    if not DB_PATH.exists():
        return {"error": "no GTFS data synced yet — run sync_gtfs.py first"}

    conn = _db()
    try:
        matched = conn.execute(
            "SELECT stop_id, stop_name FROM stops WHERE lower(stop_name) LIKE ?",
            (f"%{stop_query.lower()}%",),
        ).fetchall()
        if not matched:
            return {"error": f"no known stop matches {stop_query!r}", "stop": stop_query}

        stop_ids = [m["stop_id"] for m in matched]
        now = datetime.now()
        active_services = _active_service_ids(conn, now)
        if not active_services:
            return {"stop": matched[0]["stop_name"], "departures": []}

        now_str = now.strftime("%H:%M:%S")
        placeholders_stops = ",".join("?" * len(stop_ids))
        placeholders_services = ",".join("?" * len(active_services))
        rows = conn.execute(
            f"""
            SELECT st.departure_time, r.short_name, r.long_name, t.headsign
            FROM stop_times st
            JOIN trips t ON t.trip_id = st.trip_id
            JOIN routes r ON r.route_id = t.route_id
            WHERE st.stop_id IN ({placeholders_stops})
              AND t.service_id IN ({placeholders_services})
              AND st.departure_time >= ?
            ORDER BY st.departure_time
            LIMIT ?
            """,
            (*stop_ids, *active_services, now_str, limit),
        ).fetchall()

        return {
            "stop": matched[0]["stop_name"],
            "departures": [
                {
                    "time": r["departure_time"],
                    "route": r["short_name"] or r["long_name"],
                    "headsign": r["headsign"],
                }
                for r in rows
            ],
        }
    finally:
        conn.close()


def list_stops() -> dict:
    if not DB_PATH.exists():
        return {"stops": []}
    conn = _db()
    try:
        rows = conn.execute("SELECT DISTINCT stop_name FROM stops ORDER BY stop_name").fetchall()
        return {"stops": [r["stop_name"] for r in rows]}
    finally:
        conn.close()


# A minimal, documented-shape GraphQL query for OTP2's /otp/gtfs/v1 endpoint — asks
# for the first itinerary only (a voice answer wants "the way there," not five
# alternatives) with each leg's mode, route, and duration. VERIFY: written against
# OTP2's published schema, never run against a real instance — see README.md.
_OTP_PLAN_QUERY = """
query Plan($from: String!, $to: String!, $walkSpeed: Float!) {
  plan(
    fromPlace: $from
    toPlace: $to
    numItineraries: 1
    walkSpeed: $walkSpeed
  ) {
    itineraries {
      duration
      legs {
        mode
        route { shortName }
        from { name }
        to { name }
        duration
      }
    }
  }
}
"""

# OTP's own default is ~1.33 m/s (~4.8 km/h, a brisk adult pace) — deliberately
# slower here (~3.2 km/h) so walking-leg durations and connection feasibility (does
# a transfer's walking leg actually make the next departure?) reflect an unhurried
# real walking pace rather than a fit commuter's, per household preference.
# Override with a real per-person value if this still doesn't match reality.
WALK_SPEED_MPS = float(os.environ.get("WALK_SPEED_MPS", "0.9"))


def plan_trip(from_place: str, to_place: str) -> dict:
    """`from_place`/`to_place` are OTP's own "lat,lon" or geocoded-name format —
    passed through as given, not resolved/geocoded here. Degrades to a clear error
    (never a stack trace) if OTP isn't configured or unreachable, same "degrade,
    don't blank" rule as every other renderer in this project.
    """
    if not OTP_URL:
        return {"error": "trip planning is not configured (OTP_URL unset) — see README.md"}

    payload = json.dumps(
        {
            "query": _OTP_PLAN_QUERY,
            "variables": {"from": from_place, "to": to_place, "walkSpeed": WALK_SPEED_MPS},
        }
    ).encode("utf-8")
    req = urllib.request.Request(
        f"{OTP_URL}{OTP_GRAPHQL_PATH}", data=payload, method="POST",
        headers={"Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            result = json.loads(resp.read())
    except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
        LOG.warning("transit: OTP request failed", exc_info=True)
        return {"error": f"could not reach the trip planner at {OTP_URL}"}
    except ValueError:
        LOG.warning("transit: OTP returned non-JSON", exc_info=True)
        return {"error": "trip planner returned an unexpected response"}

    itineraries = (result.get("data") or {}).get("plan", {}).get("itineraries") or []
    if not itineraries:
        return {"from": from_place, "to": to_place, "found": False}

    best = itineraries[0]
    return {
        "from": from_place,
        "to": to_place,
        "found": True,
        "duration_minutes": round(best.get("duration", 0) / 60),
        "legs": [
            {
                "mode": leg.get("mode"),
                "route": (leg.get("route") or {}).get("shortName"),
                "from": (leg.get("from") or {}).get("name"),
                "to": (leg.get("to") or {}).get("name"),
                "duration_minutes": round((leg.get("duration") or 0) / 60),
            }
            for leg in best.get("legs", [])
        ],
    }


class Handler(BaseHTTPRequestHandler):
    server_version = "transit/1"

    def log_message(self, format, *args):  # noqa: A002
        LOG.info("%s - %s", self.address_string(), format % args)

    def _authorized(self) -> bool:
        if not TOKEN:
            return False
        return self.headers.get("Authorization", "") == f"Bearer {TOKEN}"

    def _respond(self, status: HTTPStatus, payload) -> None:
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):  # noqa: N802
        if not self._authorized():
            self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
            return

        split = urlsplit(self.path)
        params = parse_qs(split.query)

        if split.path == "/departures":
            stop = (params.get("stop", [DEFAULT_STOP])[0] or "").strip()
            if not stop:
                self._respond(HTTPStatus.BAD_REQUEST, {"error": "'stop' is required (or set TRANSIT_DEFAULT_STOP)"})
                return
            try:
                limit = min(int(params.get("limit", ["5"])[0]), 20)
            except ValueError:
                limit = 5
            self._respond(HTTPStatus.OK, departures(stop, limit))
        elif split.path == "/stops":
            self._respond(HTTPStatus.OK, list_stops())
        elif split.path == "/plan":
            from_place = (params.get("from", [""])[0] or "").strip()
            to_place = (params.get("to", [""])[0] or "").strip()
            if not from_place or not to_place:
                self._respond(HTTPStatus.BAD_REQUEST, {"error": "'from' and 'to' are both required"})
                return
            self._respond(HTTPStatus.OK, plan_trip(from_place, to_place))
        else:
            self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})


def main() -> int:
    logging.basicConfig(
        level=os.environ.get("LOG_LEVEL", "INFO").upper(),
        format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
    )
    if not TOKEN:
        LOG.error("TRANSIT_TOKEN is not set — every request will be rejected until it is.")

    port = int(os.environ.get("TRANSIT_PORT", "8099"))
    server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
    LOG.info("transit listening on :%d (db: %s)", port, DB_PATH)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    return 0


if __name__ == "__main__":
    sys.exit(main())
