#!/usr/bin/env python3
import json, os, threading, time, urllib.request, urllib.error
from collections import defaultdict, deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

HOST="127.0.0.1"; PORT=18083
TOKEN_PATH="/opt/starfall-chronicle/openrouter.token"
MODEL="x-ai/grok-4.6"
MAX_BODY=8192; MAX_RESPONSE=65536
ALLOWED_EVENTS={"AURORA_BLOOM","SALVAGE_POD","CROP_BLIGHT","CRYSTAL_STORM","REFUGEES","TRADE_OPPORTUNITY","SICKNESS","HOSTILE_PROBE","RELATIONSHIP_DELEGATION","QUIET_DAY"}
ALLOWED_TONES={"hopeful","tense","mysterious","somber","triumphant","quiet"}
WINDOW=60; LIMIT=8
hits=defaultdict(deque); lock=threading.Lock()
class UpstreamFailure(Exception): pass

def compact(value,limit): return str(value or "").replace("\n"," ").replace("\r"," ")[:limit]
def limited(ip):
    now=time.time()
    with lock:
        q=hits[ip]
        while q and q[0]<now-WINDOW:q.popleft()
        if len(q)>=LIMIT:return True
        q.append(now);return False

def validate(payload):
    if not isinstance(payload,dict):raise ValueError("object required")
    event=compact(payload.get("event"),32).upper()
    if event not in ALLOWED_EVENTS:raise ValueError("unsupported event")
    day=int(payload.get("day",1));tension=int(payload.get("tension",0))
    if not 1<=day<=100000 or not 0<=tension<=100:raise ValueError("range")
    colony=payload.get("colony",{})
    if not isinstance(colony,dict):raise ValueError("colony")
    safe={k:max(0,min(999999,int(colony.get(k,0)))) for k in ("settlers","children","wood","stone","food","crystal")}
    return event,day,tension,safe,compact(payload.get("context"),600)

def grok(event,day,tension,colony,context):
    token=open(TOKEN_PATH,encoding="utf-8").read().strip()
    system=("You are the Chronicle voice for Starfall Haven, an original hopeful science-fantasy frontier colony game. Write exactly two concise sentences, suitable for ages 13+, with no copyrighted game names, no sexual content, no graphic injury, no player instructions, and no invented mechanical effects. Describe only the supplied catalog event and colony facts. Return strict JSON matching the schema.")
    user=json.dumps({"catalog_event":event,"day":day,"tension":tension,"colony":colony,"local_context":context},separators=(",",":"))
    schema={"name":"starfall_chronicle","strict":True,"schema":{"type":"object","properties":{"title":{"type":"string","maxLength":72},"story":{"type":"string","maxLength":420},"tone":{"type":"string","enum":sorted(ALLOWED_TONES)}},"required":["title","story","tone"],"additionalProperties":False}}
    request={"model":MODEL,"messages":[{"role":"system","content":system},{"role":"user","content":user}],"temperature":0.65,"max_tokens":180,"response_format":{"type":"json_schema","json_schema":schema}}
    req=urllib.request.Request("https://openrouter.ai/api/v1/chat/completions",data=json.dumps(request).encode(),headers={"Authorization":"Bearer "+token,"Content-Type":"application/json","HTTP-Referer":"https://animefu.space","X-Title":"Starfall Haven Chronicle"})
    with urllib.request.urlopen(req,timeout=25) as response:data=response.read(MAX_RESPONSE+1)
    if len(data)>MAX_RESPONSE:raise UpstreamFailure("upstream too large")
    content=json.loads(data)["choices"][0]["message"]["content"];result=json.loads(content) if isinstance(content,str) else content
    title=compact(result.get("title"),72);story=compact(result.get("story"),420);tone=compact(result.get("tone"),16).lower()
    if not title or not story or tone not in ALLOWED_TONES:raise UpstreamFailure("invalid upstream schema")
    return {"title":title,"story":story,"tone":tone,"model":MODEL}

class Handler(BaseHTTPRequestHandler):
    server_version="StarfallChronicle/1"
    def log_message(self,format,*args):print("%s %s"%(self.address_string(),format%args),flush=True)
    def send_json(self,status,obj):
        raw=json.dumps(obj,separators=(",",":")).encode()
        try:self.send_response(status);self.send_header("Content-Type","application/json");self.send_header("Cache-Control","no-store");self.send_header("Content-Length",str(len(raw)));self.end_headers();self.wfile.write(raw)
        except (BrokenPipeError,ConnectionResetError):pass
    def do_GET(self):self.send_json(200,{"ok":True,"model":MODEL}) if self.path=="/health" else self.send_json(404,{"error":"not found"})
    def do_POST(self):
        if self.path!="/narrate":return self.send_json(404,{"error":"not found"})
        ip=self.headers.get("X-Forwarded-For",self.client_address[0]).split(",",1)[0].strip()
        if limited(ip):return self.send_json(429,{"error":"rate limited"})
        try:
            n=int(self.headers.get("Content-Length","0"))
            if n<2 or n>MAX_BODY:raise ValueError("body size")
            self.send_json(200,grok(*validate(json.loads(self.rfile.read(n)))))
        except urllib.error.HTTPError as e:print("upstream HTTP",e.code,flush=True);self.send_json(502,{"error":"narrator unavailable"})
        except UpstreamFailure as e:print("upstream error",str(e)[:120],flush=True);self.send_json(502,{"error":"narrator unavailable"})
        except Exception as e:print("request error",type(e).__name__,str(e)[:120],flush=True);self.send_json(400 if isinstance(e,(ValueError,json.JSONDecodeError)) else 502,{"error":"invalid request" if isinstance(e,(ValueError,json.JSONDecodeError)) else "narrator unavailable"})
if __name__=="__main__":ThreadingHTTPServer((HOST,PORT),Handler).serve_forever()
