#!/usr/bin/env python3
"""
JARVIS Display
──────────────
Renders the JARVIS UI overlay via X11.

  Development:   python display.py
  Kiosk:         DISPLAY=:0 python display.py --fullscreen

Weather via Open-Meteo (free, no API key).
Location set by --location postcode (default BH8 8JZ).
"""

import os
import json
import math
import threading
import time as _time
import argparse
import urllib.request
import urllib.parse
from datetime import datetime

_parser = argparse.ArgumentParser(description='JARVIS display', add_help=True)
_parser.add_argument('--fullscreen', '-fs', action='store_true',
                     help='Run fullscreen under X11/Wayland')
_parser.add_argument('--width',  type=int, default=1280,
                     help='Screen width  (default 1280)')
_parser.add_argument('--height', type=int, default=800,
                     help='Screen height (default 800)')
_parser.add_argument('--location', type=str, default='BH8 8JZ',
                     help='UK postcode for weather (default BH8 8JZ)')
_args, _ = _parser.parse_known_args()

import pygame

# ── Colour palette ───────────────────────────────────────────────────────────
BLACK    = (  0,   0,   0)
WHITE    = (255, 255, 255)
GRAY     = (160, 160, 160)
DIM_GRAY = ( 80,  80,  80)
WIRE_COL = ( 58,  58,  58)
WIRE_EYE = ( 95,  95,  95)

# ── WMO weather codes (Open-Meteo) ────────────────────────────────────────────
_WMO = {
    0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
    45: 'Fog', 48: 'Freezing fog',
    51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
    61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
    71: 'Light snow', 73: 'Snow', 75: 'Heavy snow', 77: 'Snow grains',
    80: 'Light showers', 81: 'Showers', 82: 'Heavy showers',
    85: 'Snow showers', 86: 'Heavy snow showers',
    95: 'Thunderstorm', 96: 'Thunderstorm + hail', 99: 'Thunderstorm + hail',
}

# ── Weather state (updated by background thread) ──────────────────────────────
_weather: dict = {}          # keys: 'week' (list), 'hours' (list), 'location' (str)
_weather_lock = threading.Lock()


def _postcode_to_latlon(postcode: str) -> tuple[float, float]:
    url = 'https://api.postcodes.io/postcodes/' + urllib.parse.quote(postcode.replace(' ', ''))
    with urllib.request.urlopen(url, timeout=10) as r:
        data = json.loads(r.read())
    return data['result']['latitude'], data['result']['longitude']


def _fetch_weather(lat: float, lon: float) -> None:
    url = (
        'https://api.open-meteo.com/v1/forecast'
        f'?latitude={lat:.4f}&longitude={lon:.4f}'
        '&daily=weather_code,temperature_2m_max,temperature_2m_min'
        '&hourly=temperature_2m,weather_code'
        '&timezone=Europe%2FLondon'
        '&forecast_days=7'
    )
    with urllib.request.urlopen(url, timeout=10) as r:
        data = json.loads(r.read())

    daily  = data['daily']
    hourly = data['hourly']

    week = []
    for i, date_str in enumerate(daily['time']):
        d = datetime.strptime(date_str, '%Y-%m-%d')
        week.append({
            'day':  'Today' if i == 0 else d.strftime('%A'),
            'date': d.strftime('%-d %b'),
            'high': round(daily['temperature_2m_max'][i]),
            'low':  round(daily['temperature_2m_min'][i]),
            'desc': _WMO.get(daily['weather_code'][i], ''),
        })

    now   = datetime.now()
    hours = []
    for i, ts in enumerate(hourly['time']):
        t = datetime.strptime(ts, '%Y-%m-%dT%H:%M')
        if t < now or t.date() != now.date():
            continue
        hours.append({
            'label': t.strftime('%-I%p').lower(),
            'temp':  round(hourly['temperature_2m'][i]),
        })

    with _weather_lock:
        _weather['week']  = week
        _weather['hours'] = hours


def _weather_worker(lat: float, lon: float) -> None:
    while True:
        try:
            _fetch_weather(lat, lon)
        except Exception:
            pass
        _time.sleep(1800)


# ── Wireframe face ────────────────────────────────────────────────────────────

def _hw(t: float, rx: float) -> float:
    if t <= 0.0:
        return rx * math.sqrt(max(0.0, 1.0 - t * t))
    return rx * math.sqrt(max(0.0, 1.0 - t * t)) * (1.0 - 0.30 * t)


def draw_wireframe_face(surface: pygame.Surface,
                        cx: int, cy: int, rx: float, ry: float) -> None:
    N_H, N_V, PTS = 13, 11, 40

    def sc(xf, t):
        w   = _hw(t, rx)
        bow = int(0.06 * w * (1.0 - t*t) * (1.0 - xf*xf))
        return (int(cx + xf*w), int(cy + t*ry) - bow)

    def draw(pts, col=WIRE_COL):
        if len(pts) >= 2:
            pygame.draw.lines(surface, col, False, pts, 1)

    t_vals = [-1.0 + 2.0*i/(PTS-1) for i in range(PTS)]
    draw([sc(-1.0, t) for t in t_vals])
    draw([sc( 1.0, t) for t in t_vals])
    pygame.draw.lines(surface, WIRE_COL, False, [sc(-1.0,-1.0), sc(1.0,-1.0)], 1)
    pygame.draw.lines(surface, WIRE_COL, False, [sc(-0.6,1.0), sc(0.0,1.02), sc(0.6,1.0)], 1)

    for i in range(1, N_H):
        t = -1.0 + 2.0*i/N_H
        draw([sc(-1.0 + 2.0*j/(PTS-1), t) for j in range(PTS)])
    for i in range(1, N_V):
        xf = -1.0 + 2.0*i/N_V
        draw([sc(xf, t) for t in t_vals])

    eye_t, eye_xf = -0.38, 0.35
    eye_w = _hw(eye_t, rx)
    e_rx  = int(eye_w * 0.24)
    e_ry  = int(ry * 0.09)
    for sign in (-1, +1):
        ecx = int(cx + sign * eye_xf * eye_w)
        ecy = int(cy + eye_t * ry)
        pts = [(int(ecx + e_rx*math.cos(2*math.pi*k/28)),
                int(ecy + e_ry*math.sin(2*math.pi*k/28))) for k in range(29)]
        pygame.draw.lines(surface, WIRE_EYE, True, pts, 1)

    inner  = int(eye_w * 0.12)
    e_btm  = int(cy + eye_t*ry) + e_ry
    nose_y = int(cy + 0.08*ry)
    nose_w = int(eye_w * 0.07)
    draw([(cx-inner, e_btm), (cx-nose_w, nose_y)])
    draw([(cx+inner, e_btm), (cx+nose_w, nose_y)])


# ── Clock ─────────────────────────────────────────────────────────────────────

def draw_clock(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None:
    now    = datetime.now()
    t_surf = fonts['large'].render(now.strftime('%-H:%M'), True, WHITE)
    s_surf = fonts['small'].render(now.strftime('%S'), True, DIM_GRAY)
    surface.blit(fonts['small'].render(now.strftime('%A, %-d %B %Y'), True, GRAY), (x, y))
    ty = y + fonts['small'].get_height() + 4
    surface.blit(t_surf, (x, ty))
    surface.blit(s_surf, (x + t_surf.get_width() + 2,
                           ty + t_surf.get_height() - s_surf.get_height() - 8))


# ── Weather panel ─────────────────────────────────────────────────────────────

def draw_weather(surface: pygame.Surface, fonts: dict,
                 x: int, y: int, available_w: int) -> None:
    with _weather_lock:
        week  = list(_weather.get('week',  []))
        hours = list(_weather.get('hours', []))

    th = fonts['tiny'].get_height()

    if not week:
        surface.blit(fonts['tiny'].render('Weather loading…', True, DIM_GRAY), (x, y))
        return

    # 5-day columns
    days   = week[:5]
    col_w  = available_w // len(days)
    for i, day in enumerate(days):
        cx    = x + i * col_w
        label = 'Today' if i == 0 else day['day'][:3]
        desc  = day['desc'] if len(day['desc']) <= 13 else day['desc'][:12] + '…'
        surface.blit(fonts['tiny'].render(label,                    True, GRAY),     (cx, y))
        surface.blit(fonts['tiny'].render(f"{day['high']}°/{day['low']}°", True, WHITE),  (cx, y + th + 2))
        surface.blit(fonts['tiny'].render(desc,                     True, DIM_GRAY), (cx, y + 2*(th + 2)))

    # Hourly strip — remaining hours today, two rows (label / temp)
    if hours:
        hy    = y + 3*(th + 2) + 10
        hw    = available_w // min(len(hours[:8]), 8)
        for i, h in enumerate(hours[:8]):
            hx = x + i * hw
            surface.blit(fonts['tiny'].render(h['label'],      True, DIM_GRAY), (hx, hy))
            surface.blit(fonts['tiny'].render(f"{h['temp']}°", True, GRAY),     (hx, hy + th + 1))


def draw_dot(surface: pygame.Surface, cx: int, y: int, radius: int = 9) -> None:
    pygame.draw.circle(surface, WHITE, (cx, y), radius)


# ── Font helper ───────────────────────────────────────────────────────────────

def _load_font(size: int, bold: bool = False) -> pygame.font.Font:
    for name in ('DejaVuSans', 'FreeSans', 'LiberationSans', 'Helvetica', 'Arial', None):
        try:
            f = pygame.font.SysFont(name, size, bold=bold)
            if f:
                return f
        except Exception:
            pass
    return pygame.font.Font(None, size)


# ── Main loop ─────────────────────────────────────────────────────────────────

def main() -> None:
    pygame.init()

    W, H = _args.width, _args.height

    if _args.fullscreen:
        screen = pygame.display.set_mode((W, H), pygame.FULLSCREEN | pygame.NOFRAME)
    else:
        screen = pygame.display.set_mode((W, H))
        pygame.display.set_caption('JARVIS')

    pygame.mouse.set_visible(False)

    fonts = {
        'large': _load_font(38),
        'small': _load_font(13),
        'tiny':  _load_font(11),
    }

    face_rx = int(min(W, H) * 0.085)
    face_ry = int(min(W, H) * 0.100)
    face_cx = W // 2
    face_cy = int(face_ry * 1.2) + 8
    dot_y   = int(H * 0.895)

    clock_block_h = fonts['small'].get_height() + 4 + fonts['large'].get_height()
    clock_y       = face_cy - clock_block_h // 2
    weather_x     = face_cx + face_rx + 30

    # Resolve postcode and start weather thread
    try:
        lat, lon = _postcode_to_latlon(_args.location)
        threading.Thread(target=_weather_worker, args=(lat, lon), daemon=True).start()
    except Exception:
        pass

    _here    = os.path.dirname(os.path.abspath(__file__))
    png_path = os.path.join(_here, 'assets', 'face_wire.png')
    face_img = None
    if os.path.exists(png_path):
        raw      = pygame.image.load(png_path).convert()
        iw, ih   = raw.get_size()
        scale    = min((face_rx * 2.2) / iw, (face_ry * 2.4) / ih)
        face_img = pygame.transform.smoothscale(
            raw, (max(1, int(iw*scale)), max(1, int(ih*scale))))

    pg_clock = pygame.time.Clock()

    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit(); return
            if event.type == pygame.KEYDOWN and event.key in (pygame.K_ESCAPE, pygame.K_q):
                pygame.quit(); return

        screen.fill(BLACK)

        if face_img:
            screen.blit(face_img, face_img.get_rect(center=(face_cx, face_cy)),
                        special_flags=pygame.BLEND_ADD)
        else:
            draw_wireframe_face(screen, face_cx, face_cy, face_rx, face_ry)

        draw_clock(screen, fonts, 20, clock_y)
        draw_weather(screen, fonts, weather_x, clock_y, W - 20 - weather_x)
        draw_dot(screen, W // 2, dot_y)

        pygame.display.flip()
        pg_clock.tick(10)


if __name__ == '__main__':
    main()
