#!/usr/bin/env python3
"""moon_phase_monthly.py — build the monthly moon-phase close-up.

Handles three phases, controlled by --phase:

  full              ~100% lit, posted MOON_FULL_POST_DELAY_DAYS after exact full
  first-quarter     ~50% lit waxing (right half lit in northern hemisphere)
  third-quarter     ~50% lit waning (left half lit in northern hemisphere)

Algorithm (per phase):
  1. Find the most recent occurrence of the target phase (or honour --target).
  2. Scan east frames across the collection window (D-Δb .. D+Δa) and pick
     the frame closest in time to exact phase UTC where east successfully
     detected the moon and standard quality / altitude / illumination
     thresholds are met.  East is the WITNESS — it confirms you actually had
     a chance to see the moon that night.
  3. Round east's capture timestamp to the nearest hour and fetch the NASA
     SVS Dial-a-Moon render for that hour.  This gives a real-physics moon
     image with correct phase, libration and crater shadows.
  4. Render full-screen on a black background (moon fills ~92% of frame
     height), drop a caption naming the phase / month / capture moment /
     attribution, and upload to Mattermost.

Geometry note — first-quarter from east is HARD: at first quarter the moon is
up from noon to midnight, but east only sees the eastern sky, so it captures
the moon during DAYTIME only (with a bright sky background).  The detector is
brightness-based and may often fail to find a daytime moon.  Set
MOON_FIRST_QUARTER_ENABLED=false in sky-cam.conf if you'd rather not chase it.
Full moon and third-quarter both rise after dark and stay in east's view —
those should land cleanly most months.

Usage:
  moon_phase_monthly.py                  # auto: run any phase whose post-day = today
  moon_phase_monthly.py --phase full     # force a single phase
  moon_phase_monthly.py --phase third-quarter --target 2026-04-09T11:51:00Z --dry-run
"""
from __future__ import annotations

import argparse
import os
import pathlib
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone

_here = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(_here))


def _read_conf(path):
    conf = {}
    try:
        with open(path) as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith('#') or '=' not in line:
                    continue
                k, v = line.split('=', 1)
                k = k.strip()
                v = v.strip()
                v = re.sub(r'\s+#.*$', '', v)
                v = v.strip('"').strip("'")
                m = re.match(r'^\$\{[^}]+:-([^}]*)\}$', v)
                if m:
                    v = m.group(1).strip('"').strip("'")
                conf[k] = v
    except FileNotFoundError:
        pass
    return conf


CONF = _read_conf(_here / 'sky-cam.conf')
CONF.update(_read_conf(_here / '.env'))

BASE_DIR = CONF.get('BASE_DIR') or str(_here / 'data')
MOVIES_DIR = CONF.get('MOVIES_DIR') or f'{BASE_DIR}/movies'
SUNRISE_CAM = CONF.get('SUNRISE_CAM', 'east')
TIMEZONE = CONF.get('TIMEZONE', 'UTC')

MIN_QUALITY = float(CONF.get('MOON_MIN_QUALITY', CONF.get('MOON_FULL_MIN_QUALITY', 0.55)))
MIN_ALTITUDE = float(CONF.get('MOON_MIN_ALTITUDE_DEG', CONF.get('MOON_FULL_MIN_ALTITUDE_DEG', 15.0)))
OUT_W = int(CONF.get('MOON_OUTPUT_W', CONF.get('MOON_FULL_OUTPUT_W', 1920)))
OUT_H = int(CONF.get('MOON_OUTPUT_H', CONF.get('MOON_FULL_OUTPUT_H', 1080)))
MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', 0.92))
REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false'

DARK_START_MIN = int(CONF.get('MOON_DARK_START_MIN', 30))


PHASE_SPEC = {
    'full': {
        'index': 2,
        'label': 'Full Moon',
        'emoji': '🌕',
        'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 1)),
        'enabled_key': 'MOON_FULL_ENABLED',
    },
    'first-quarter': {
        'index': 1,
        'label': 'First Quarter (Waxing Half)',
        'emoji': '🌓',
        'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
        'enabled_key': 'MOON_FIRST_QUARTER_ENABLED',
    },
    'third-quarter': {
        'index': 3,
        'label': 'Third Quarter (Waning Half)',
        'emoji': '🌗',
        'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
        'enabled_key': 'MOON_THIRD_QUARTER_ENABLED',
    },
}

_FRAME_RE = re.compile(r'^(\d{2})-(\d{2})-(\d{2})\.jpg$')


def _local_tz():
    try:
        import pytz
        return pytz.timezone(TIMEZONE)
    except Exception:
        return timezone.utc


def _frame_local_dt(date_str: str, fname: str):
    m = _FRAME_RE.match(fname)
    if not m:
        return None
    h, mn, s = (int(x) for x in m.groups())
    y, mo, d = (int(x) for x in date_str.split('-'))
    naive = datetime(y, mo, d, h, mn, s)
    tz = _local_tz()
    if hasattr(tz, 'localize'):
        return tz.localize(naive)
    return naive.replace(tzinfo=tz)


def _candidate_frames(cam: str, dates: list[str]) -> list[tuple[str, datetime]]:
    out = []
    for d in dates:
        fdir = pathlib.Path(BASE_DIR) / cam / d
        if not fdir.is_dir():
            continue
        for fname in sorted(os.listdir(fdir)):
            if not fname.endswith('.jpg'):
                continue
            local_dt = _frame_local_dt(d, fname)
            if local_dt is None:
                continue
            out.append((str(fdir / fname), local_dt.astimezone(timezone.utc)))
    return out


def _format_local(dt_utc: datetime) -> str:
    return dt_utc.astimezone(_local_tz()).strftime('%Y-%m-%d %H:%M:%S %Z')


def _notify(title: str, body: str):
    try:
        subprocess.run([str(_here / 'notify.sh'), title, body], check=False)
    except FileNotFoundError:
        pass


def _post_to_mattermost(image_path: str, message: str) -> bool:
    import requests
    base = CONF.get('mattermost_url', '').rstrip('/')
    token = CONF.get('access_token', '')
    channel_id = CONF.get('channel_id', '')
    if not all([base, token, channel_id]):
        print('mattermost credentials missing — skipping upload', file=sys.stderr)
        return False
    headers = {'Authorization': f'Bearer {token}'}
    with open(image_path, 'rb') as f:
        r = requests.post(
            f'{base}/api/v4/files',
            headers=headers,
            files={'files': f},
            data={'channel_id': channel_id},
        )
    if r.status_code != 201:
        print(f'mattermost upload failed: {r.status_code} {r.text}', file=sys.stderr)
        return False
    file_id = r.json()['file_infos'][0]['id']
    r = requests.post(
        f'{base}/api/v4/posts',
        headers=headers,
        json={'channel_id': channel_id, 'message': message, 'file_ids': [file_id]},
    )
    if r.status_code != 201:
        print(f'mattermost post failed: {r.status_code} {r.text}', file=sys.stderr)
        return False
    return True


def _output_subdir(phase: str) -> str:
    return {
        'full': 'full-moons',
        'first-quarter': 'first-quarter',
        'third-quarter': 'third-quarter',
    }[phase]


def _output_filename(phase: str, target_utc: datetime) -> str:
    slug = {'full': 'full', 'first-quarter': 'first-quarter', 'third-quarter': 'third-quarter'}[phase]
    return f"{target_utc.strftime('%Y-%m')}-{slug}.jpg"


def _dark_moon_intervals(
    search_start: datetime,
    search_end: datetime,
    moon_phase_mod,
) -> list[tuple[datetime, datetime]]:
    """Return contiguous blocks where the sky is dark and the moon is visible.

    'Dark' means after (sunset + DARK_START_MIN) and before the following
    sunrise, based on actual computed sunset/sunrise times.  Within each night
    block we sample every 10 min and keep only the sub-intervals where the
    moon is above MIN_ALTITUDE_DEG.  Falls back to sampling sun altitude
    directly if skyfield can't find sunrise/sunset events (e.g. polar summer).
    """
    # Get actual sunset/sunrise events; widen the window a bit to catch events
    # that fall right at the boundary.
    events = moon_phase_mod.sun_events_in_range(
        search_start - timedelta(hours=2),
        search_end + timedelta(hours=2),
    )

    # Build night periods from real sunset/sunrise times.
    night_periods: list[tuple[datetime, datetime]] = []
    for i, (evt_t, is_rise) in enumerate(events):
        if is_rise:
            continue  # only care about sunsets here
        dark_start = evt_t + timedelta(minutes=DARK_START_MIN)
        # The night ends at the next sunrise (or search_end if none found).
        next_rise = next((t for t, r in events[i + 1:] if r), None)
        dark_end = next_rise if next_rise is not None else search_end
        if dark_start < dark_end:
            night_periods.append((dark_start, dark_end))

    # Polar fallback: no sunset/sunrise events found.
    if not night_periods:
        night_periods = [(search_start, search_end)]

    # Within each night, sample every 10 min to find where moon is high enough.
    step = timedelta(minutes=10)
    intervals: list[tuple[datetime, datetime]] = []
    for night_start, night_end in night_periods:
        t = max(night_start, search_start)
        end = min(night_end, search_end)
        if t >= end:
            continue
        seg_start = None
        while t <= end:
            try:
                moon_alt, _ = moon_phase_mod.altaz(t)
            except Exception:
                t += step
                continue
            ok = moon_alt >= MIN_ALTITUDE
            if ok and seg_start is None:
                seg_start = t
            elif not ok and seg_start is not None:
                intervals.append((seg_start, t))
                seg_start = None
            t += step
        if seg_start is not None:
            intervals.append((seg_start, end))

    return intervals


def run_phase(phase: str, target_utc: datetime | None, cam: str,
              dry_run: bool, no_upload: bool, out_path: str | None) -> int:
    spec = PHASE_SPEC[phase]
    if CONF.get(spec['enabled_key'], 'true').lower() == 'false':
        print(f'{spec["enabled_key"]}=false — skipping {phase}')
        return 0

    import moon_phase
    from moon_detect import detect_moon

    if target_utc is None:
        now = datetime.now(timezone.utc)
        events = moon_phase.phase_events_in_range(
            now - timedelta(days=45), now, spec['index'])
        if not events:
            print(f'no recent {phase} found in past 45 days', file=sys.stderr)
            return 1
        target_utc = events[-1]

    print(f'target {phase}: {target_utc.isoformat()}  ({_format_local(target_utc)})')

    tz = _local_tz()

    if not REQUIRE_EAST_VERIFY:
        print('MOON_REQUIRE_EAST_VERIFY=false — skipping east scan, using exact phase UTC')
        return _render_and_post(phase, spec, target_utc, target_utc,
                                cam, dry_run, no_upload, out_path,
                                witness_text='east verification disabled')

    # Find every interval within ±24h of the phase moment where the sky is
    # dark (sun below civil twilight) and the moon is above the horizon.
    search_start = target_utc - timedelta(hours=24)
    search_end   = target_utc + timedelta(hours=24)
    intervals = _dark_moon_intervals(search_start, search_end, moon_phase)

    if not intervals:
        print('no dark-sky + moon window in ±24h of phase — skipping')
        _notify(
            f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped',
            'No dark-sky + moon-above-horizon window found near the phase moment.',
        )
        return 0

    total_h = sum((e - s).total_seconds() / 3600 for s, e in intervals)
    print(f'dark+moon window: {len(intervals)} segment(s), {total_h:.1f}h total')
    for s, e in intervals:
        print(f'  {_format_local(s)} -> {_format_local(e)}')

    # Collect east frames that fall inside those intervals
    search_dates: list[str] = []
    d = search_start.astimezone(tz).date()
    while d <= search_end.astimezone(tz).date():
        search_dates.append(d.strftime('%Y-%m-%d'))
        d += timedelta(days=1)
    all_frames = _candidate_frames(cam, search_dates)
    dark_frames = [
        (p, dt) for p, dt in all_frames
        if any(s <= dt <= e for s, e in intervals)
    ]
    print(f'east frames in dark+moon window: {len(dark_frames)}')

    # Sort by proximity to exact phase moment so the first clear frame we find
    # is the one temporally closest to the moon being precisely full/quarter.
    dark_frames.sort(key=lambda x: abs(x[1] - target_utc))

    best_frame: str | None = None
    best_dt: datetime | None = None
    for fpath, fdt in dark_frames:
        det = detect_moon(fpath)
        if det is not None and det.quality >= MIN_QUALITY:
            best_frame = fpath
            best_dt = fdt
            offset_min = int((fdt - target_utc).total_seconds() / 60)
            print(f'best frame: {pathlib.Path(fpath).name}  quality={det.quality:.3f}  '
                  f'time={_format_local(fdt)}  offset={offset_min:+d} min from exact phase')
            break

    if best_frame is None:
        checked = len(dark_frames)
        print(f'no clear moon in {checked} dark-window frame(s) — skipping (overcast)')
        _notify(
            f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped',
            f'No clear moon detection in {checked} frame(s) during the dark+moon window.',
        )
        return 0

    illum_pct = round(moon_phase.illumination(best_dt) * 100)
    print(f'illumination at detection: {illum_pct}%')

    if dry_run:
        print(f'dry-run: would post NASA image for {best_dt.isoformat()} ({illum_pct}% lit)')
        return 0

    return _render_and_post(
        phase, spec, target_utc, best_dt,
        cam, dry_run, no_upload, out_path,
        witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(best_dt)}',
    )


def _render_and_post(phase, spec, target_utc, when_utc, cam,
                     dry_run, no_upload, out_path, witness_text):
    if out_path is None:
        out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase)
        out_dir.mkdir(parents=True, exist_ok=True)
        out_path = str(out_dir / _output_filename(phase, target_utc))

    import moon_dialamoon
    import moon_phase as _mp
    try:
        nasa_path = moon_dialamoon.fetch_for_time(when_utc)
    except Exception as e:
        msg = (
            f'NASA SVS Dial-a-Moon fetch failed for '
            f'{when_utc.strftime("%Y-%m-%dT%HZ")}: {e}'
        )
        _notify(f'{spec["emoji"]} {spec["label"]} — dial-a-moon fetch failed', msg)
        print(msg, file=sys.stderr)
        return 4
    print(f'dial-a-moon: {nasa_path}')

    illum_pct = round(_mp.illumination(when_utc) * 100)
    from moon_composite import render_phase_closeup
    caption = (
        f"{spec['label']} — {illum_pct}% lit — {target_utc.strftime('%B %Y')} — "
        f"{witness_text} — NASA SVS Dial-a-Moon"
    )
    render_phase_closeup(
        str(nasa_path), out_path,
        output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
        caption=caption,
        when_utc=when_utc,
    )
    print(f'wrote {out_path}')

    if no_upload:
        _notify(
            f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} (built, not posted)',
            f'{out_path} — {witness_text}',
        )
        return 0

    posted = _post_to_mattermost(
        out_path,
        f"{spec['emoji']} {spec['label']} — {target_utc.strftime('%B %Y')}\n"
        f"sky-cam {cam} {witness_text}.\n"
        f"Surface render from NASA SVS Dial-a-Moon for that hour.",
    )
    if posted:
        _notify(
            f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} posted',
            f'{witness_text} — {out_path}',
        )
    else:
        _notify(
            f'FAILED: {spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} upload',
            f'Image built at {out_path} but Mattermost upload failed.',
        )
    return 0


def auto_run(cam: str, dry_run: bool, no_upload: bool) -> int:
    """Daily check: run any phase whose post-day equals today (UTC)."""
    import moon_phase
    today_utc = datetime.now(timezone.utc).date()
    ran_any = False
    rc = 0
    for phase, spec in PHASE_SPEC.items():
        if CONF.get(spec['enabled_key'], 'true').lower() == 'false':
            print(f'-- {phase}: {spec["enabled_key"]}=false → skip')
            continue
        events = moon_phase.phase_events_in_range(
            datetime.combine(today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc),
            datetime.now(timezone.utc),
            spec['index'])
        if not events:
            continue
        last_event = events[-1]
        days_since = (today_utc - last_event.date()).days
        if days_since == spec['post_delay']:
            print(f'== running {phase} (last event {last_event.date()}, +{spec["post_delay"]} days = today) ==')
            sub = run_phase(phase, last_event, cam, dry_run, no_upload, None)
            rc = rc or sub
            ran_any = True
        else:
            print(f'-- {phase}: last {last_event.date()}, days_since={days_since}, post_delay={spec["post_delay"]} → skip')
    if not ran_any:
        print('no phase scheduled for today')
    return rc


def main():
    p = argparse.ArgumentParser()
    p.add_argument('--phase', choices=list(PHASE_SPEC.keys()),
                   help='Run a single phase regardless of schedule')
    p.add_argument('--target', help='Override phase event UTC, ISO 8601 (requires --phase)')
    p.add_argument('--cam', default=SUNRISE_CAM)
    p.add_argument('--dry-run', action='store_true')
    p.add_argument('--no-upload', action='store_true')
    p.add_argument('--out', help='Override output path (requires --phase)')
    args = p.parse_args()

    if args.phase:
        target = None
        if args.target:
            target = datetime.fromisoformat(args.target.replace('Z', '+00:00'))
        return run_phase(args.phase, target, args.cam, args.dry_run, args.no_upload, args.out)
    return auto_run(args.cam, args.dry_run, args.no_upload)


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