#!/usr/bin/env python3
"""
Hermes Diary Web Server with Image Gen Proxy
Serves /opt/data/diary on port 8789
Proxies /api/* and /image_gen/* to image_gen Flask app on port 5011
"""

from http.server import HTTPServer, SimpleHTTPRequestHandler
from socketserver import ThreadingMixIn
import os, socket, http.client, json, base64, time, re, hashlib, secrets, tempfile, threading, csv, io
import email
from email.parser import BytesParser
import urllib.request, urllib.error

PORT = 8789  # 對外 URL: http://localhost:8789 (重要: start-up.sh 對齊呢個)
SERVE_DIR = "/opt/data/diary"
IMAGE_GEN_HOST = "127.0.0.1"
IMAGE_GEN_PORT = 5011
# 維港帳房 (Victoria Ledger) — Flask sub-process run by ledger/run_ledger.py
# Reverse-proxied at /ledger/* (prefix stripped before forwarding).
# 18790: high port to avoid Hermes WebUI server bound by /opt/data/webui-v052/server.py on 8790.
LEDGER_HOST = "127.0.0.1"
LEDGER_PORT = int(os.getenv("LEDGER_PORT") or "18790")
LEDGER_PATH_PREFIX = "/ledger"
# (xlsx_editor removed 2026-07-13 — no longer maintained)
GBA_SAVES_DIR = "/opt/data/diary/gba_saves"

# ── Tomica login (obscurity, not real security) ─────────────
TOMICA_USER = "ming"
TOMICA_PASS_HASH = hashlib.sha256(b"kewi1127").hexdigest()
TOMICA_SESSIONS = {}  # token -> expiry timestamp

# ── Tomica Sync — Step 1 (server-side core + atomic write) ─
# SPEC: /opt/data/specs/SPEC_tomica-server-sync.md
TOMICA_DB_PATH = "tomica_cars.json"
tomica_db_lock = threading.RLock()

# ── Tomica v2 (clean break, /api/garage/*) ────────────────
# SPEC: /opt/data/specs/SPEC_tomica2.md
# Auth: password-only (kewi1127), user field not enforced
GARAGE_PASS_HASH = hashlib.sha256(b"kewi1127").hexdigest()
GARAGE_SESSIONS = {}  # token -> expiry timestamp
GARAGE_DB_PATH = "/opt/data/diary/garage_cars.json"  # absolute path (fixes v1 relative-path fragility)
GARAGE_IMAGES_DIR = "/opt/data/diary/garage_images"
garage_db_lock = threading.RLock()
_garage_start_time = time.time()

# Ensure PIL importable regardless of launch environment (Hermes venv may not include /opt/data/.local)
import sys as _sys
for _p in ('/opt/data/.local', '/opt/hermes/.venv/lib/python3.13/site-packages'):
    if _p not in _sys.path and os.path.isdir(os.path.join(_p, 'PIL')):
        _sys.path.insert(0, _p)
try:
    from PIL import Image as _PIL_Image  # noqa: F401
    GARAGE_PIL_OK = True
except ImportError:
    GARAGE_PIL_OK = False

def _garage_default_data():
    return {"cars": {}, "view_count": 0, "updated_at": time.time_ns() // 1_000_000}

def _garage_atomic_write(filepath, data):
    dir_name = os.path.dirname(filepath) or '.'
    fd, tmp_path = tempfile.mkstemp(dir=dir_name, prefix="tmp_garage_", suffix=".json")
    try:
        with os.fdopen(fd, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        os.replace(tmp_path, filepath)
    except Exception:
        if os.path.exists(tmp_path):
            try: os.remove(tmp_path)
            except Exception: pass
        raise

def _garage_load():
    with garage_db_lock:
        if not os.path.exists(GARAGE_DB_PATH):
            data = _garage_default_data()
            try:
                _garage_atomic_write(GARAGE_DB_PATH, data)
            except Exception:
                pass
            return data
        try:
            with open(GARAGE_DB_PATH, 'r', encoding='utf-8') as f:
                return json.load(f)
        except Exception:
            bak_path = GARAGE_DB_PATH.replace('.json', '.bak.json')
            if os.path.exists(bak_path):
                try:
                    with open(bak_path, 'r', encoding='utf-8') as f:
                        return json.load(f)
                except Exception:
                    pass
            return _garage_default_data()

def _tomica_default_data():
    return {"cars": {}, "view_count": 0, "updated_at": time.time_ns() // 1_000_000}

def _tomica_atomic_write(filepath, data):
    dir_name = os.path.dirname(filepath) or '.'
    fd, tmp_path = tempfile.mkstemp(dir=dir_name, prefix="tmp_tomica_", suffix=".json")
    try:
        with os.fdopen(fd, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        os.replace(tmp_path, filepath)
    except Exception:
        if os.path.exists(tmp_path):
            try: os.remove(tmp_path)
            except Exception: pass
        raise

def _tomica_load():
    with tomica_db_lock:
        if not os.path.exists(TOMICA_DB_PATH):
            init = _tomica_default_data()
            _tomica_atomic_write(TOMICA_DB_PATH, init)
            return init
        try:
            with open(TOMICA_DB_PATH, 'r', encoding='utf-8') as f:
                return json.load(f)
        except json.JSONDecodeError:
            bak_path = TOMICA_DB_PATH.replace('.json', '.bak.json')
            if os.path.exists(bak_path):
                try:
                    with open(bak_path, 'r', encoding='utf-8') as f:
                        recovered = json.load(f)
                    _tomica_atomic_write(TOMICA_DB_PATH, recovered)
                    return recovered
                except json.JSONDecodeError:
                    pass
            fallback = _tomica_default_data()
            _tomica_atomic_write(TOMICA_DB_PATH, fallback)
            return fallback

class DiaryHandler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=SERVE_DIR, **kwargs)

    # ── GBA server-side saves ─────────────────────────────
    # File layout: GBA_SAVES_DIR/<game_id>/<save_id>.json + .dat
    #   - <save_id> = epoch microseconds (sortable, unique)
    #   - .json = {name, mtime}
    #   - .dat  = raw binary save data
    GBA_SAVE_ID_RE = re.compile(r'^\d+$')

    def _gba_game_dir(self, game_id):
        # Sanitize: only allow alnum / underscore / dash, max 64 chars
        if not re.match(r'^[A-Za-z0-9_-]{1,64}$', game_id or ''):
            return None
        return os.path.join(GBA_SAVES_DIR, game_id)

    def _send_json(self, obj, status=200):
        body = json.dumps(obj).encode('utf-8')
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        self.wfile.write(body)
        return True

    def _handle_gba(self):
        """Route /api/gba/* to local file system. Returns True if handled."""
        path = self.path
        m = re.match(r'^/api/gba/save/?$', path)
        if m and self.command == 'POST':
            return self._gba_save()
        m = re.match(r'^/api/gba/saves/([A-Za-z0-9_-]{1,64})/?$', path)
        if m and self.command == 'GET':
            return self._gba_list(m.group(1))
        m = re.match(r'^/api/gba/saves/([A-Za-z0-9_-]{1,64})/(\d+)/?$', path)
        if m and self.command == 'GET':
            return self._gba_load(m.group(1), m.group(2))
        if m and self.command == 'DELETE':
            return self._gba_delete(m.group(1), m.group(2))
        return False  # not a GBA route, fall through

    def _gba_save(self):
        try:
            cl = int(self.headers.get('Content-Length') or 0)
            raw = self.rfile.read(cl)
            payload = json.loads(raw.decode('utf-8'))
            game_id = payload.get('game_id', '').strip()
            name = payload.get('name', '').strip()[:64]
            data_b64 = payload.get('data', '').strip()
            if not game_id or not data_b64:
                return self._send_json({'error': 'missing game_id or data'}, 400)
            try:
                save_bytes = base64.b64decode(data_b64)
            except Exception as e:
                return self._send_json({'error': f'invalid base64: {e}'}, 400)
            gdir = self._gba_game_dir(game_id)
            if not gdir:
                return self._send_json({'error': 'invalid game_id'}, 400)
            os.makedirs(gdir, exist_ok=True)
            save_id = str(int(time.time() * 1000))
            with open(os.path.join(gdir, save_id + '.dat'), 'wb') as f:
                f.write(save_bytes)
            with open(os.path.join(gdir, save_id + '.json'), 'w') as f:
                json.dump({
                    'id': save_id,
                    'name': name or 'Unnamed',
                    'mtime': int(time.time() * 1000),  # milliseconds — matches JS Date convention
                    'size': len(save_bytes),
                }, f)
            return self._send_json({'ok': True, 'id': save_id, 'size': len(save_bytes)})
        except Exception as e:
            return self._send_json({'error': f'save failed: {e}'}, 500)

    def _gba_list(self, game_id):
        gdir = self._gba_game_dir(game_id)
        if not gdir or not os.path.isdir(gdir):
            return self._send_json([])  # no saves for this game yet
        saves = []
        for fn in os.listdir(gdir):
            if not fn.endswith('.json'):
                continue
            save_id = fn[:-5]  # strip .json
            if not self.GBA_SAVE_ID_RE.match(save_id):
                continue
            try:
                with open(os.path.join(gdir, fn)) as f:
                    meta = json.load(f)
                mtime = meta.get('mtime', 0)
                # Backfill: old format stored epoch seconds, new format is ms.
                # If mtime looks like seconds (< year 2001 in ms = 978307200000),
                # multiply by 1000.
                if 0 < mtime < 978307200000:
                    mtime = int(mtime * 1000)
                saves.append({
                    'id': meta.get('id', save_id),
                    'name': meta.get('name', ''),
                    'mtime': mtime,
                    'size': meta.get('size', 0),
                })
            except Exception:
                continue
        saves.sort(key=lambda s: s['mtime'], reverse=True)
        return self._send_json(saves)

    def _gba_load(self, game_id, save_id):
        gdir = self._gba_game_dir(game_id)
        if not gdir or not self.GBA_SAVE_ID_RE.match(save_id):
            return self._send_json({'error': 'invalid id'}, 400)
        dat_path = os.path.join(gdir, save_id + '.dat')
        json_path = os.path.join(gdir, save_id + '.json')
        if not os.path.isfile(dat_path):
            return self._send_json({'error': 'not found'}, 404)
        with open(dat_path, 'rb') as f:
            data = f.read()
        meta = {}
        if os.path.isfile(json_path):
            try:
                with open(json_path) as f:
                    meta = json.load(f)
            except Exception:
                pass
        return self._send_json({
            'id': save_id,
            'name': meta.get('name', ''),
            'mtime': meta.get('mtime', 0),
            'data': base64.b64encode(data).decode('ascii'),
        })

    def _gba_delete(self, game_id, save_id):
        gdir = self._gba_game_dir(game_id)
        if not gdir or not self.GBA_SAVE_ID_RE.match(save_id):
            return self._send_json({'error': 'invalid id'}, 400)
        for ext in ('.dat', '.json'):
            p = os.path.join(gdir, save_id + ext)
            if os.path.isfile(p):
                try: os.remove(p)
                except Exception: pass
        return self._send_json({'ok': True})

    # ── Tomica routes (login + identify) ──────────────────────
    def _tomica_auth_ok(self):
        """Check session cookie. Returns True if valid."""
        cookie = self.headers.get('Cookie', '')
        m = re.search(r'tomica_sess=([a-f0-9]+)', cookie)
        if not m:
            return False
        token = m.group(1)
        expiry = TOMICA_SESSIONS.get(token)
        if not expiry or expiry < time.time():
            TOMICA_SESSIONS.pop(token, None)
            return False
        return True

    def _tomica_login(self):
        try:
            cl = int(self.headers.get('Content-Length') or 0)
            raw = self.rfile.read(cl)
            payload = json.loads(raw.decode('utf-8'))
            user = payload.get('user', '').strip()
            pw = payload.get('password', '')
            if user == TOMICA_USER and hashlib.sha256(pw.encode()).hexdigest() == TOMICA_PASS_HASH:
                token = secrets.token_hex(16)
                TOMICA_SESSIONS[token] = time.time() + 86400 * 30  # 30 days
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.send_header('Set-Cookie', f'tomica_sess={token}; Path=/; Max-Age=2592000; HttpOnly')
                body = json.dumps({'ok': True, 'token': token}).encode()
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
                return
            self._send_json({'error': 'invalid credentials'}, 401)
        except Exception as e:
            self._send_json({'error': str(e)}, 400)

    # ── Tomica v2 (/api/garage/*) auth helpers ────────────────
    def _garage_auth_ok(self):
        """Check garage session cookie. Returns True if valid."""
        cookie = self.headers.get('Cookie', '')
        m = re.search(r'garage_sess=([a-f0-9]+)', cookie)
        if not m:
            return False
        token = m.group(1)
        expiry = GARAGE_SESSIONS.get(token, 0)
        if expiry < time.time():
            GARAGE_SESSIONS.pop(token, None)
            return False
        return True

    def _garage_login(self):
        """Password-only login. user field is ignored (per spec §3)."""
        try:
            cl = int(self.headers.get('Content-Length') or 0)
            raw = self.rfile.read(cl)
            payload = json.loads(raw.decode('utf-8'))
            pw = payload.get('password', '')
            if hashlib.sha256(pw.encode()).hexdigest() == GARAGE_PASS_HASH:
                token = secrets.token_hex(16)
                GARAGE_SESSIONS[token] = time.time() + 86400 * 30  # 30 days
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.send_header('Set-Cookie', f'garage_sess={token}; Path=/; Max-Age=2592000; HttpOnly')
                body = json.dumps({'ok': True, 'token': token}).encode()
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
                return
            self._send_json({'error': 'invalid password'}, 401)
        except Exception as e:
            self._send_json({'error': str(e)}, 400)

    def _garage_logout(self):
        cookie = self.headers.get('Cookie', '')
        m = re.search(r'garage_sess=([a-f0-9]+)', cookie)
        if m:
            GARAGE_SESSIONS.pop(m.group(1), None)
        # Always clear cookie
        self.send_response(200)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Set-Cookie', 'garage_sess=; Path=/; Max-Age=0; HttpOnly')
        body = json.dumps({'ok': True}).encode()
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _garage_health(self):
        try:
            db = _garage_load()
            car_count = len(db.get('cars', {})) if isinstance(db.get('cars'), dict) else 0
            return self._send_json({
                'ok': True,
                'uptime_ms': int((time.time() - _garage_start_time) * 1000),
                'car_count': car_count
            })
        except Exception as e:
            return self._send_json({'error': str(e)}, 500)

    def _garage_export_json(self):
        """P1: dump full DB as JSON attachment (download)."""
        if not self._garage_auth_ok():
            return self._send_json({'error': 'unauthorized'}, 401)
        try:
            db = _garage_load()
            export = {
                'exported_at': time.time_ns() // 1_000_000,
                'car_count': len(db.get('cars', {})),
                'cars': db.get('cars', {}),
                'view_count': db.get('view_count', 0),
            }
            body = json.dumps(export, ensure_ascii=False, indent=2).encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Content-Disposition', 'attachment; filename="tomica_garage_export.json"')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return True
        except Exception as e:
            return self._send_json({'error': str(e)}, 500)

    def _garage_export_csv(self):
        """P1: dump cars as CSV attachment (download). One row per car, flat schema."""
        if not self._garage_auth_ok():
            return self._send_json({'error': 'unauthorized'}, 401)
        try:
            db = _garage_load()
            cars_dict = db.get('cars', {}) if isinstance(db.get('cars'), dict) else {}
            fieldnames = ['id', 'brand', 'model', 'no', 'year', 'color', 'chassis_code',
                          'owned', 'confirmed', 'low_conf', 'top_photo', 'bottom_photo',
                          'notes', 'created_at', 'updated_at']
            import csv as _csv
            import io as _io
            buf = _io.StringIO()
            writer = _csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
            writer.writeheader()
            for cid in sorted(cars_dict.keys()):
                row = dict(cars_dict[cid])
                row.setdefault('id', cid)
                writer.writerow(row)
            body = buf.getvalue().encode('utf-8-sig')  # BOM for Excel
            self.send_response(200)
            self.send_header('Content-Type', 'text/csv; charset=utf-8')
            self.send_header('Content-Disposition', 'attachment; filename="tomica_garage_export.csv"')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return True
        except Exception as e:
            return self._send_json({'error': str(e)}, 500)

    def _garage_identify(self):
        """Identify a Tomica car from top + bottom images via MiniMax vision.
        Mirror of _tomica_identify but garage-cookie auth (see _tomica_identify for full logic)."""
        try:
            cl = int(self.headers.get('Content-Length') or 0)
            raw = self.rfile.read(cl)
            payload = json.loads(raw.decode('utf-8'))
            top = payload.get('top', '')
            bottom = payload.get('bottom', '')
            if not top:
                return self._send_json({'error': 'missing top image'}, 400)

            # Read MiniMax CN API key from .env (same one hermes-vision-mcp uses)
            env_path = '/opt/data/.env'
            api_key = None
            if os.path.isfile(env_path):
                with open(env_path) as f:
                    for line in f:
                        line = line.strip()
                        if line.startswith('MINIMAX_CN_API_KEY=') and not line.startswith('#'):
                            api_key = line.split('=', 1)[1].strip()
                            break
            if not api_key:
                return self._send_json({'error': 'server missing MINIMAX_CN_API_KEY'}, 500)

            # Use MiniMax-01 vision via understand_image-equivalent chat API.
            # Prompt asks for structured JSON per-field + confidence.
            system_prompt = (
                "You are a Tomica die-cast car identification expert. "
                "Given two photos (top showing the car body, bottom showing the base), "
                "return a JSON object with fields: brand, model, no, year, color (optional), "
                "confidence (object with brand/model/year/no each 0.0-1.0 float), notes. "
                "If unclear, use low confidence (0.3) rather than guessing."
            )
            user_payload = {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Top photo (vehicle body):"},
                    {"type": "image_url", "image_url": {"url": top}},
                    {"type": "text", "text": "Bottom photo (base engraving showing © TOMY YYYY + No.XX):"},
                    {"type": "image_url", "image_url": {"url": bottom}},
                    {"type": "text", "text": "Return ONLY a JSON object with brand, model, no, year, color, confidence, notes."}
                ]
            }
            body = {
                "model": "MiniMax-01",
                "messages": [
                    {"role": "system", "content": system_prompt},
                    user_payload
                ],
                "max_tokens": 600,
                "temperature": 0.2,
            }
            req = urllib.request.Request(
                "https://api.minimaxi.com/v1/text/chatcompletion_v2",
                data=json.dumps(body).encode('utf-8'),
                headers={
                    'Authorization': f'Bearer {api_key}',
                    'Content-Type': 'application/json',
                }
            )
            try:
                with urllib.request.urlopen(req, timeout=45) as resp:
                    resp_data = json.loads(resp.read())

                # Defensive parsing: handle null/empty choices/message/content.
                # Manus reported: 'NoneType' object is not subscriptable when API returned
                # choices=null (e.g. rate-limit, auth failure, transient backend error).
                choices = (resp_data or {}).get('choices') or []
                if not isinstance(choices, list) or not choices:
                    # Try to surface the upstream error message if any.
                    upstream = (resp_data or {}).get('error') or {}
                    upstream_msg = upstream.get('message') if isinstance(upstream, dict) else None
                    base_msg = (upstream_msg or 'upstream returned no choices (model unavailable, rate-limited, or content filtered)')
                    raise RuntimeError(f'AI identify upstream: {base_msg}')
                first = choices[0] if isinstance(choices[0], dict) else {}
                message = first.get('message') if isinstance(first.get('message'), dict) else {}
                content = message.get('content', '') if isinstance(message.get('content'), str) else ''
                if not content:
                    # Some upstream responses use 'reasoning_content' instead; skip it.
                    raise RuntimeError('AI identify upstream returned empty content')
                # Strip markdown fences if any
                content = re.sub(r'^```(?:json)?\s*', '', content.strip())
                content = re.sub(r'\s*```\s*$', '', content)

                try:
                    parsed = json.loads(content)
                except Exception:
                    # Try to extract JSON from prose
                    m = re.search(r'\{.*\}', content, re.DOTALL)
                    parsed = json.loads(m.group(0)) if m else {'brand': '', 'model': '', 'no': '', 'year': None, 'confidence': {}}

                return self._send_json({
                    'brand': parsed.get('brand', '') or '',
                    'model': parsed.get('model', '') or '',
                    'no': str(parsed.get('no', '') or ''),
                    'year': parsed.get('year'),
                    'color': parsed.get('color', '') or '',
                    'confidence': parsed.get('confidence', {}) or {},
                    'notes': parsed.get('notes', '') or ''
                })
            except Exception as upstream_err:
                # Anything inside the upstream/parse block → 502 (bad gateway, not our bug).
                import traceback as _tb
                self.log_message(f"garage identify upstream error: {upstream_err}\n{_tb.format_exc()}")
                return self._send_json({'error': f'identify upstream failed: {type(upstream_err).__name__}: {upstream_err}'}, 502)
        except Exception as e:
            # Outer: payload/IO/key — genuine server-side bug → 500.
            import traceback
            self.log_message(f"garage identify exception: {e}\n{traceback.format_exc()}")
            return self._send_json({'error': f'identify failed: {type(e).__name__}: {e}'}, 500)

    def _garage_post_upload(self):
        """P1: multipart/form-data image upload. Field name 'file'.
        Saves BOTH original (kept for archive) + 800px WebP thumbnail (used by AI identify + UI).
        Cap: 8MB. Pillow optional — if missing, thumb is a copy of original.
        Returns: {filename, original_url, thumb_url, original_size, thumb_size, original_ext}"""
        content_type = self.headers.get('Content-Type', '')
        if 'multipart/form-data' not in content_type:
            return self._send_json({'error': 'expected multipart/form-data'}, 400)
        cl = int(self.headers.get('Content-Length') or 0)
        if cl > 8 * 1024 * 1024:
            return self._send_json({'error': 'file too large (max 8MB)'}, 413)
        try:
            body = self.rfile.read(cl)
            mime_msg_bytes = ('Content-Type: ' + content_type + '\r\n\r\n').encode('utf-8') + body
            msg = BytesParser().parsebytes(mime_msg_bytes)
            save_dir = GARAGE_IMAGES_DIR
            os.makedirs(save_dir, exist_ok=True)
            for part in msg.walk():
                if part.get_content_maintype() == 'multipart':
                    continue
                if not part.get_filename():
                    continue
                file_data = part.get_payload(decode=True)
                if not file_data:
                    continue
                # Detect original extension from part's filename or Content-Type
                orig_name = part.get_filename() or 'upload'
                orig_ext = os.path.splitext(orig_name)[1].lower() or '.jpg'
                if orig_ext not in ('.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif'):
                    orig_ext = '.jpg'
                ts = str(time.time_ns() // 1_000_000)
                hex_id = secrets.token_hex(4)
                base = 'v' + ts + '_' + hex_id
                # 1. Save ORIGINAL
                orig_filename = base + '_orig' + orig_ext
                orig_path = os.path.join(save_dir, orig_filename)
                with open(orig_path, 'wb') as f:
                    f.write(file_data)
                orig_size = len(file_data)
                # 2. Generate 800px WebP THUMBNAIL (best-effort; if Pillow missing, copy as-is)
                thumb_filename = base + '_thumb.webp'
                thumb_path = os.path.join(save_dir, thumb_filename)
                thumb_size = orig_size
                _thumb_status = 'unknown'
                if not GARAGE_PIL_OK:
                    # Pillow unavailable in this env — copy original as fallback
                    with open(thumb_path, 'wb') as f:
                        f.write(file_data)
                else:
                    img = _PIL_Image.open(io.BytesIO(file_data))
                    if img.mode in ('RGBA', 'LA', 'P'):
                        img = img.convert('RGB')
                    img.thumbnail((800, 800), _PIL_Image.Resampling.LANCZOS)
                    img.save(thumb_path, 'WEBP', quality=85, method=6)
                    thumb_size = os.path.getsize(thumb_path)
                # No-op: debug logging removed; thumb generation now uses pre-imported PIL
                return self._send_json({
                    'filename': orig_filename,
                    'original_filename': orig_filename,
                    'original_url': '/garage_images/' + orig_filename,
                    'thumb_filename': thumb_filename,
                    'thumb_url': '/garage_images/' + thumb_filename,
                    'original_size': orig_size,
                    'thumb_size': thumb_size,
                    'original_ext': orig_ext,
                    'size': orig_size
                })
            return self._send_json({'error': 'no file found in payload'}, 400)
        except Exception as e:
            import traceback
            self.log_message(f"upload exception: {e}\n{traceback.format_exc()}")
            return self._send_json({'error': str(e)}, 500)

    def _garage_post_cars_single(self):
        """Incremental save with LWW by updated_at (mirror of _tomica_post_cars_single)."""
        try:
            cl = int(self.headers.get('Content-Length') or 0)
            raw = self.rfile.read(cl)
            car = json.loads(raw.decode('utf-8'))
            car_id = car.get('id')
            if not car_id or not isinstance(car_id, str):
                return self._send_json({'error': 'missing or invalid id'}, 400)
            if not re.match(r'^[A-Za-z0-9_-]{1,64}$', car_id):
                return self._send_json({'error': 'invalid id format'}, 400)
            # Validation: at least brand or model
            brand = (car.get('brand') or '').strip()
            model = (car.get('model') or '').strip()
            if not brand and not model:
                return self._send_json({'error': 'must have at least brand or model'}, 400)
            # Confidence gate: drafts (confirmed:false) allowed. Final (confirmed:true) require brand/model.
            confirmed_val = car.get('confirmed')
            if not isinstance(confirmed_val, bool):
                return self._send_json({'error': 'must set confirmed:true or confirmed:false explicitly'}, 400)
            if confirmed_val is True:
                # Final save: stricter validation
                if not brand and not model:
                    return self._send_json({'error': 'must have at least brand or model (final save)'}, 400)
            now = time.time_ns() // 1_000_000
            with garage_db_lock:
                db = _garage_load()
                if isinstance(db.get('cars'), list):
                    db['cars'] = {}
                cars = db.setdefault('cars', {})
                existing = cars.get(car_id)
                client_updated = car.get('updated_at', 0) or 0
                if existing and (existing.get('updated_at', 0) or 0) > client_updated:
                    return self._send_json({'ok': True, 'car': existing, 'merged': 'server_won'})
                # Ensure timestamps
                car.setdefault('created_at', now)
                car['updated_at'] = now
                # Compute low_conf
                conf = car.get('confidence') or {}
                low = any(isinstance(conf.get(k), (int, float)) and conf.get(k) < 0.6
                          for k in ('brand', 'model', 'year', 'no'))
                car['low_conf'] = low
                # Derive thumb URL from orig URL (P1 1.5a-aware: _orig.<ext> + _thumb.webp).
                # If client didn't pass thumb_url, compute it from orig URL pattern.
                for orig_key, thumb_key in (('top_photo', 'top_thumb'), ('bottom_photo', 'bottom_thumb')):
                    orig_url = car.get(orig_key, '')
                    if orig_url and '_orig.' in orig_url and not car.get(thumb_key):
                        base = orig_url.rsplit('_orig.', 1)[0]
                        car[thumb_key] = base + '_thumb.webp'
                # Preserve confirmed state as-is (True for final, False for draft)
                cars[car_id] = car
                db['updated_at'] = now
                # Atomic write with .bak rotation
                if os.path.exists(GARAGE_DB_PATH):
                    try:
                        _garage_atomic_write(
                            GARAGE_DB_PATH.replace('.json', '.bak.json'),
                            json.load(open(GARAGE_DB_PATH))
                        )
                    except Exception:
                        pass
                _garage_atomic_write(GARAGE_DB_PATH, db)
            return self._send_json({'ok': True, 'car': car, 'merged': 'client_won'})
        except Exception as e:
            import traceback
            self.log_message(f"garage save exception: {e}\n{traceback.format_exc()}")
            return self._send_json({'error': str(e)}, 500)

    def _garage_post_cars_replace(self):
        """Bulk replace entire cars collection."""
        try:
            cl = int(self.headers.get('Content-Length') or 0)
            raw = self.rfile.read(cl)
            payload = json.loads(raw.decode('utf-8'))
            cars = payload.get('cars', {})
            if not isinstance(cars, dict):
                return self._send_json({'error': 'cars must be an object'}, 400)
            view_count = payload.get('view_count', 0)
            now = time.time_ns() // 1_000_000
            db = {"cars": cars, "view_count": view_count, "updated_at": now}
            with garage_db_lock:
                if os.path.exists(GARAGE_DB_PATH):
                    try:
                        _garage_atomic_write(
                            GARAGE_DB_PATH.replace('.json', '.bak.json'),
                            json.load(open(GARAGE_DB_PATH))
                        )
                    except Exception:
                        pass
                _garage_atomic_write(GARAGE_DB_PATH, db)
            return self._send_json({'ok': True, 'car_count': len(cars)})
        except Exception as e:
            return self._send_json({'error': str(e)}, 500)

    def _garage_post_import_preview(self):
        """P1 1.5c: parse CSV, validate rows, return preview. NO write to DB.
        Accepts multipart 'file' field OR JSON {csv_text: "..."}.
        Validates: id format, confirmed explicit, brand/model present (for confirmed:true)."""
        if not self._garage_auth_ok():
            return self._send_json({'error': 'unauthorized'}, 401)
        try:
            content_type = self.headers.get('Content-Type', '')
            csv_text = None
            if 'multipart/form-data' in content_type:
                cl = int(self.headers.get('Content-Length') or 0)
                if cl > 4 * 1024 * 1024:
                    return self._send_json({'error': 'CSV too large (max 4MB)'}, 413)
                body = self.rfile.read(cl)
                mime_msg_bytes = ('Content-Type: ' + content_type + '\r\n\r\n').encode('utf-8') + body
                msg = BytesParser().parsebytes(mime_msg_bytes)
                for part in msg.walk():
                    if part.get_content_maintype() == 'multipart':
                        continue
                    if not part.get_filename():
                        continue
                    file_data = part.get_payload(decode=True)
                    if file_data:
                        # Try utf-8 first, then utf-8-sig, then gbk
                        for enc in ('utf-8-sig', 'utf-8', 'gbk', 'big5'):
                            try:
                                csv_text = file_data.decode(enc)
                                break
                            except UnicodeDecodeError:
                                continue
                        if csv_text is None:
                            return self._send_json({'error': 'CSV decode failed (tried utf-8/gbk/big5)'}, 400)
            else:
                cl = int(self.headers.get('Content-Length') or 0)
                raw = self.rfile.read(cl)
                payload = json.loads(raw.decode('utf-8'))
                csv_text = payload.get('csv_text', '')
            if not csv_text or not csv_text.strip():
                return self._send_json({'error': 'empty CSV'}, 400)

            # Parse CSV
            reader = csv.DictReader(io.StringIO(csv_text))
            rows = list(reader)
            # Get current DB state for overwrite detection
            db = _garage_load()
            existing_ids = set(db.get('cars', {}).keys()) if isinstance(db.get('cars'), dict) else set()

            valid = []
            invalid = []
            for i, row in enumerate(rows):
                line_no = i + 2  # header is line 1
                errors = []
                rid = (row.get('id') or '').strip()
                if not rid:
                    errors.append('missing id')
                elif not re.match(r'^[A-Za-z0-9_-]{1,64}$', rid):
                    errors.append(f'invalid id format: {rid[:40]}')
                brand = (row.get('brand') or '').strip()
                model = (row.get('model') or '').strip()
                confirmed_raw = (row.get('confirmed') or '').strip().lower()
                if confirmed_raw in ('true', '1', 'yes'):
                    confirmed = True
                elif confirmed_raw in ('false', '0', 'no', ''):
                    confirmed = False
                else:
                    errors.append(f'confirmed must be true/false (got: {row.get("confirmed")!r})')
                    confirmed = None
                if confirmed is True and not brand and not model:
                    errors.append('confirmed:true requires at least brand or model')
                # Coerce year if present
                year_raw = (row.get('year') or '').strip()
                year = None
                if year_raw:
                    try:
                        year = int(year_raw)
                    except ValueError:
                        errors.append(f'invalid year: {year_raw}')
                if errors:
                    invalid.append({'line': line_no, 'id': rid, 'errors': errors, 'row': row})
                else:
                    valid.append({
                        'line': line_no,
                        'id': rid,
                        'brand': brand,
                        'model': model,
                        'no': (row.get('no') or '').strip(),
                        'year': year,
                        'color': (row.get('color') or '').strip(),
                        'chassis_code': (row.get('chassis_code') or '').strip(),
                        'owned': (row.get('owned') or '').strip().lower() in ('true', '1', 'yes'),
                        'confirmed': confirmed,
                        'notes': (row.get('notes') or '').strip(),
                        'will_overwrite': rid in existing_ids,
                    })

            return self._send_json({
                'row_count': len(rows),
                'valid_count': len(valid),
                'invalid_count': len(invalid),
                'will_overwrite_count': sum(1 for v in valid if v['will_overwrite']),
                'new_count': sum(1 for v in valid if not v['will_overwrite']),
                'preview': valid[:20],  # first 20 rows for UI preview
                'errors': invalid[:20],
            })
        except Exception as e:
            import traceback
            self.log_message(f"import preview exception: {e}\n{traceback.format_exc()}")
            return self._send_json({'error': str(e)}, 500)

    def _garage_handle_get(self):
        if self.path == '/api/garage/health':
            return self._garage_health()
        if self.path == '/api/garage/export.json':
            return self._garage_export_json()
        if self.path == '/api/garage/export.csv':
            return self._garage_export_csv()
        if self.path == '/api/garage/cars':
            if not self._garage_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            try:
                return self._send_json(_garage_load())
            except Exception as e:
                return self._send_json({'error': str(e)}, 500)
        return False

    def _garage_handle_post(self):
        path = self.path
        if path == '/api/garage/login' or path.startswith('/api/garage/login?'):
            return self._garage_login()
        if path == '/api/garage/logout' or path.startswith('/api/garage/logout?'):
            return self._garage_logout()
        if path == '/api/garage/identify' or path.startswith('/api/garage/identify?'):
            if not self._garage_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._garage_identify()
        if path == '/api/garage/cars':
            if not self._garage_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._garage_post_cars_replace()
        if path == '/api/garage/cars/single':
            if not self._garage_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._garage_post_cars_single()
        # P0: /api/garage/cars/<id>/confirm and /api/garage/cars/<id>/discard
        m_confirm = re.match(r'^/api/garage/cars/([^/?]+)/confirm/?$', path)
        if m_confirm:
            if not self._garage_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._garage_post_cars_confirm(m_confirm.group(1))
        m_discard = re.match(r'^/api/garage/cars/([^/?]+)/discard/?$', path)
        if m_discard:
            if not self._garage_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._garage_post_cars_discard(m_discard.group(1))
        if path == '/api/garage/upload':
            if not self._garage_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._garage_post_upload()
        if path == '/api/garage/import/preview':
            if not self._garage_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._garage_post_import_preview()
        return False

    def _garage_handle_delete(self):
        if not self._garage_auth_ok():
            return self._send_json({'error': 'unauthorized'}, 401)
        m = re.match(r'^/api/garage/cars/([^/?]+)/?$', self.path)
        if not m:
            return self._send_json({'error': 'Not Found'}, 404)
        car_id = m.group(1)
        if not re.match(r'^[A-Za-z0-9_-]{1,64}$', car_id):
            return self._send_json({'error': 'invalid id format'}, 400)
        with garage_db_lock:
            db = _garage_load()
            if isinstance(db.get('cars'), list):
                db['cars'] = {}
            cars = db.setdefault('cars', {})
            if car_id not in cars:
                return self._send_json({'ok': True, 'deleted': False})
            del cars[car_id]
            now = time.time_ns() // 1_000_000
            db['updated_at'] = now
            if os.path.exists(GARAGE_DB_PATH):
                try:
                    _garage_atomic_write(
                        GARAGE_DB_PATH.replace('.json', '.bak.json'),
                        json.load(open(GARAGE_DB_PATH))
                    )
                except Exception:
                    pass
            _garage_atomic_write(GARAGE_DB_PATH, db)
        return self._send_json({'ok': True, 'deleted': True})

    def _garage_post_cars_confirm(self, car_id):
        """Promote draft (confirmed:false) → confirmed (true). Idempotent.
        P0 confirmation gate: identify result is draft until user explicitly confirms."""
        if not re.match(r'^[A-Za-z0-9_-]{1,64}$', car_id):
            return self._send_json({'error': 'invalid id format'}, 400)
        with garage_db_lock:
            db = _garage_load()
            if isinstance(db.get('cars'), list):
                db['cars'] = {}
            cars = db.setdefault('cars', {})
            if car_id not in cars:
                return self._send_json({'error': 'not found'}, 404)
            car = cars[car_id]
            car['confirmed'] = True
            car['updated_at'] = time.time_ns() // 1_000_000
            db['updated_at'] = car['updated_at']
            if os.path.exists(GARAGE_DB_PATH):
                try:
                    _garage_atomic_write(
                        GARAGE_DB_PATH.replace('.json', '.bak.json'),
                        json.load(open(GARAGE_DB_PATH))
                    )
                except Exception:
                    pass
            _garage_atomic_write(GARAGE_DB_PATH, db)
        return self._send_json({'ok': True, 'car': car, 'was_draft': not car.get('low_conf', False)})

    def _garage_post_cars_discard(self, car_id):
        """Discard a draft. Delete car entry AND its photos from GARAGE_IMAGES_DIR.
        Idempotent on missing car."""
        if not re.match(r'^[A-Za-z0-9_-]{1,64}$', car_id):
            return self._send_json({'error': 'invalid id format'}, 400)
        with garage_db_lock:
            db = _garage_load()
            if isinstance(db.get('cars'), list):
                db['cars'] = {}
            cars = db.setdefault('cars', {})
            car = cars.get(car_id)
            deleted_car = False
            photos_to_remove = []
            if car:
                # Capture filenames to remove. Also derive matching _thumb.webp from _orig.<ext> filenames
                # (P1 1.5a-aware: each upload saves both _orig.<ext> + _thumb.webp).
                for url_key in ('top_photo', 'bottom_photo', 'top_thumb', 'bottom_thumb'):
                    url = car.get(url_key, '')
                    if url and url.startswith('/garage_images/'):
                        fn = url[len('/garage_images/'):]
                        photos_to_remove.append(fn)
                        if '_orig.' in fn:
                            base = fn.rsplit('_orig.', 1)[0]
                            photos_to_remove.append(base + '_thumb.webp')
                        elif fn.endswith('_thumb.webp'):
                            base = fn[:-len('_thumb.webp')]
                            for ext in ('.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif'):
                                photos_to_remove.append(base + '_orig' + ext)
                del cars[car_id]
                deleted_car = True
                db['updated_at'] = time.time_ns() // 1_000_000
                if os.path.exists(GARAGE_DB_PATH):
                    try:
                        _garage_atomic_write(
                            GARAGE_DB_PATH.replace('.json', '.bak.json'),
                            json.load(open(GARAGE_DB_PATH))
                        )
                    except Exception:
                        pass
                _garage_atomic_write(GARAGE_DB_PATH, db)
        # Best-effort photo cleanup (outside lock — doesn't block reads). Dedupe.
        unique_files = list(dict.fromkeys(photos_to_remove))
        removed_files = 0
        for fn in unique_files:
            try:
                p = os.path.join(GARAGE_IMAGES_DIR, fn)
                if os.path.exists(p):
                    os.remove(p)
                    removed_files += 1
            except Exception:
                pass
        return self._send_json({'ok': True, 'discarded': deleted_car, 'photos_removed': removed_files, 'files_attempted': len(unique_files)})

    def _tomica_identify(self):
        """Identify a Tomica car from top + bottom images via MiniMax vision."""
        try:
            cl = int(self.headers.get('Content-Length') or 0)
            raw = self.rfile.read(cl)
            payload = json.loads(raw.decode('utf-8'))
            top = payload.get('top', '')
            bottom = payload.get('bottom', '')
            if not top:
                return self._send_json({'error': 'missing top image'}, 400)

            # Read MiniMax CN API key from .env (same one hermes-vision-mcp uses)
            env_path = '/opt/data/.env'
            api_key = None
            if os.path.isfile(env_path):
                with open(env_path) as f:
                    for line in f:
                        line = line.strip()
                        if line.startswith('AUXILIARY_VISION_API_KEY=') or line.startswith('MINIMAX_CN_API_KEY='):
                            val = line.split('=', 1)[1].strip().strip('"').strip("'")
                            if len(val) > 30 and '*' not in val:
                                api_key = val
                                break

            if not api_key:
                return self._send_json({'error': 'vision API key not configured'}, 500)

            # Use MiniMax /v1/coding_plan/vlm endpoint (same shape as hermes-vision-mcp)
            # Call top image first, then bottom, then merge
            prompt_text = (
                "You are identifying a Tomica die-cast toy car from photos. "
                "Look at the TOP image (vehicle shape, color, livery) to identify brand and model. "
                "Look at the BOTTOM image carefully — Tomica bases are engraved with "
                "'© TOMY YYYY' (production year) and sometimes a Tomica model number like 'No.XX'.\n\n"
                "Return ONLY a compact JSON object (no markdown, no commentary):\n"
                '{"brand": "Toyota/Nissan/Honda/Mazda/Subaru/etc OR empty string if unsure", '
                '"model": "specific car model name OR empty string", '
                '"no": "Tomica number if visible, OR empty string if not legible", '
                '"year": YYYY_as_integer_OR_null_if_unsure, '
                '"confidence_brand": "high/medium/low", '
                '"confidence_model": "high/medium/low", '
                '"confidence_year": "high/medium/low (read from base engraving, NOT estimated)", '
                '"confidence_no": "high/medium/low", '
                '"notes": "1 sentence what you saw (engravings, color, distinguishing marks)"}\n\n'
                "CRITICAL RULES:\n"
                "1. If you cannot clearly read a field, return empty string or null — do NOT guess.\n"
                "2. Year must come from engraved '© TOMY YYYY' on the base. Never estimate year from model.\n"
                "3. Tomica number 'No.XX' is hard to read — only return if clearly visible.\n"
                "4. brand/model can be high confidence even if year/no are unclear.\n\n"
                "Reply JSON only."
            )

            def call_vlm(image_data_url):
                import sys
                # MiniMax /v1/coding_plan/vlm uses FLAT prompt+image_url format (NOT OpenAI messages array)
                vlm_payload = {"prompt": prompt_text, "image_url": image_data_url}
                sys.stderr.write(f"[VLM] calling vlm, image bytes={len(image_data_url)}\n"); sys.stderr.flush()
                req = urllib.request.Request(
                    "https://api.minimaxi.com/v1/coding_plan/vlm",
                    data=json.dumps(vlm_payload).encode("utf-8"),
                    headers={
                        "Authorization": f"Bearer {api_key}",
                        "Content-Type": "application/json",
                    },
                    method="POST",
                )
                try:
                    with urllib.request.urlopen(req, timeout=45) as resp:
                        body_text = resp.read().decode("utf-8", errors="replace")
                        sys.stderr.write(f"[VLM] OK status={resp.status} bytes={len(body_text)}\n"); sys.stderr.flush()
                        return resp.status, body_text
                except urllib.error.HTTPError as e:
                    body_text = e.read().decode("utf-8", errors="replace") if e.fp else ""
                    sys.stderr.write(f"[VLM] HTTPError {e.code} body={body_text[:200]}\n"); sys.stderr.flush()
                    return e.code, body_text
                except Exception as e:
                    sys.stderr.write(f"[VLM] EXC {type(e).__name__}: {e}\n"); sys.stderr.flush()
                    return 0, f"client error: {type(e).__name__}: {e}"

            def extract_json(text):
                # Try direct parse
                text = text.strip()
                if text.startswith('{'):
                    return text
                # Strip markdown code fence
                import re as _re
                m = _re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, _re.DOTALL)
                if m:
                    return m.group(1)
                # Find first {...}
                m = _re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', text, _re.DOTALL)
                if m:
                    return m.group(0)
                return text

            # Call top image
            status1, body1 = call_vlm(top)
            if status1 != 200:
                return self._send_json({'error': f'top image vision {status1}: {body1[:200]}'}, 502)

            top_data = json.loads(body1)
            top_text = top_data.get('content', top_data.get('text', top_data.get('result', '')))
            if isinstance(top_text, list):
                top_text = ' '.join(str(x) for x in top_text)

            merged = {}
            try:
                merged = json.loads(extract_json(top_text))
            except Exception:
                merged = {'notes': top_text[:300]}

            # If bottom image exists, call it and merge year/no if missing
            if bottom:
                status2, body2 = call_vlm(bottom)
                if status2 == 200:
                    bottom_data = json.loads(body2)
                    bottom_text = bottom_data.get('content', bottom_data.get('text', bottom_data.get('result', '')))
                    if isinstance(bottom_text, list):
                        bottom_text = ' '.join(str(x) for x in bottom_text)
                    try:
                        b = json.loads(extract_json(bottom_text))
                        # Fill gaps from bottom image
                        for k in ('year', 'no', 'brand', 'model'):
                            if not merged.get(k) and b.get(k):
                                merged[k] = b[k]
                        if b.get('notes'):
                            merged['notes'] = (merged.get('notes', '') + ' | base: ' + b['notes'])[:300]
                    except Exception:
                        pass

            return self._send_json({
                'brand': merged.get('brand', '') or '',
                'model': merged.get('model', merged.get('name', '')) or '',
                'name': merged.get('model', '') or '',
                'no': str(merged.get('no', '') or ''),
                'year': merged.get('year'),
                'confidence': {
                    'brand': merged.get('confidence_brand', 'low'),
                    'model': merged.get('confidence_model', 'low'),
                    'year': merged.get('confidence_year', 'low'),
                    'no': merged.get('confidence_no', 'low'),
                },
                'notes': merged.get('notes', '') or ''
            })
        except Exception as e:
            import traceback
            tb = traceback.format_exc()
            self.log_message(f"identify exception: {e}\n{tb}")
            try:
                return self._send_json({'error': f'identify failed: {type(e).__name__}: {e}'}, 500)
            except Exception:
                return

    def _handle_tomica_get(self):
        if self.path == '/api/tomica/cars':
            if not self._tomica_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            try:
                return self._send_json(_tomica_load())
            except Exception as e:
                return self._send_json({'error': str(e)}, 500)
        return False

    def _handle_tomica_post(self):
        path = self.path
        if path == '/api/tomica/login' or path.startswith('/api/tomica/login?'):
            return self._tomica_login()
        if path == '/api/tomica/identify' or path.startswith('/api/tomica/identify?'):
            if not self._tomica_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._tomica_identify()
        if path == '/api/tomica/cars':
            if not self._tomica_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._tomica_post_cars_replace()
        if path == '/api/tomica/cars/single':
            if not self._tomica_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._tomica_post_cars_single()
        if path == '/api/tomica/upload':
            if not self._tomica_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._tomica_post_upload()
        if path == '/api/tomica/view-count/reset':
            if not self._tomica_auth_ok():
                return self._send_json({'error': 'unauthorized'}, 401)
            return self._send_json({'error': 'Not implemented (Step 3+)'}, 405)
        return False

    def _handle_tomica_delete(self):
        """Step 2D: DELETE /api/tomica/cars/<id>. Idempotent.
        RLock + sanitize + atomic + .bak pattern.
        No-op if id absent: return deleted:false, no disk IO."""
        if not self._tomica_auth_ok():
            return self._send_json({'error': 'unauthorized'}, 401)
        m = re.match(r'^/api/tomica/cars/([^/?]+)/?$', self.path)
        if not m:
            return self._send_json({'error': 'Not Found'}, 404)
        car_id = m.group(1)
        if not re.match(r'^[A-Za-z0-9_-]{1,64}$', car_id):
            return self._send_json({'error': 'invalid id format'}, 400)
        with tomica_db_lock:
            db = _tomica_load()
            # Defensive sanitize
            if isinstance(db.get('cars'), list):
                db['cars'] = {}
            cars = db.setdefault('cars', {})
            if car_id not in cars:
                # Idempotent: no-op, no disk write, no updated_at bump
                return self._send_json({'ok': True, 'deleted': False})
            del cars[car_id]
            now = time.time_ns() // 1_000_000
            db['updated_at'] = now
            # Atomic write with .bak rotation
            if os.path.exists(TOMICA_DB_PATH):
                try:
                    _tomica_atomic_write(
                        TOMICA_DB_PATH.replace('.json', '.bak.json'),
                        json.load(open(TOMICA_DB_PATH))
                    )
                except Exception:
                    pass
            _tomica_atomic_write(TOMICA_DB_PATH, db)
        return self._send_json({'ok': True, 'deleted': True})

    def _tomica_post_cars_single(self):
        """Step 2A: incremental save with LWW by updated_at.
        RLock means caller can safely with-lock then call _tomica_load
        (which may also internally with-lock). No deadlock.
        Defensive: isinstance(..., list) sanitizes legacy array data."""
        try:
            cl = int(self.headers.get('Content-Length') or 0)
            raw = self.rfile.read(cl)
            car = json.loads(raw.decode('utf-8'))
            car_id = car.get('id')
            if not car_id or not isinstance(car_id, str):
                return self._send_json({'error': 'missing or invalid id'}, 400)
            if not re.match(r'^[A-Za-z0-9_-]{1,64}$', car_id):
                return self._send_json({'error': 'invalid id format'}, 400)
            now = time.time_ns() // 1_000_000
            with tomica_db_lock:
                db = _tomica_load()
                # Defensive sanitize: legacy array -> object
                if isinstance(db.get('cars'), list):
                    db['cars'] = {}
                cars = db.setdefault('cars', {})
                existing = cars.get(car_id)
                client_updated = car.get('updated_at', 0) or 0
                if existing and (existing.get('updated_at', 0) or 0) > client_updated:
                    # Server wins (newer). Don't write.
                    return self._send_json({
                        'ok': True, 'car': existing, 'merged': 'server_won'
                    })
                # Client wins (new car OR newer ts)
                if not car.get('created_at'):
                    car['created_at'] = car.get('updated_at', now)
                cars[car_id] = car
                db['updated_at'] = now
                # Atomic write: write to .bak first, then main
                if os.path.exists(TOMICA_DB_PATH):
                    try:
                        _tomica_atomic_write(
                            TOMICA_DB_PATH.replace('.json', '.bak.json'),
                            json.load(open(TOMICA_DB_PATH))
                        )
                    except Exception:
                        pass
                _tomica_atomic_write(TOMICA_DB_PATH, db)
            return self._send_json({'ok': True, 'car': car, 'merged': 'client_won'})
        except Exception as e:
            return self._send_json({'error': str(e)}, 500)

    def _tomica_post_cars_replace(self):
        """Step 2B: bulk replace entire cars collection.
        Body: {'cars': {id1: carObj, id2: carObj, ...}, 'view_count': int}
        Defensive: isinstance(..., list) sanitize; view_count preserved if
        omitted from payload (server-side merge with current)."""
        try:
            cl = int(self.headers.get('Content-Length') or 0)
            raw = self.rfile.read(cl)
            payload = json.loads(raw.decode('utf-8'))
            new_cars = payload.get('cars')
            if not isinstance(new_cars, dict):
                return self._send_json({'error': 'cars must be an object {id: carObj}'}, 400)
            # Validate each car has an 'id' field matching the dict key
            for cid, car in new_cars.items():
                if not isinstance(car, dict) or car.get('id') != cid:
                    return self._send_json({'error': f'car id mismatch at key {cid}'}, 400)
                if not re.match(r'^[A-Za-z0-9_-]{1,64}$', cid):
                    return self._send_json({'error': f'invalid id format: {cid}'}, 400)
            now = time.time_ns() // 1_000_000
            with tomica_db_lock:
                db = _tomica_load()
                # Defensive sanitize: legacy array -> object
                if isinstance(db.get('cars'), list):
                    db['cars'] = {}
                # view_count: if payload omits, preserve current; else use payload
                if 'view_count' in payload:
                    db['view_count'] = int(payload['view_count'])
                db['cars'] = new_cars
                db['updated_at'] = now
                # Atomic write with .bak rotation
                if os.path.exists(TOMICA_DB_PATH):
                    try:
                        _tomica_atomic_write(
                            TOMICA_DB_PATH.replace('.json', '.bak.json'),
                            json.load(open(TOMICA_DB_PATH))
                        )
                    except Exception:
                        pass
                _tomica_atomic_write(TOMICA_DB_PATH, db)
            return self._send_json({
                'ok': True,
                'updated_at': now,
                'count': len(new_cars),
                'view_count': db['view_count']
            })
        except Exception as e:
            return self._send_json({'error': str(e)}, 500)

    def _tomica_post_upload(self):
        """Step 2C: multipart/form-data image upload. Field name 'file'.
        Client expected to have transcoded to WebP (spec 5.3).
        Ponytail: 8MB cap BEFORE read; 413 early.
        Uses email.parser.BytesParser (cgi removed in Py 3.13).
        Wrap body with Content-Type header (boundary info) before parse.
        No DB lock needed (writes to separate file in tomica_images/)."""
        content_type = self.headers.get('Content-Type', '')
        if not content_type.startswith('multipart/form-data'):
            return self._send_json({'error': 'must be multipart/form-data'}, 400)
        cl = int(self.headers.get('Content-Length') or 0)
        if cl <= 0:
            return self._send_json({'error': 'missing or zero Content-Length'}, 400)
        if cl > 8 * 1024 * 1024:
            return self._send_json({'error': 'file too large (max 8MB)'}, 413)
        try:
            body = self.rfile.read(cl)
            # Wrap body with Content-Type header so BytesParser finds boundary
            mime_msg_bytes = ('Content-Type: ' + content_type + '\r\n\r\n').encode('utf-8') + body
            msg = BytesParser().parsebytes(mime_msg_bytes)
            save_dir = '/opt/data/diary/tomica_images'
            os.makedirs(save_dir, exist_ok=True)
            for part in msg.walk():
                if part.get_content_maintype() == 'multipart':
                    continue
                if not part.get_filename():
                    continue
                file_data = part.get_payload(decode=True)
                if not file_data:
                    continue
                safe_name = 'v' + str(time.time_ns() // 1_000_000) + '_' + secrets.token_hex(4) + '.webp'
                save_path = os.path.join(save_dir, safe_name)
                with open(save_path, 'wb') as f:
                    f.write(file_data)
                return self._send_json({
                    'filename': safe_name,
                    'url': '/tomica_images/' + safe_name,
                    'size': len(file_data)
                })
            return self._send_json({'error': 'no file found in payload'}, 400)
        except Exception as e:
            return self._send_json({'error': str(e)}, 500)


    # ============================================================
    # Bureau Chatbot — MiniMax CN proxy (POST /api/bureau/chat)
    # SPEC: inline-translate app, persona-locked multi-turn.
    # Server-side proxy keeps API key off browser.
    # ============================================================
    _BUREAU_KEY = None
    _BUREAU_URL = "https://api.minimaxi.com/v1/text/chatcompletion_v2"
    _BUREAU_TIMEOUT = 120

    def _bureau_load_key(self):
        if DiaryHandler._BUREAU_KEY is not None:
            return DiaryHandler._BUREAU_KEY
        try:
            with open('/opt/data/.env', 'r', encoding='utf-8') as f:
                for line in f:
                    if line.startswith('MINIMAX_CN_API_KEY=') and not line.startswith('#'):
                        DiaryHandler._BUREAU_KEY = line.strip().split('=', 1)[1].strip()
                        break
        except Exception:
            pass
        return DiaryHandler._BUREAU_KEY

    _BUREAU_DISABLED = False  # resumed — switch to MiniMax-M2.7 (stable) instead of M3 (unstable reasoning)

    def _bureau_handle(self):
        if DiaryHandler._BUREAU_DISABLED:
            return self._send_json({"error": "Service temporarily disabled due to upstream instability", "code": "bureau_paused"}, 503)
        key = self._bureau_load_key()
        if not key:
            return self._send_json({"error": "Server missing MINIMAX_CN_API_KEY"}, 500)
        cl = self.headers.get('Content-Length')
        if not cl:
            return self._send_json({"error": "Missing Content-Length"}, 400)
        try:
            body = self.rfile.read(int(cl))
            payload = json.loads(body)
        except Exception as e:
            return self._send_json({"error": f"Bad request body: {e}"}, 400)
        payload["model"] = "MiniMax-M2.7"  # switch from M3 — M2.7 reasoning-mode stable
        # ponytail: short single-turn prompts + Gemini-mirror approach.
        # Front-end now sends one user input per call, no history. 600
        # tokens = enough for the JSON output (output ~150-300 chars) plus
        # a tight deepDive (80-150 chars). Lower than 1500 because we no
        # longer carry 6 messages of context that needed reasoning budget.
        payload.setdefault("max_tokens", 600)
        payload.setdefault("temperature", 0.5)
        payload.setdefault("response_format", {"type": "json_object"})
        req = urllib.request.Request(
            DiaryHandler._BUREAU_URL,
            data=json.dumps(payload).encode('utf-8'),
            headers={'Authorization': f'Bearer {key}', 'Content-Type': 'application/json'},
            method='POST'
        )
        try:
            with urllib.request.urlopen(req, timeout=DiaryHandler._BUREAU_TIMEOUT) as resp:
                raw_data = json.loads(resp.read())
        except urllib.error.HTTPError as e:
            err_body = e.read().decode('utf-8', errors='replace')[:400]
            return self._send_json({"error": f"MiniMax HTTP {e.code}: {err_body}"}, e.code)
        except Exception as e:
            return self._send_json({"error": f"MiniMax proxy failed: {e}"}, 502)
        # Ponytail: MiniMax M3 reasoning-mode quirk - when finish_reason=length,
        # content field may be empty because tokens burned on reasoning_content.
        # Extract JSON block from reasoning_content as fallback so the client
        # never sees a blank bubble.
        try:
            msg = raw_data.get('choices', [{}])[0].get('message', {})
            content = msg.get('content') or ''
            finish = raw_data.get('choices', [{}])[0].get('finish_reason', '')
            reasoning = msg.get('reasoning_content') or ''
            if (not content.strip() or finish == 'length') and reasoning:
                m = re.search(r'\{[\s\S]*\}', reasoning)
                if m:
                    content = m.group(0)
            raw_data['choices'][0]['message']['content'] = content
            raw_data.setdefault('_meta', {})['reasoning_fallback'] = bool(reasoning and not content.strip())
        except Exception:
            pass
        return self._send_json(raw_data, 200)

    def do_GET(self):
        if self.path == "/" or self.path == "/diary":
            self.path = "/diary_blog.html"
        # Redirect /image_gen.html to /image_gen (serves the HTML from Flask)
        if self.path == "/image_gen.html":
            self.path = "/image_gen"
        # Victoria Ledger entry alias — keep canonical /ledger for the proxied Flask app
        if self.path == "/ledger.html":
            self.send_response(302)
            self.send_header("Location", "/ledger/")
            self.end_headers()
            return
        # GBA server-side saves (local file system, no proxy)
        if self.path.startswith("/api/gba"):
            if self._handle_gba(): return
        # Tomica v2 (/api/garage/*)
        if self.path.startswith("/api/garage"):
            if self._garage_handle_get(): return
        # Tomica login route
        if self.path.startswith("/api/tomica"):
            if self._handle_tomica_get(): return
        # Victoria Ledger — reverse-proxy /ledger/* to the Flask sub-process
        if self.path == "/ledger" or self.path.startswith("/ledger/") or self.path.startswith("/ledger?"):
            self.proxy_to_ledger()
            return
        if self.path.startswith("/image_gen") or self.path.startswith("/api"):
            self.proxy_to_image_gen()
            return
        return super().do_GET()

    def do_POST(self):
        import sys
        sys.stderr.write(f"[do_POST] path={self.path}\n"); sys.stderr.flush()
        # GBA server-side saves (local file system, no proxy)
        if self.path.startswith("/api/gba"):
            if self._handle_gba(): return
        # Tomica v2 (/api/garage/*)
        if self.path.startswith("/api/garage"):
            if self._garage_handle_post(): return
        # Tomica login + identify
        if self.path.startswith("/api/tomica"):
            if self._handle_tomica_post(): return
        # Bureau Chatbot — MiniMax CN proxy (inline-translate app)
        if self.path == '/api/bureau/chat':
            self._bureau_handle()
            return
        # Victoria Ledger — POST/PUT to Flask sub-process
        if self.path == '/ledger' or self.path.startswith('/ledger/') or self.path.startswith('/ledger?'):
            self.proxy_to_ledger()
            return
        if self.path.startswith("/api"):
            self.proxy_to_image_gen()
            return
        self.send_error(405)

    def do_DELETE(self):
        if self.path.startswith("/api/gba"):
            if self._handle_gba(): return
        if self.path.startswith("/api/garage"):
            if self._garage_handle_delete(): return
        if self.path.startswith("/api/tomica"):
            if self._handle_tomica_delete(): return
        # Victoria Ledger — DELETE reverse-proxy
        if self.path.startswith("/ledger"):
            self.proxy_to_ledger()
            return
        if self.path.startswith("/api"):
            self.proxy_to_image_gen()
            return
        self.send_error(405)

    def _proxy_to_upstream(self, host, port, prefix_strip="", timeout=90):
        """Generic reverse-proxy. Strips prefix_strip from path before forwarding."""
        path = self.path
        if prefix_strip and path.startswith(prefix_strip):
            path = path[len(prefix_strip):] or "/"

        body = None
        cl = self.headers.get("Content-Length")
        if cl:
            try:
                body = self.rfile.read(int(cl))
            except Exception:
                body = None

        hdrs = {}
        for k in ("Content-Type", "Authorization", "Accept", "X-Password", "Cookie"):
            v = self.headers.get(k)
            if v:
                hdrs[k] = v

        conn = http.client.HTTPConnection(host, port, timeout=timeout)
        try:
            conn.request(self.command, path, body=body, headers=hdrs)
            resp = conn.getresponse()
            self.send_response(resp.status)
            for k, v in resp.getheaders():
                if k.lower() in ("transfer-encoding", "connection"):
                    continue
                self.send_header(k, v)
            self.end_headers()
            self.wfile.write(resp.read())
        except Exception as e:
            try:
                self.send_error(502, str(e))
            except Exception:
                pass
        finally:
            try:
                conn.close()
            except Exception:
                pass

    def proxy_to_image_gen(self):
        # image_gen has /image_gen/* local file serving baked in. Preserve that.
        path = self.path
        if path.startswith("/api"):
            return self._proxy_to_upstream(IMAGE_GEN_HOST, IMAGE_GEN_PORT)
        if path.startswith("/image_gen/"):
            # Serve local image files directly from /opt/data/image_gen/
            filename = path[len("/image_gen/"):]
            local_file = f"/opt/data/image_gen/{filename}"
            if os.path.exists(local_file):
                self.send_response(200)
                ext = filename.rsplit(".", 1)[-1].lower()
                ctype = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"}.get(ext, "application/octet-stream")
                self.send_header("Content-Type", ctype)
                self.send_header("Content-Length", os.path.getsize(local_file))
                self.end_headers()
                with open(local_file, "rb") as f:
                    self.wfile.write(f.read())
                return
            self.send_error(404, "File not found")
            return
        if path.startswith("/image_gen"):
            return self._proxy_to_upstream(IMAGE_GEN_HOST, IMAGE_GEN_PORT, prefix_strip="/image_gen")
        self.send_error(404)

    def proxy_to_ledger(self):
        # /ledger/* → Victoria Ledger Flask sub-process; strip /ledger prefix.
        return self._proxy_to_upstream(LEDGER_HOST, LEDGER_PORT, prefix_strip=LEDGER_PATH_PREFIX)

    def log_message(self, format, *args):
        # Log to stderr (visible in our log file via nohup stderr redirect)
        import sys
        try:
            sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")
            sys.stderr.flush()
        except Exception:
            pass

if __name__ == "__main__":
    os.chdir(SERVE_DIR)
    class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
        """Multi-threaded so identify's blocking vision call doesn't freeze other requests."""
        daemon_threads = True

    server = ThreadingHTTPServer(("0.0.0.0", PORT), DiaryHandler)
    # ponytail: SO_REUSEADDR lets restart bind even if a previous instance is in
    # TIME_WAIT, complementing watchdog's pkill. Cheap belt-and-braces fix.
    server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    print(f"Diary server running on port {PORT}")
    server.serve_forever()
# test
