#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""IMPRINT · 印记 — cloud-ready stdlib server.

Runs behind Nginx or Docker with no Python package installation. Each browser gets
an anonymous HttpOnly player cookie; saves, endings, and cross-run residue are
isolated per player.
"""

import json
import os
import re
import sys
import threading
import time
import traceback
import uuid
from collections import deque
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

HERE = os.path.dirname(os.path.abspath(__file__))
PUBLIC = os.path.join(HERE, "public")
sys.path.insert(0, HERE)


def _load_env():
    for base in (HERE, os.path.dirname(HERE)):
        for name in (".env", "API.env"):
            path = os.path.join(base, name)
            if not os.path.isfile(path):
                continue
            try:
                with open(path, "r", encoding="utf-8") as f:
                    for line in f:
                        line = line.strip()
                        if not line or line.startswith("#") or "=" not in line:
                            continue
                        key, value = line.split("=", 1)
                        value = value.strip().strip('"').strip("'")
                        os.environ.setdefault(key.strip(), value)
            except Exception as exc:
                print("warn: could not read %s: %s" % (path, exc), file=sys.stderr)


_load_env()

from engine import endings as END          # noqa: E402
from engine import game as G               # noqa: E402
from engine import state as S              # noqa: E402

HOST = os.environ.get("HOST", "127.0.0.1")
PORT = int(os.environ.get("PORT", "8791"))
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "0").lower() in ("1", "true", "yes")
TRUST_PROXY = os.environ.get("TRUST_PROXY", "0").lower() in ("1", "true", "yes")
ENABLE_HSTS = os.environ.get("ENABLE_HSTS", "0").lower() in ("1", "true", "yes")
LOG_REQUESTS = os.environ.get("LOG_REQUESTS", "0").lower() in ("1", "true", "yes")
RATE_LIMIT = max(1, int(os.environ.get("RATE_LIMIT_PER_MINUTE", "12")))
MAX_BODY = max(1024, int(os.environ.get("MAX_REQUEST_BYTES", "65536")))

PLAYER_COOKIE = "imprint_player"
PLAYER_RE = re.compile(r"^[0-9a-f]{32}$")
PLAYER_LOCKS = [threading.Lock() for _ in range(256)]
RATE_BUCKETS = {}
RATE_LOCK = threading.Lock()

MIME = {
    ".html": "text/html; charset=utf-8",
    ".css": "text/css; charset=utf-8",
    ".js": "application/javascript; charset=utf-8",
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".mp3": "audio/mpeg",
    ".json": "application/json; charset=utf-8",
    ".svg": "image/svg+xml",
    ".ico": "image/x-icon",
    ".woff2": "font/woff2",
}


def _player_lock(player_id):
    return PLAYER_LOCKS[int(player_id[:8], 16) % len(PLAYER_LOCKS)]


def _rate_allowed(key):
    now = time.monotonic()
    with RATE_LOCK:
        bucket = RATE_BUCKETS.setdefault(key, deque())
        while bucket and now - bucket[0] >= 60:
            bucket.popleft()
        if len(bucket) >= RATE_LIMIT:
            return False
        bucket.append(now)
        # Bound memory when the service sees many one-off clients.
        if len(RATE_BUCKETS) > 20000:
            stale = [k for k, q in list(RATE_BUCKETS.items())[:5000]
                     if not q or now - q[-1] >= 120]
            for stale_key in stale:
                RATE_BUCKETS.pop(stale_key, None)
        return True


class Handler(BaseHTTPRequestHandler):
    server_version = "IMPRINT-Cloud/1.0"
    protocol_version = "HTTP/1.1"

    def log_message(self, fmt, *args):
        if LOG_REQUESTS:
            super().log_message(fmt, *args)

    def _prepare_player(self):
        raw = self.headers.get("Cookie", "")
        player_id = ""
        try:
            cookie = SimpleCookie()
            cookie.load(raw)
            if PLAYER_COOKIE in cookie:
                player_id = cookie[PLAYER_COOKIE].value
        except Exception:
            player_id = ""
        self._new_player_cookie = not PLAYER_RE.match(player_id or "")
        if self._new_player_cookie:
            player_id = uuid.uuid4().hex
        self.player_id = player_id
        S.set_player_id(player_id)

    def _client_ip(self):
        if TRUST_PROXY:
            forwarded = self.headers.get("X-Forwarded-For", "")
            if forwarded:
                return forwarded.split(",", 1)[0].strip()
        return self.client_address[0]

    def _common_headers(self):
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("X-Frame-Options", "DENY")
        self.send_header("Referrer-Policy", "no-referrer")
        self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
        self.send_header(
            "Content-Security-Policy",
            "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
            "img-src 'self' data:; media-src 'self'; connect-src 'self'; "
            "font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
        )
        if ENABLE_HSTS:
            self.send_header("Strict-Transport-Security", "max-age=31536000")
        if getattr(self, "_new_player_cookie", False):
            flags = [
                "%s=%s" % (PLAYER_COOKIE, self.player_id),
                "Path=/",
                "Max-Age=31536000",
                "HttpOnly",
                "SameSite=Lax",
            ]
            if COOKIE_SECURE:
                flags.append("Secure")
            self.send_header("Set-Cookie", "; ".join(flags))

    def _json(self, obj, code=200, retry_after=None):
        data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "no-store")
        if retry_after is not None:
            self.send_header("Retry-After", str(retry_after))
        self._common_headers()
        self.end_headers()
        try:
            self.wfile.write(data)
        except (BrokenPipeError, ConnectionResetError):
            pass

    def _body(self):
        try:
            length = int(self.headers.get("Content-Length", 0))
        except (TypeError, ValueError):
            return None
        if length < 0 or length > MAX_BODY:
            return None
        try:
            raw = self.rfile.read(length) if length else b"{}"
            value = json.loads(raw.decode("utf-8"))
            return value if isinstance(value, dict) else None
        except Exception:
            return None

    def _static(self, request_path):
        path = "/index.html" if request_path == "/" else request_path
        full = os.path.abspath(os.path.normpath(os.path.join(PUBLIC, path.lstrip("/"))))
        try:
            inside_public = os.path.commonpath((PUBLIC, full)) == PUBLIC
        except ValueError:
            inside_public = False
        if not inside_public or not os.path.isfile(full):
            return self._json({"error": "not_found"}, 404)
        ext = os.path.splitext(full)[1].lower()
        try:
            with open(full, "rb") as f:
                data = f.read()
        except OSError:
            return self._json({"error": "read_failed"}, 500)
        self.send_response(200)
        self.send_header("Content-Type", MIME.get(ext, "application/octet-stream"))
        self.send_header("Content-Length", str(len(data)))
        if ext in (".png", ".jpg", ".jpeg", ".mp3", ".woff2"):
            self.send_header("Cache-Control", "public, max-age=604800, immutable")
        elif ext in (".js", ".css"):
            self.send_header("Cache-Control", "no-cache")
        else:
            self.send_header("Cache-Control", "no-store")
        self._common_headers()
        self.end_headers()
        try:
            self.wfile.write(data)
        except (BrokenPipeError, ConnectionResetError):
            pass

    def do_GET(self):
        parsed = urlparse(self.path)
        path = parsed.path
        if path == "/api/health":
            return self._json({
                "ok": True,
                "provider": G.provider().name,
                "keyConfigured": G.provider().available(),
            })

        self._prepare_player()
        if path == "/api/boot":
            info = G.boot_info()
            info["llm"] = {
                "provider": G.provider().name,
                "ready": G.provider().available(),
            }
            gallery = []
            for ending_id, ending in END.ENDINGS.items():
                unlocked = ending_id in info["profile"].get("endings", [])
                gallery.append({
                    "id": ending_id,
                    "title": ending["title"] if unlocked else "？？？",
                    "unlock": ending["unlock"] if unlocked else "未解锁",
                    "unlocked": unlocked,
                })
            gallery.sort(key=lambda item: item["id"])
            info["gallery"] = gallery
            return self._json(info)

        if path == "/api/state":
            run_id = parse_qs(parsed.query).get("id", [""])[0]
            state = S.load_state(run_id)
            if not state:
                return self._json({"error": "not_found"}, 404)
            return self._json({
                "id": state["id"],
                "day": state["day"],
                "turn": state["turn"],
                "ended": state.get("ended"),
                "ending_id": state.get("ending_id"),
                "history": state["history"][-80:],
                "monitor": G.monitor(state),
                "substrate": state["substrate"],
                "archives": state["archives"],
                "archive_texts": state.get("archive_texts", {}),
                "report": state.get("report"),
            })
        return self._static(path)

    def do_POST(self):
        self._prepare_player()
        path = urlparse(self.path).path
        if path not in ("/api/new", "/api/turn", "/api/advance", "/api/report"):
            return self._json({"error": "not_found"}, 404)
        body = self._body()
        if body is None:
            return self._json({"error": "invalid_request"}, 400)
        rate_key = "%s:%s" % (self.player_id, self._client_ip())
        if not _rate_allowed(rate_key):
            return self._json({
                "error": "rate_limited",
                "message": "请求过于频繁。请稍后继续测试。",
            }, 429, retry_after=60)

        # Requests from one player are serialized; different players run concurrently.
        with _player_lock(self.player_id):
            try:
                if path == "/api/new":
                    state, opener, notifications = G.new_run()
                    return self._json({
                        "id": state["id"],
                        "opener": opener,
                        "notifications": notifications,
                        "monitor": G.monitor(state),
                        "budget": S.DAY_BUDGET[1],
                    })
                state = S.load_state(body.get("id", ""))
                if not state:
                    return self._json({"error": "not_found"}, 404)
                if path == "/api/turn":
                    return self._json(G.play_turn(state, body.get("text", "")))
                if path == "/api/advance":
                    return self._json(G.advance_day(state))
                return self._json(G.submit_report(
                    state,
                    body.get("q1"),
                    body.get("q2"),
                    body.get("note"),
                    body.get("hesitation", 0),
                ))
            except Exception as exc:
                traceback.print_exc()
                return self._json({
                    "error": "internal",
                    "message": str(exc)[:200],
                }, 500)


class Server(ThreadingHTTPServer):
    daemon_threads = True
    allow_reuse_address = True


def main():
    os.makedirs(S.SAVE_DIR, mode=0o700, exist_ok=True)
    httpd = Server((HOST, PORT), Handler)
    print("IMPRINT · 印记 — CLOUD BUILD")
    print("Listening on http://%s:%d" % (HOST, PORT))
    print("Data directory: %s" % S.SAVE_DIR)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        httpd.server_close()


if __name__ == "__main__":
    main()
