import os
import json
import time
import threading
from urllib.parse import quote

import requests
import shutil
from flask import Flask, request, jsonify, send_file, send_from_directory, render_template
from werkzeug.utils import secure_filename
from uuid import uuid4
from datetime import datetime, date, time as dtime, timedelta, timezone
try:
    from zoneinfo import ZoneInfo  # Python 3.9+
except Exception:
    ZoneInfo = None


ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(ROOT_DIR, 'data')
ASSETS_DIR = os.path.join(ROOT_DIR, 'assets')
INDEX_PATH = os.path.join(ROOT_DIR, 'templates', 'index.html')
CONFIG_PATH = os.path.join(ROOT_DIR, 'config.json')
UPLOADS_DIR = os.path.join(ASSETS_DIR, 'uploads')
UPLOADS_DELETE_DIR = os.path.join(UPLOADS_DIR, 'delete')
ICS_DIR = os.path.join(DATA_DIR, 'ics')

os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(ASSETS_DIR, exist_ok=True)
os.makedirs(UPLOADS_DIR, exist_ok=True)
os.makedirs(UPLOADS_DELETE_DIR, exist_ok=True)
os.makedirs(os.path.join(DATA_DIR, 'delete'), exist_ok=True)
os.makedirs(os.path.join(DATA_DIR, 'delete', 'diary'), exist_ok=True)
os.makedirs(ICS_DIR, exist_ok=True)


def _json_list_path(name: str) -> str:
    return os.path.join(DATA_DIR, f'{name}.json')


def _read_list(name: str):
    path = _json_list_path(name)
    if not os.path.exists(path):
        return []
    try:
        with open(path, 'r', encoding='utf-8') as f:
            return json.load(f)
    except Exception:
        return []


def _write_list(name: str, items):
    path = _json_list_path(name)
    with open(path, 'w', encoding='utf-8') as f:
        json.dump(items, f, ensure_ascii=False, indent=2)


def _append_item(name: str, item: dict, prepend: bool = True):
    items = _read_list(name)
    if prepend:
        items.insert(0, item)
    else:
        items.append(item)
    _write_list(name, items)
    return item


app = Flask(__name__, static_folder=None, template_folder=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates'))


def load_config():
    # 默认配置：仅公开 icon 可直接写在这里
    cfg = {
        "BARK_SERVER": "https://api.yanzhe.us",
        "BARK_API_KEY": "",
        "ICON_URL": "https://us.yanzhe.us/jyz/asset/avatar.png",
        "BARK_PREFIX": "",  # 可选：如需在 server 与 key 之间加一段（例如用户名/频道名）
        "GEMINI_API_KEY": "",
        "PORT": 9999,
        "DELETE_PASSWORD": "123",
        "WEATHER_API_ID": "",
        "WEATHER_API_KEY": "",
    }
    if os.path.exists(CONFIG_PATH):
        try:
            with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
                file_cfg = json.load(f)
                if isinstance(file_cfg, dict):
                    cfg.update({k: v for k, v in file_cfg.items() if v is not None})
        except Exception:
            pass
    return cfg


CONFIG = load_config()


@app.get('/')
def index():
    # 将服务器缓存的日历注入到页面，避免前端首次点击再去请求
    initial = _ICS_CACHE['data'] if _ICS_CACHE['data'] is not None else {'events': []}
    return render_template('index.html', initial_calendar=initial)


@app.get('/assets/<path:filename>')
def assets(filename):
    return send_from_directory(ASSETS_DIR, filename)


@app.post('/api/upload')
def upload_image():
    f = request.files.get('file')
    if not f:
        return jsonify({'ok': False, 'error': 'file required'}), 400
    # 简单校验类型
    mime = (f.mimetype or '').lower()
    if not mime.startswith('image/'):
        return jsonify({'ok': False, 'error': 'only image allowed'}), 400

    # 确定扩展名
    filename = secure_filename(f.filename or '')
    ext = ''
    if '.' in filename:
        ext = filename.rsplit('.', 1)[-1].lower()
    else:
        # 依据 mimetype 给个合理扩展
        if mime == 'image/jpeg':
            ext = 'jpg'
        elif mime == 'image/png':
            ext = 'png'
        elif mime == 'image/gif':
            ext = 'gif'
        elif mime == 'image/webp':
            ext = 'webp'
        else:
            ext = 'img'

    new_name = f"{int(time.time()*1000)}_{uuid4().hex[:8]}.{ext}"
    save_path = os.path.join(UPLOADS_DIR, new_name)
    f.save(save_path)
    url = f"/assets/uploads/{new_name}"
    return jsonify({'ok': True, 'url': url})


@app.post('/api/push')
def api_push():
    data = request.get_json(silent=True) or {}
    text = (data.get('text') or '').strip()
    if not text:
        return jsonify({'ok': False, 'error': 'text required'}), 400

    title = (data.get('title') or '').strip()
    sound = (data.get('sound') or '').strip()
    level = (data.get('level') or '').strip()  # e.g., timeSensitive
    path_style = (data.get('path_style') or '').strip()  # 'body_only' | ''
    call_flag = (data.get('call') or '').strip()  # '1' to trigger call mode
    volume = (data.get('volume') or '').strip()   # optional volume
    include_icon = bool(data.get('include_icon')) # force include icon even when no prefix
    # channel: 覆盖配置里的前缀。若前端显式提供（即使为空字符串）则以其为准；
    # 若未提供该字段，则使用配置中的 BARK_PREFIX。
    channel_field_present = 'channel' in data
    raw_channel = data.get('channel') if channel_field_present else None
    channel = (raw_channel if raw_channel is not None else '').strip()

    # 固定写死链接要素（不再从环境/配置读取）
    bark_server = 'https://api.yanzhe.us'
    bark_key = 'A58wWxWuR9NnM8fUMA8QmC'
    icon_url = 'https://us.yanzhe.us/jyz/asset/avatar.png'
    if channel_field_present:
        # 显式指定（可为空表示不使用前缀）
        bark_prefix = channel
    else:
        # 固定写死默认前缀
        bark_prefix = 'Pookie❤️'

    # Build GET URL (Bark path style)
    # 允许 body-only 形式：/{key}/{text}
    path_parts = [bark_server]
    if bark_prefix:
        path_parts.append(quote(bark_prefix))
    path_parts.append(bark_key)
    if path_style == 'body_only' or not title:
        path_parts.append(quote(text))
    else:
        path_parts.extend([quote(title), quote(text)])
    url = '/'.join(part.strip('/') for part in path_parts)
    params = []
    # 在有前缀或显式要求包含时，追加 icon
    if icon_url and (bark_prefix or include_icon):
        params.append(f"icon={quote(icon_url)}")
    if sound:
        params.append(f"sound={quote(sound)}")
    if level:
        params.append(f"level={quote(level)}")
    if call_flag:
        params.append(f"call={quote(call_flag)}")
    if volume:
        params.append(f"volume={quote(volume)}")
    if params:
        url += '?' + '&'.join(params)

    # 首选 GET 方式；失败则回退 JSON POST 方式
    ok = False
    err_msg = None
    try:
        resp = requests.get(url, timeout=6)
        ok = resp.ok
    except Exception as e:
        err_msg = f"GET fail: {e}"

    if not ok:
        try:
            post_url = f"{bark_server.rstrip('/')}/push"
            payload = {
                'device_key': bark_key,
                'body': text,
            }
            if title and path_style != 'body_only':
                payload['title'] = title
            if sound:
                payload['sound'] = sound
            if level:
                payload['level'] = level
            if icon_url and (bark_prefix or include_icon):
                payload['icon'] = icon_url
            if call_flag:
                payload['call'] = call_flag
            if volume:
                payload['volume'] = volume
            resp2 = requests.post(post_url, json=payload, timeout=8)
            ok = resp2.ok
            if not ok and not err_msg:
                try:
                    err_msg = resp2.text[:200]
                except Exception:
                    err_msg = 'POST failed'
        except Exception as e:
            err_msg = (err_msg + ' | ' if err_msg else '') + f"POST fail: {e}"

    return jsonify({'ok': ok, 'error': (None if ok else (err_msg or 'request failed'))})


@app.get('/api/chat')
def chat_list():
    return jsonify(_read_list('chat'))


@app.post('/api/chat')
def chat_add():
    data = request.get_json(silent=True) or {}
    text = (data.get('text') or '').strip()
    if not text:
        return jsonify({'ok': False, 'error': 'text required'}), 400
    item = {
        'id': int(time.time() * 1000),
        'ts': int(time.time()),
        'text': text,
        'sound': data.get('sound') or '',
        'meta': data.get('meta') or {},
    }
    _append_item('chat', item)
    return jsonify({'ok': True, 'item': item})


@app.get('/api/diary')
def diary_list():
    return jsonify(_read_list('diary'))


@app.post('/api/diary')
def diary_add():
    data = request.get_json(silent=True) or {}
    content = (data.get('content') or '').strip()
    if not content:
        return jsonify({'ok': False, 'error': 'content required'}), 400
    item = {
        'id': int(time.time() * 1000),
        'ts': int(time.time()),
        'date': data.get('date'),
        'time': data.get('time'),
        'weather': data.get('weather'),
        'mood': data.get('mood'),
        'content': content,
        'image': data.get('image') or '',
        'region': data.get('region') or '',
    }
    _append_item('diary', item)
    return jsonify({'ok': True, 'item': item})


@app.post('/api/diary/delete')
def diary_delete():
    payload = request.get_json(silent=True) or {}
    try:
        target_id = int(payload.get('id'))
    except Exception:
        return jsonify({'ok': False, 'error': 'invalid id'}), 400
    password = (payload.get('password') or '').strip()
    if password != str(CONFIG.get('DELETE_PASSWORD', '123')):
        return jsonify({'ok': False, 'error': 'forbidden'}), 403

    diaries = _read_list('diary')
    idx = next((i for i, d in enumerate(diaries) if str(d.get('id')) == str(target_id)), None)
    if idx is None:
        return jsonify({'ok': False, 'error': 'not found'}), 404

    # Pop the item
    item = diaries.pop(idx)
    _write_list('diary', diaries)

    # Archive JSON to data/delete/diary/<id>.json
    trash_dir = os.path.join(DATA_DIR, 'delete', 'diary')
    os.makedirs(trash_dir, exist_ok=True)
    try:
        with open(os.path.join(trash_dir, f"{item.get('id')}.json"), 'w', encoding='utf-8') as f:
            json.dump(item, f, ensure_ascii=False, indent=2)
    except Exception:
        pass

    # Move image to uploads/delete if exists
    image_url = item.get('image') or ''
    prefix = '/assets/uploads/'
    if image_url.startswith(prefix):
        rel = image_url[len(prefix):]
        src = os.path.join(UPLOADS_DIR, rel)
        dst_dir = UPLOADS_DELETE_DIR
        os.makedirs(dst_dir, exist_ok=True)
        dst = os.path.join(dst_dir, rel)
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        try:
            if os.path.exists(src):
                shutil.move(src, dst)
        except Exception:
            pass

    return jsonify({'ok': True})


@app.post('/api/magic')
def magic():
    payload = request.get_json(silent=True) or {}
    text = (payload.get('text') or '').strip()
    if not text:
        return jsonify({'ok': False, 'error': 'text required'}), 400

    api_key = str(CONFIG.get('GEMINI_API_KEY', '')).strip()
    if not api_key:
        return jsonify({'ok': False, 'error': 'GEMINI_API_KEY not configured'}), 500

    url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key={api_key}"
    prompt = text
    try:
        resp = requests.post(url, json={
            'contents': [{'parts': [{'text': prompt}]}]
        }, timeout=10)
        if not resp.ok:
            return jsonify({'ok': False, 'error': 'API error'}), 502
        data = resp.json()
        result_text = data['candidates'][0]['content']['parts'][0]['text'].strip()
        return jsonify({'ok': True, 'text': result_text})
    except Exception:
        return jsonify({'ok': False, 'error': 'Magic request failed'}), 502


@app.get('/api/weather')
def weather():
    api_id = str(CONFIG.get('WEATHER_API_ID', '')).strip()
    api_key = str(CONFIG.get('WEATHER_API_KEY', '')).strip()
    if not api_id or not api_key:
        return jsonify({'ok': False, 'error': 'weather config missing'}), 500
    # 明确传入访问者 IP，优先取前端透传的 ip 参数
    visitor_ip = (request.args.get('ip') or request.headers.get('X-Forwarded-For') or request.remote_addr or '').split(',')[0].strip()
    url = f"https://cn.apihz.cn/api/tianqi/tqybip.php?id={api_id}&key={api_key}" + (f"&ip={visitor_ip}" if visitor_ip else "")
    try:
        # 尝试把访问者 IP 透传给上游（有些服务用此识别地区/授权）
        fwd_ip = visitor_ip or request.headers.get('X-Forwarded-For') or request.remote_addr or ''
        headers = {
            'User-Agent': 'NoticeApp/1.0 (+https://local) Python-requests',
            'X-Forwarded-For': fwd_ip,
        }
        resp = requests.get(url, timeout=8, headers=headers)
        data = resp.json() if resp.ok else {}
        return jsonify(data)
    except Exception:
        return jsonify({'ok': False, 'error': 'weather request failed'}), 502


@app.get('/api/weather/prime_url')
def weather_prime_url():
    """Return the provider URL so the client can prime via visitor IP.
    Some providers require a request from the visitor IP to activate data.
    The client can fetch this URL with mode='no-cors' before calling /api/weather.
    """
    api_id = str(CONFIG.get('WEATHER_API_ID', '')).strip()
    api_key = str(CONFIG.get('WEATHER_API_KEY', '')).strip()
    if not api_id or not api_key:
        return jsonify({'ok': False, 'error': 'weather config missing'}), 500
    visitor_ip = (request.args.get('ip') or request.headers.get('X-Forwarded-For') or request.remote_addr or '').split(',')[0].strip()
    url = f"https://cn.apihz.cn/api/tianqi/tqybip.php?id={api_id}&key={api_key}" + (f"&ip={visitor_ip}" if visitor_ip else "")
    return jsonify({'ok': True, 'url': url})


@app.get('/api/ics/latest')
def api_ics_latest():
    """Return latest .ics snapshot content from data/ics.
    Picks the lexicographically latest 12-digit timestamped file (YYYYMMDDHHMM.ics).
    """
    try:
        files = []
        for fn in os.listdir(ICS_DIR):
            if not fn.endswith('.ics'):
                continue
            stem = fn[:-4]
            if len(stem) == 12 and stem.isdigit():
                files.append(fn)
        if not files:
            return jsonify({'ok': False, 'error': 'no ics found'}), 404
        files.sort(reverse=True)
        latest = files[0]
        path = os.path.join(ICS_DIR, latest)
        with open(path, 'r', encoding='utf-8') as f:
            content = f.read()
        # Provide filename and updated time
        try:
            stat = os.stat(path)
            mtime = int(stat.st_mtime)
        except Exception:
            mtime = None
        return jsonify({'ok': True, 'filename': latest, 'mtime': mtime, 'ics': content})
    except Exception:
        return jsonify({'ok': False, 'error': 'read ics failed'}), 500


@app.get('/api/status')
def status_api():
    """Return presence based on calendar and local time + optional weather.
    Rules:
      - 00:00–08:00 -> sleeping (gray)
      - Otherwise, if now overlaps a non-all-day event -> busy (orange)
      - Else idle (green)
    """
    # 1) Derive presence from cached calendar
    def _load_events_window(today_dt: datetime, end_dt: datetime):
        # Try memory cache first
        data = _ICS_CACHE.get('data') or {}
        events = data.get('events') or []
        if events:
            return events
        # Try disk cache json
        try:
            if os.path.exists(_ICS_CACHE_PATH):
                with open(_ICS_CACHE_PATH, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    if isinstance(data, dict):
                        ev = data.get('events') or []
                        if ev:
                            return ev
        except Exception:
            pass
        # Try latest raw ICS snapshot
        try:
            files = []
            for fn in os.listdir(ICS_DIR):
                if fn.endswith('.ics'):
                    stem = fn[:-4]
                    if len(stem) == 12 and stem.isdigit():
                        files.append(fn)
            if files:
                files.sort(reverse=True)
                latest = os.path.join(ICS_DIR, files[0])
                with open(latest, 'r', encoding='utf-8') as f:
                    raw = f.read()
                return [e for e in _parse_ics(raw, today_dt, end_dt)]
        except Exception:
            pass
        return []

    def presence_from_calendar() -> str:
        try:
            if ZoneInfo is not None:
                now_hk = datetime.now(ZoneInfo('Asia/Hong_Kong'))
                today_dt = datetime(now_hk.year, now_hk.month, now_hk.day)
            else:
                now_hk = datetime.now()
                today_dt = datetime.combine(date.today(), dtime.min)
            hour = now_hk.hour
            if 0 <= hour < 8:
                return 'sleeping'
            end_dt = today_dt + timedelta(days=3)
            # Use naive local (HK) time for comparison because cached events are naive
            now_local = datetime(now_hk.year, now_hk.month, now_hk.day, now_hk.hour, now_hk.minute, now_hk.second, now_hk.microsecond)
            events = _load_events_window(today_dt, end_dt)
            for e in events:
                try:
                    s = datetime.fromisoformat(e.get('start'))
                    en = datetime.fromisoformat(e.get('end'))
                    if s <= now_local <= en:
                        # 全天事件不视为“忙”
                        if not e.get('all_day', False):
                            return 'busy'
                except Exception:
                    continue
            return 'idle'
        except Exception:
            return 'idle'

    status = presence_from_calendar()

    # 2) Weather (optional)
    temp = '—°'
    cond = '--'
    try:
        api_id = str(CONFIG.get('WEATHER_API_ID', '')).strip()
        api_key = str(CONFIG.get('WEATHER_API_KEY', '')).strip()
        if api_id and api_key:
            visitor_ip = (request.headers.get('X-Forwarded-For') or request.remote_addr or '').split(',')[0].strip()
            url = f"https://cn.apihz.cn/api/tianqi/tqybip.php?id={api_id}&key={api_key}" + (f"&ip={visitor_ip}" if visitor_ip else "")
            fwd_ip = request.headers.get('X-Forwarded-For') or request.remote_addr or ''
            headers = {
                'User-Agent': 'NoticeApp/1.0 (+https://local) Python-requests',
                'X-Forwarded-For': fwd_ip,
            }
            resp = requests.get(url, timeout=6, headers=headers)
            if resp.ok:
                data = resp.json()
                cond = data.get('weather1') or cond
                nowinfo = data.get('nowinfo') or {}
                t = nowinfo.get('temperature')
                if t is not None:
                    temp = f"{t}°"
    except Exception:
        pass

    return jsonify({'ok': True, 'status': status, 'temp': temp, 'weather': cond})


# ============ Calendar (ICS) ============
_ICS_CACHE = {
    'ts': 0,
    'data': None,
}
_ICS_CACHE_PATH = os.path.join(DATA_DIR, 'calendar_cache.json')


def _unfold_ics(text: str) -> list[str]:
    lines = text.splitlines()
    out = []
    for ln in lines:
        if ln.startswith(' '):
            if out:
                out[-1] += ln[1:]
        else:
            out.append(ln.rstrip('\r'))
    return out


def _parse_ics_datetime(raw_value: str, params: str) -> tuple[datetime | date | None, bool]:
    # Returns (dt_or_date, is_all_day)
    v = raw_value.strip()
    # All-day date like 20251223 or VALUE=DATE
    if 'VALUE=DATE' in params or (len(v) == 8 and v.isdigit()):
        try:
            return datetime.strptime(v, '%Y%m%d').date(), True
        except Exception:
            return None, False
    # Date-time, possibly Zulu or with TZID
    is_utc = v.endswith('Z')
    if is_utc:
        v = v[:-1]
    # Try detect TZID
    tzid = None
    try:
        # params format like ';TZID=Europe/London;VALUE=DATE-TIME'
        ps = [p for p in params.split(';') if p]
        for p in ps:
            if p.upper().startswith('TZID='):
                tzid = p.split('=', 1)[1]
                break
    except Exception:
        tzid = None
    for fmt in ('%Y%m%dT%H%M%S', '%Y%m%dT%H%M'):
        try:
            dt = datetime.strptime(v, fmt)
            if is_utc:
                # Convert UTC -> Hong Kong time if available; else local
                if ZoneInfo is not None:
                    dt = dt.replace(tzinfo=timezone.utc).astimezone(ZoneInfo('Asia/Hong_Kong')).replace(tzinfo=None)
                else:
                    dt = dt.replace(tzinfo=timezone.utc).astimezone().replace(tzinfo=None)
            elif tzid and ZoneInfo is not None:
                try:
                    # Treat naive as local time in tzid, convert to Hong Kong, then drop tzinfo
                    zoned = dt.replace(tzinfo=ZoneInfo(tzid))
                    dt = zoned.astimezone(ZoneInfo('Asia/Hong_Kong')).replace(tzinfo=None)
                except Exception:
                    # Fallback: keep as naive local
                    pass
            return dt, False
        except Exception:
            continue
    return None, False


def _weekday_to_int(s: str) -> int | None:
    m = {
        'MO': 0, 'TU': 1, 'WE': 2, 'TH': 3, 'FR': 4, 'SA': 5, 'SU': 6
    }
    return m.get(s.upper())


def _parse_rrule(s: str) -> dict:
    out = {}
    try:
        parts = s.strip().split(';')
        for p in parts:
            if '=' in p:
                k, v = p.split('=', 1)
                out[k.upper()] = v
    except Exception:
        pass
    return out


def _parse_ics(text: str, window_start: datetime | None = None, window_end: datetime | None = None) -> list[dict]:
    lines = _unfold_ics(text)
    events = []
    i = 0
    while i < len(lines):
        if lines[i].startswith('BEGIN:VEVENT'):
            block = []
            i += 1
            while i < len(lines) and not lines[i].startswith('END:VEVENT'):
                block.append(lines[i])
                i += 1
            # Parse block
            summary = ''
            location = ''
            dt_start = None
            dt_end = None
            all_day = False
            rrule = ''
            for ln in block:
                if ':' not in ln:
                    continue
                key_params, value = ln.split(':', 1)
                key = key_params.split(';', 1)[0].upper()
                params = key_params[len(key):]
                if key == 'SUMMARY':
                    summary = value.strip()
                elif key == 'LOCATION':
                    location = value.strip()
                elif key == 'DTSTART':
                    dt, is_ad = _parse_ics_datetime(value, params)
                    if dt is not None:
                        dt_start = dt
                        all_day = all_day or is_ad
                elif key == 'DTEND':
                    dt, is_ad = _parse_ics_datetime(value, params)
                    if dt is not None:
                        dt_end = dt
                        all_day = all_day or is_ad
                elif key == 'RRULE':
                    rrule = value.strip()
            if dt_start is None:
                continue
            # If DTEND missing, assume 1 hour for timed, or same day for all-day
            if dt_end is None:
                if isinstance(dt_start, datetime):
                    dt_end = dt_start + timedelta(hours=1)
                else:
                    dt_end = dt_start
                    all_day = True

            # Normalize: convert date to a day range (full day); convert to datetime bounds
            def add_event(sdt: datetime, edt: datetime, is_all_day: bool):
                if is_all_day:
                    events.append({
                        'summary': summary,
                        'location': location,
                        'start': sdt.isoformat(),
                        'end': edt.isoformat(),
                        'all_day': True,
                    })
                else:
                    events.append({
                        'summary': summary,
                        'location': location,
                        'start': sdt.isoformat(),
                        'end': edt.isoformat(),
                        'all_day': False,
                    })

            # Compute base normalized range
            if isinstance(dt_start, date) and not isinstance(dt_start, datetime):
                base_start = datetime.combine(dt_start, dtime.min)
                if isinstance(dt_end, date) and not isinstance(dt_end, datetime):
                    base_end = datetime.combine(dt_end, dtime.min)
                else:
                    base_end = datetime.combine(dt_start + timedelta(days=1), dtime.min)
                base_all_day = True
            else:
                base_start = dt_start if isinstance(dt_start, datetime) else datetime.combine(dt_start, dtime.min)
                if isinstance(dt_end, date) and not isinstance(dt_end, datetime):
                    base_end = datetime.combine(dt_end, dtime.min)
                else:
                    base_end = dt_end if isinstance(dt_end, datetime) else datetime.combine(dt_end, dtime.min)
                base_all_day = False

            if rrule and window_start is not None and window_end is not None:
                # Limited RRULE support: WEEKLY (+BYDAY,INTERVAL,UNTIL,COUNT)
                rr = _parse_rrule(rrule)
                freq = rr.get('FREQ', '').upper()
                interval = int(rr.get('INTERVAL', '1') or '1') if rr.get('INTERVAL') else 1
                count = rr.get('COUNT')
                try:
                    count = int(count) if count is not None else None
                except Exception:
                    count = None
                until = rr.get('UNTIL')
                until_dt = None
                if until:
                    uv = until.strip()
                    # Support Zulu or naive local-like
                    if uv.endswith('Z'):
                        uv = uv[:-1]
                    for fmt in ('%Y%m%dT%H%M%S', '%Y%m%dT%H%M', '%Y%m%d'):
                        try:
                            tmp = datetime.strptime(uv, fmt)
                            if len(uv) == 8:
                                # date-only UNTIL -> exclusive next day 00:00
                                tmp = datetime.combine(tmp.date() + timedelta(days=1), dtime.min)
                            until_dt = tmp
                            break
                        except Exception:
                            continue

                # duration
                duration = base_end - base_start
                if duration.total_seconds() <= 0:
                    duration = timedelta(hours=1 if not base_all_day else 24)

                occ_added = 0
                if freq == 'WEEKLY':
                    byday_raw = rr.get('BYDAY')
                    if byday_raw:
                        days = []
                        for p in byday_raw.split(','):
                            w = _weekday_to_int(p.strip())
                            if w is not None:
                                days.append(w)
                        if not days:
                            days = [base_start.weekday()]
                    else:
                        days = [base_start.weekday()]

                    # For each weekday, generate occurrences spaced by INTERVAL weeks
                    for w in days:
                        # Find first occurrence on/after base_start at weekday w
                        t0 = base_start
                        delta = (w - t0.weekday()) % 7
                        first = t0 + timedelta(days=delta)
                        if first < base_start:
                            first += timedelta(days=7)

                        # Advance to first occurrence >= window_start
                        # Compute weeks between
                        def weeks_between(a: datetime, b: datetime) -> int:
                            return int((a.date() - b.date()).days // 7)

                        if first < window_start:
                            weeks = max(0, weeks_between(window_start, first))
                            # Jump by full INTERVAL steps
                            steps = (weeks + interval - 1) // interval
                            first = first + timedelta(days=7 * interval * steps)

                        cur = first
                        while cur < window_end:
                            if cur >= base_start:
                                if until_dt is None or cur <= until_dt:
                                    sdt = cur
                                    edt = cur + duration
                                    # keep only overlapping window
                                    if (sdt < window_end) and (edt > window_start):
                                        add_event(sdt, edt, base_all_day)
                                        occ_added += 1
                                        if count is not None and occ_added >= count:
                                            break
                            cur = cur + timedelta(days=7 * interval)
                        if count is not None and occ_added >= count:
                            break
                else:
                    # Unsupported FREQ -> fallback to single base event
                    add_event(base_start, base_end, base_all_day)
            else:
                # Single event
                add_event(base_start, base_end, base_all_day)
        i += 1
    return events


@app.get('/api/calendar')
def api_calendar():
    # 仅返回服务器缓存，不在请求时直连上游
    if _ICS_CACHE['data'] is not None:
        return jsonify({'ok': True, **_ICS_CACHE['data']})
    # 尝试从磁盘缓存恢复
    try:
        if os.path.exists(_ICS_CACHE_PATH):
            with open(_ICS_CACHE_PATH, 'r', encoding='utf-8') as f:
                data = json.load(f)
                if isinstance(data, dict) and 'events' in data:
                    _ICS_CACHE['data'] = data
                    return jsonify({'ok': True, **data})
    except Exception:
        pass
    return jsonify({'ok': True, 'events': []})


def _calendar_fetch_and_cache():
    ics_url = 'https://calendar.google.com/calendar/ical/i%40yanzhe.us/private-13da0f5c0486295ad4d67493c6256b3a/basic.ics'
    try:
        resp = requests.get(ics_url, timeout=8, headers={'User-Agent': 'NoticeApp/CalendarFetcher/1.0'})
        if not resp.ok:
            return False
        raw = resp.text
        # 1) 按要求每5分钟保存一次原始ICS快照到 data/ics/YYYYMMDDHHMM.ics（香港时间）
        try:
            if ZoneInfo is not None:
                ts_name = datetime.now(ZoneInfo('Asia/Hong_Kong')).strftime('%Y%m%d%H%M') + '.ics'
            else:
                ts_name = datetime.now().strftime('%Y%m%d%H%M') + '.ics'
            snap_path = os.path.join(ICS_DIR, ts_name)
            with open(snap_path, 'w', encoding='utf-8') as f:
                f.write(raw)
            # 仅保留最新3个
            try:
                files = []
                for fn in os.listdir(ICS_DIR):
                    if not fn.endswith('.ics'):
                        continue
                    stem = fn[:-4]
                    if len(stem) == 12 and stem.isdigit():
                        files.append(fn)
                files.sort(reverse=True)  # YYYYMMDDHHMM.ics 字典序即时间序
                for old in files[3:]:
                    try:
                        os.remove(os.path.join(ICS_DIR, old))
                    except Exception:
                        pass
            except Exception:
                pass
        except Exception:
            pass
        items = _parse_ics(raw, today_dt, end_dt)
        # 2) 解析并仅保留今天起三天（用于前端展示缓存，使用香港时区）
        if ZoneInfo is not None:
            now_hk = datetime.now(ZoneInfo('Asia/Hong_Kong'))
            today_dt = datetime(now_hk.year, now_hk.month, now_hk.day)
        else:
            today_dt = datetime.combine(date.today(), dtime.min)
        end_dt = today_dt + timedelta(days=3)
        # _parse_ics 已按窗口展开；这里仍做一次健壮性过滤
        def overlaps(e):
            try:
                s = datetime.fromisoformat(e['start'])
                en = datetime.fromisoformat(e['end'])
            except Exception:
                return False
            return (s < end_dt) and (en > today_dt)
        filtered = [e for e in items if overlaps(e)]
        filtered.sort(key=lambda x: x.get('start', ''))
        data = {'events': filtered}
        _ICS_CACHE['data'] = data
        _ICS_CACHE['ts'] = time.time()
        # 写磁盘缓存
        try:
            with open(_ICS_CACHE_PATH, 'w', encoding='utf-8') as f:
                json.dump(data, f, ensure_ascii=False)
        except Exception:
            pass
        return True
    except Exception:
        return False


def _calendar_refresh_loop(interval_sec: int = 300):
    # 启动时先尝试恢复磁盘，再拉取一次
    try:
        if os.path.exists(_ICS_CACHE_PATH):
            with open(_ICS_CACHE_PATH, 'r', encoding='utf-8') as f:
                data = json.load(f)
                if isinstance(data, dict) and 'events' in data:
                    _ICS_CACHE['data'] = data
                    _ICS_CACHE['ts'] = time.time()
    except Exception:
        pass
    # 首次主动拉取
    _calendar_fetch_and_cache()
    # 周期刷新
    while True:
        time.sleep(interval_sec)
        try:
            _calendar_fetch_and_cache()
        except Exception:
            pass


def main():
    port = int(CONFIG.get('PORT', 9999))
    # 启动后台刷新线程
    t = threading.Thread(target=_calendar_refresh_loop, kwargs={'interval_sec': 300}, daemon=True)
    t.start()
    app.run(host='0.0.0.0', port=port, debug=True)


if __name__ == '__main__':
    main()
