#!/usr/bin/env python3
"""
Villa Decision Hub — local server with state persistence.
Serves static files + GET/POST /state.json (saves to state.json next to this file).
Run: python3 server.py
"""
import http.server
import socketserver
import json
import os
import re
import sys
import time
from urllib.parse import urlparse, parse_qs, unquote

PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
PORT = int(os.environ.get('PORT', 8765))
# Whitelisted JSON state files we accept GET/POST for
ALLOWED_JSON = {'state.json', 'timeline.json'}
ATTACHMENTS_DIR = os.path.join(PROJECT_DIR, 'attachments')
MAX_UPLOAD_BYTES = 25 * 1024 * 1024  # 25 MB
SAFE_ID = re.compile(r'^[a-zA-Z0-9_-]+$')

# In-memory cache for large static files that live in iCloud and may briefly
# lock during sync. We read them into RAM once and serve from there.
CACHED_ASSETS = {'villa.png'}
_cache = {}  # path -> (bytes, mtime)


def _read_cached(path):
    """Return file bytes, using in-memory cache to dodge iCloud lock errors.

    Re-reads from disk if the file's mtime has changed since the cache entry.
    """
    try:
        st = os.stat(path)
    except OSError:
        return None
    cached = _cache.get(path)
    if cached and cached[1] == st.st_mtime:
        return cached[0]
    # Try to read fresh; on transient iCloud lock, fall back to last cache.
    try:
        with open(path, 'rb') as f:
            data = f.read()
        _cache[path] = (data, st.st_mtime)
        return data
    except OSError:
        return cached[0] if cached else None


class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=PROJECT_DIR, **kwargs)

    def end_headers(self):
        # Always set CORS for the state endpoint to be safe
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        super().end_headers()

    def do_OPTIONS(self):
        self.send_response(204)
        self.end_headers()

    def _resolve_state_path(self, url_path):
        """Return absolute path if url_path is /<allowed>.json, else None."""
        name = url_path.lstrip('/')
        if name in ALLOWED_JSON:
            return os.path.join(PROJECT_DIR, name)
        return None

    def do_GET(self):
        path = urlparse(self.path).path
        state_path = self._resolve_state_path(path)
        if state_path is not None:
            self._serve_state(state_path)
            return
        # Serve cached assets from RAM to bypass iCloud lock issues
        name = path.lstrip('/')
        if name in CACHED_ASSETS:
            data = _read_cached(os.path.join(PROJECT_DIR, name))
            if data is not None:
                ctype = 'image/png' if name.endswith('.png') else 'application/octet-stream'
                self.send_response(200)
                self.send_header('Content-Type', ctype)
                self.send_header('Content-Length', str(len(data)))
                self.send_header('Cache-Control', 'public, max-age=86400')
                self.end_headers()
                self.wfile.write(data)
                return
        super().do_GET()

    def do_POST(self):
        path = urlparse(self.path).path
        state_path = self._resolve_state_path(path)
        if state_path is not None:
            self._save_state(state_path)
            return
        if path == '/upload':
            self._handle_upload()
            return
        if path == '/delete-file':
            self._handle_delete_file()
            return
        self.send_error(404, 'Not Found')

    def _serve_state(self, state_path):
        try:
            data = '{}'
            if os.path.exists(state_path):
                with open(state_path, 'r', encoding='utf-8') as f:
                    data = f.read() or '{}'
            payload = data.encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Cache-Control', 'no-store')
            self.send_header('Content-Length', str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
        except Exception as e:
            self._error(500, str(e))

    def _save_state(self, state_path):
        try:
            length = int(self.headers.get('Content-Length', 0))
            if length <= 0 or length > 256 * 1024:
                self._error(400, 'Invalid body size')
                return
            body = self.rfile.read(length).decode('utf-8')
            # Validate JSON (object or array)
            data = json.loads(body)
            if not isinstance(data, (dict, list)):
                self._error(400, 'Body must be a JSON object or array')
                return
            # Atomic write — write to temp then replace
            tmp = state_path + '.tmp'
            with open(tmp, 'w', encoding='utf-8') as f:
                json.dump(data, f, indent=2, ensure_ascii=False)
            os.replace(tmp, state_path)
            payload = b'{"ok":true}'
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Content-Length', str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
        except json.JSONDecodeError as e:
            self._error(400, f'Invalid JSON: {e}')
        except Exception as e:
            self._error(500, str(e))

    def _error(self, code, message):
        body = json.dumps({'error': message}).encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _sanitize_filename(self, name):
        # Strip path components and dangerous chars; preserve Arabic/Latin/digits/dots/dashes
        name = os.path.basename(name)
        name = re.sub(r'[^\w؀-ۿ.\-]+', '_', name, flags=re.UNICODE)
        name = name.lstrip('._')
        if not name:
            name = f'upload_{int(time.time())}'
        if len(name) > 200:
            name = name[:200]
        return name

    def _handle_upload(self):
        try:
            qs = parse_qs(urlparse(self.path).query)
            phase_id = (qs.get('phase') or [''])[0]
            task_id = (qs.get('task') or [''])[0]
            if not (SAFE_ID.match(phase_id) and SAFE_ID.match(task_id)):
                return self._error(400, 'Invalid phase/task id')

            raw_name = self.headers.get('X-Filename', 'upload.bin')
            try:
                raw_name = unquote(raw_name)
            except Exception:
                pass
            safe_name = self._sanitize_filename(raw_name)

            length = int(self.headers.get('Content-Length', 0))
            if length <= 0 or length > MAX_UPLOAD_BYTES:
                return self._error(400, 'Invalid file size')

            folder = os.path.join(ATTACHMENTS_DIR, phase_id, task_id)
            os.makedirs(folder, exist_ok=True)

            # Avoid overwriting: append (1), (2)... if name exists
            base, ext = os.path.splitext(safe_name)
            target = os.path.join(folder, safe_name)
            counter = 1
            while os.path.exists(target):
                target = os.path.join(folder, f'{base}({counter}){ext}')
                counter += 1

            with open(target, 'wb') as f:
                remaining = length
                while remaining > 0:
                    chunk = self.rfile.read(min(remaining, 64 * 1024))
                    if not chunk:
                        break
                    f.write(chunk)
                    remaining -= len(chunk)

            rel = os.path.relpath(target, PROJECT_DIR)
            payload = json.dumps({
                'ok': True,
                'name': os.path.basename(target),
                'path': '/' + rel.replace(os.sep, '/'),
                'size': os.path.getsize(target),
            }, ensure_ascii=False).encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Content-Length', str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
        except Exception as e:
            self._error(500, str(e))

    def _handle_delete_file(self):
        try:
            length = int(self.headers.get('Content-Length', 0))
            if length <= 0 or length > 4096:
                return self._error(400, 'Invalid body')
            body = self.rfile.read(length).decode('utf-8')
            data = json.loads(body)
            rel = (data.get('path') or '').lstrip('/')
            if not rel:
                return self._error(400, 'Missing path')
            abs_path = os.path.normpath(os.path.join(PROJECT_DIR, rel))
            # Must be under attachments/ (prevent traversal)
            if not abs_path.startswith(ATTACHMENTS_DIR + os.sep):
                return self._error(400, 'Path not allowed')
            if os.path.exists(abs_path):
                os.remove(abs_path)
            payload = b'{"ok":true}'
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Content-Length', str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
        except Exception as e:
            self._error(500, str(e))

    def log_message(self, format, *args):
        sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")


class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
    daemon_threads = True
    allow_reuse_address = True


if __name__ == '__main__':
    print(f"Villa server: http://localhost:{PORT}")
    print(f"Project dir: {PROJECT_DIR}")
    print(f"State files: {sorted(ALLOWED_JSON)}")
    with ThreadingHTTPServer(('', PORT), Handler) as httpd:
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            print("\nshutting down")
