"""
SorcGCS Local Server

Provides:
- Static file serving for game preview (with HTML error injection)
- REST API for file read/write/list/search/delete/rename
- Game directory browsing and management
- Script proxy for cross-origin JS loading
- FFmpeg media processing (if ffmpeg is in PATH)
- Checkpoint create/restore/list
- Command execution for AI agent integration

Source: https://github.com/SorcGCS/sorcgcs-server
"""

import http.server
import socketserver
import json
import os
import subprocess
import glob
import mimetypes
import re
import base64
import threading
import uuid
import time
from urllib.parse import quote, unquote, parse_qs, urlparse
import urllib.request

CFG = {"games": os.getcwd(), "preview": None}
FFMPEG_JOBS = {}

ERROR_CAPTURE_SCRIPT = r'''<script>(function(){var cs=function(s){return s?s.replace(/https?:\/\/[^/]+\/api\/script-proxy\?url=[^:]+%2F([^:%]+\.js)(:\d+)?(:\d+)?/g,"$1$2$3").replace(/https?:\/\/[^/]+\/api\/script-proxy\?url=[^\s]+%2F([^%\s]+)/g,"$1"):s};var cf=function(s){if(!s)return"";if(s.indexOf("/api/script-proxy")>-1){var m=s.match(/%2F([^%]+\.js)/);if(m)return m[1]}return s.split("/").pop().split("?")[0]};var _p=function(d){try{window.parent.postMessage(d,"*")}catch(e){}};var lastE=null;window.addEventListener("error",function(e){lastE=e;var msg=e.error&&e.error.stack?cs(e.error.stack):e.error&&e.error.message?e.error.message:e.message||"Script error";var src=e.filename?cf(e.filename):"";_p({type:"error",level:"error",message:msg,source:src,line:e.lineno||0})},true);window.onerror=function(m,s,l,c,e){if(lastE&&lastE.message===m){lastE=null;return false}var msg=e&&e.stack?cs(e.stack):m;_p({type:"error",level:"error",message:msg,source:cf(s),line:l||0});return false};window.onunhandledrejection=function(e){var msg="Promise: "+(e.reason&&e.reason.stack?cs(e.reason.stack):e.reason&&e.reason.message?e.reason.message:String(e.reason||"Unknown"));_p({type:"error",level:"error",message:msg})};["error","warn","log"].forEach(function(lvl){var o=console[lvl];console[lvl]=function(){var a=[];for(var i=0;i<arguments.length;i++){var x=arguments[i];a.push(x instanceof Error?cs(x.stack||x.message):x&&x.stack?cs(x.stack):typeof x==="object"?JSON.stringify(x):String(x))}_p({type:"console",level:lvl,message:a.join(" ")});o.apply(console,arguments)}})})();</script>'''


class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=CFG["games"], **kwargs)

    def end_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "*")
        super().end_headers()

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

    def jres(self, d, c=200):
        self.send_response(c)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(d).encode())

    def rbody(self):
        length = int(self.headers.get("Content-Length", 0))
        return json.loads(self.rfile.read(length)) if length > 0 else {}

    def rpath(self, p, pp):
        return p if os.path.isabs(p) else os.path.normpath(os.path.join(pp or CFG["games"], p))

    # ── FFmpeg processing ────────────────────────────────────────────

    def handle_ffmpeg_process(self):
        import tempfile
        import shutil

        ffmpeg_path = shutil.which("ffmpeg")
        if not ffmpeg_path:
            self.jres({"error": "FFmpeg not found in PATH"}, 500)
            return

        content_type = self.headers.get("Content-Type", "")
        if "multipart/form-data" not in content_type:
            self.jres({"error": "Expected multipart/form-data"}, 400)
            return

        boundary = None
        for part in content_type.split(";"):
            part = part.strip()
            if part.startswith("boundary="):
                boundary = part[9:].strip('"')
                break

        if not boundary:
            self.jres({"error": "No boundary found"}, 400)
            return

        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length)

        parts = body.split(("--" + boundary).encode())
        file_data = None
        file_name = None
        options = {}
        CRLF = bytes([13, 10])
        CRLF2 = bytes([13, 10, 13, 10])

        for part in parts:
            if b"Content-Disposition" not in part:
                continue
            header_end = part.find(CRLF2)
            if header_end == -1:
                continue
            header_section = part[:header_end].decode("utf-8", errors="replace")
            content = part[header_end + 4:]
            if content.endswith(b"--" + CRLF):
                content = content[:-6]
            elif content.endswith(CRLF):
                content = content[:-2]
            if 'name="file"' in header_section:
                file_data = content
                crlf_str = chr(13) + chr(10)
                for line in header_section.split(crlf_str):
                    if "filename=" in line:
                        start = line.find('filename="') + 10
                        end = line.find('"', start)
                        file_name = line[start:end]
            elif 'name="options"' in header_section:
                try:
                    options = json.loads(content.decode("utf-8"))
                except Exception:
                    pass

        if not file_data or not file_name:
            self.jres({"error": "No file uploaded"}, 400)
            return

        with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file_name)[1]) as tmp_in:
            tmp_in.write(file_data)
            input_path = tmp_in.name

        job_id = str(uuid.uuid4())
        FFMPEG_JOBS[job_id] = {"status": "processing", "file_name": file_name}
        print(f"  [FFmpeg] Job {job_id[:8]} queued for {file_name}")

        def run_ffmpeg():
            try:
                opt_type = options.get("type", "")
                if opt_type == "format-convert":
                    out_ext = "." + options.get("outputFormat", "mp4")
                elif opt_type in ("video-resize", "webm-to-mp4"):
                    out_ext = ".mp4"
                elif opt_type == "audio-compress":
                    out_ext = ".mp3"
                else:
                    out_ext = os.path.splitext(file_name)[1]

                with tempfile.NamedTemporaryFile(delete=False, suffix=out_ext) as tmp_out:
                    output_path = tmp_out.name

                cmd = [ffmpeg_path, "-y", "-i", input_path]
                if opt_type == "audio-compress":
                    cmd += ["-b:a", options.get("bitrate", "128k"), "-ac", str(options.get("channels", 2)), output_path]
                elif opt_type == "video-resize":
                    cmd += ["-vf", f"scale=-2:{options.get('height', 720)}", "-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", output_path]
                elif opt_type == "webm-to-mp4":
                    crf = str(options.get("crf", 18))
                    fps = str(options.get("fps", 30))
                    cmd += ["-r", fps, "-c:v", "libx264", "-crf", crf, "-pix_fmt", "yuv420p", "-an", output_path]
                elif opt_type == "volume-adjust":
                    cmd += ["-af", f"volume={options.get('volume', 1.0)}", output_path]
                elif opt_type == "format-convert":
                    fmt = options.get("outputFormat", "mp4")
                    FORMAT_ARGS = {
                        "mp4": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-preset", "medium", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"],
                        "webm": ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus", "-b:a", "128k"],
                        "mov": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", "-b:a", "192k"],
                        "mkv": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", "-b:a", "192k"],
                        "avi": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", "-b:a", "192k"],
                        "gif": ["-vf", "fps=15,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"],
                        "mp3": ["-vn", "-c:a", "libmp3lame", "-q:a", "2"],
                        "wav": ["-vn", "-c:a", "pcm_s16le"],
                        "ogg": ["-vn", "-c:a", "libvorbis", "-q:a", "4"],
                        "flac": ["-vn", "-c:a", "flac"],
                        "aac": ["-vn", "-c:a", "aac", "-b:a", "192k"],
                        "m4a": ["-vn", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"],
                    }
                    cmd += FORMAT_ARGS.get(fmt, []) + [output_path]
                else:
                    FFMPEG_JOBS[job_id] = {"status": "error", "error": f"Unknown type: {opt_type}"}
                    os.unlink(input_path)
                    return

                print(f"  [FFmpeg] Job {job_id[:8]} running: {' '.join(cmd)}")
                result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)

                if result.returncode != 0:
                    print(f"  [FFmpeg] Job {job_id[:8]} error: {result.stderr[:200]}")
                    FFMPEG_JOBS[job_id] = {"status": "error", "error": result.stderr[:200]}
                    os.unlink(input_path)
                    if os.path.exists(output_path):
                        os.unlink(output_path)
                    return

                output_size = os.path.getsize(output_path)
                ext = os.path.splitext(file_name)[1].lower()
                ctype_map = {
                    ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
                    ".flac": "audio/flac", ".m4a": "audio/mp4", ".aac": "audio/aac",
                    ".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm",
                    ".mov": "video/quicktime", ".mkv": "video/x-matroska",
                    ".avi": "video/x-msvideo", ".gif": "image/gif",
                }
                if opt_type == "format-convert":
                    ctype = ctype_map.get("." + options.get("outputFormat", "mp4"), "application/octet-stream")
                elif opt_type in ("video-resize", "webm-to-mp4"):
                    ctype = "video/mp4"
                elif opt_type == "audio-compress":
                    ctype = "audio/mpeg"
                else:
                    ctype = ctype_map.get(ext, "application/octet-stream")

                FFMPEG_JOBS[job_id] = {
                    "status": "done", "output_path": output_path,
                    "output_size": output_size, "content_type": ctype,
                    "file_name": file_name,
                }
                print(f"  [FFmpeg] Job {job_id[:8]} done! {output_size} bytes")
                os.unlink(input_path)

                def cleanup():
                    time.sleep(600)
                    job = FFMPEG_JOBS.pop(job_id, None)
                    if job and job.get("output_path") and os.path.exists(job["output_path"]):
                        os.unlink(job["output_path"])

                threading.Thread(target=cleanup, daemon=True).start()

            except Exception as e:
                print(f"  [FFmpeg] Job {job_id[:8]} exception: {e}")
                FFMPEG_JOBS[job_id] = {"status": "error", "error": str(e)}
                if os.path.exists(input_path):
                    os.unlink(input_path)

        threading.Thread(target=run_ffmpeg, daemon=True).start()
        self.jres({"jobId": job_id, "status": "processing"})

    def handle_ffmpeg_status(self, job_id):
        job = FFMPEG_JOBS.get(job_id)
        if not job:
            self.jres({"error": "Job not found"}, 404)
            return
        self.jres({
            "jobId": job_id, "status": job["status"],
            "error": job.get("error"), "outputSize": job.get("output_size"),
            "fileName": job.get("file_name"),
        })

    def handle_ffmpeg_download(self, job_id):
        job = FFMPEG_JOBS.get(job_id)
        if not job:
            self.jres({"error": "Job not found"}, 404)
            return
        if job["status"] != "done":
            self.jres({"error": "Not ready", "status": job["status"]}, 400)
            return
        output_path = job.get("output_path", "")
        if not output_path or not os.path.exists(output_path):
            self.jres({"error": "Output file missing"}, 404)
            return
        try:
            with open(output_path, "rb") as f:
                data = f.read()
            self.send_response(200)
            self.send_header("Content-Type", job.get("content_type", "application/octet-stream"))
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)
            print(f"  [FFmpeg] Job {job_id[:8]} downloaded ({len(data)} bytes)")
            FFMPEG_JOBS.pop(job_id, None)
            os.unlink(output_path)
        except Exception as e:
            print(f"  [FFmpeg] Download error: {e}")

    # ── POST endpoints ───────────────────────────────────────────────

    def do_POST(self):
        try:
            if self.path == "/api/ffmpeg-process":
                self.handle_ffmpeg_process()
                return

            b = self.rbody()
            pp = b.get("projectPath", CFG["games"])

            if self.path == "/api/read-file":
                fp = self.rpath(b.get("path", ""), pp)
                if os.path.isfile(fp):
                    if b.get("binary"):
                        self.jres({"content": base64.b64encode(open(fp, "rb").read()).decode("ascii"), "binary": True})
                    else:
                        self.jres({"content": open(fp, "r", encoding="utf-8", errors="replace").read()})
                else:
                    self.jres({"error": "File not found"}, 404)

            elif self.path == "/api/write-file":
                fp = self.rpath(b.get("path", ""), pp)
                pd = os.path.dirname(fp)
                if pd:
                    os.makedirs(pd, exist_ok=True)
                open(fp, "w", encoding="utf-8").write(b.get("content", ""))
                self.jres({"success": True})

            elif self.path == "/api/list-dir":
                dp = self.rpath(b.get("path", "."), pp)
                if os.path.isdir(dp):
                    entries = []
                    for n in os.listdir(dp):
                        full_path = os.path.join(dp, n)
                        entries.append({
                            "name": n,
                            "path": os.path.relpath(full_path, pp).replace("\\", "/"),
                            "isDirectory": os.path.isdir(full_path),
                        })
                    self.jres({"entries": entries})
                else:
                    self.jres({"error": "Not found"}, 404)

            elif self.path == "/api/search-files":
                pattern = b.get("pattern", "**/*")
                files = [
                    os.path.relpath(f, pp).replace("\\", "/")
                    for f in glob.glob(os.path.join(pp, pattern), recursive=True)
                    if os.path.isfile(f)
                ][:500]
                self.jres({"files": files})

            elif self.path == "/api/run-command":
                r = subprocess.run(
                    b.get("command", ""), shell=True,
                    cwd=b.get("cwd", pp),
                    capture_output=True, text=True, timeout=60,
                )
                self.jres({"exitCode": r.returncode, "stdout": r.stdout, "stderr": r.stderr})

            elif self.path == "/api/create-dir":
                os.makedirs(self.rpath(b.get("path", ""), pp), exist_ok=True)
                self.jres({"success": True})

            elif self.path == "/api/delete-file":
                fp = self.rpath(b.get("path", ""), pp)
                if os.path.isfile(fp):
                    os.remove(fp)
                    self.jres({"success": True})
                else:
                    self.jres({"error": "Not found"}, 404)

            elif self.path == "/api/rename-file":
                op = self.rpath(b.get("oldPath", ""), pp)
                np_path = self.rpath(b.get("newPath", ""), pp)
                if os.path.exists(op):
                    os.rename(op, np_path)
                    self.jres({"success": True, "newPath": np_path})
                else:
                    self.jres({"error": "Not found"}, 404)

            elif self.path == "/api/write-file-binary":
                fp = self.rpath(b.get("path", ""), pp)
                pd = os.path.dirname(fp)
                if pd:
                    os.makedirs(pd, exist_ok=True)
                with open(fp, "wb") as f:
                    f.write(base64.b64decode(b.get("content", "")))
                self.jres({"success": True})

            elif self.path == "/api/set-preview-path":
                CFG["preview"] = b.get("path", CFG["games"])
                self.jres({"success": True, "previewPath": CFG["preview"]})

            elif self.path == "/api/set-games-dir":
                CFG["games"] = b.get("path", CFG["games"])
                self.jres({"success": True, "gamesDir": CFG["games"]})

            elif self.path == "/api/checkpoint/create":
                cid = b.get("checkpointId", "")
                cp = b.get("checkpoint", {})
                cpdir = os.path.join(pp, ".sorceress", "checkpoints", cid)
                os.makedirs(cpdir, exist_ok=True)
                with open(os.path.join(cpdir, "checkpoint.json"), "w", encoding="utf-8") as f:
                    json.dump(cp, f)
                self.jres({"success": True, "checkpointId": cid})

            elif self.path == "/api/checkpoint/restore-file":
                f_data = b.get("file", {})
                fp = self.rpath(f_data.get("path", ""), pp)
                if f_data.get("action") == "created":
                    if os.path.isfile(fp):
                        os.remove(fp)
                elif f_data.get("action") in ["modified", "deleted"]:
                    if f_data.get("previousContent") is not None:
                        pd = os.path.dirname(fp)
                        if pd:
                            os.makedirs(pd, exist_ok=True)
                        with open(fp, "w", encoding="utf-8") as wf:
                            wf.write(f_data.get("previousContent", ""))
                self.jres({"success": True})

            elif self.path == "/api/checkpoint/list":
                cpbase = os.path.join(pp, ".sorceress", "checkpoints")
                cps = []
                if os.path.exists(cpbase):
                    for d in os.listdir(cpbase):
                        cpf = os.path.join(cpbase, d, "checkpoint.json")
                        if os.path.exists(cpf):
                            with open(cpf, "r", encoding="utf-8") as rf:
                                cps.append(json.load(rf))
                cps.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
                self.jres({"checkpoints": cps[:50]})

            else:
                self.jres({"error": "Unknown endpoint"}, 404)

        except Exception as e:
            self.jres({"error": str(e)}, 500)

    # ── File serving ─────────────────────────────────────────────────

    def serve_file(self, fp):
        if os.path.isdir(fp):
            fp = os.path.join(fp, "index.html")
        if not os.path.isfile(fp):
            self.send_error(404)
            return
        ct = mimetypes.guess_type(fp)[0] or "application/octet-stream"
        self.send_response(200)
        self.send_header("Content-Type", ct)
        self.end_headers()
        with open(fp, "rb") as f:
            content = f.read()
        if ct == "text/html":
            html = content.decode("utf-8", errors="replace")
            if "<head>" in html:
                html = html.replace("<head>", "<head>" + ERROR_CAPTURE_SCRIPT)
            elif "<html" in html:
                idx = html.index("<html")
                end_idx = idx + html[idx:].index(">") + 1
                html = html[:end_idx] + ERROR_CAPTURE_SCRIPT + html[end_idx:]
            else:
                html = ERROR_CAPTURE_SCRIPT + html
            content = html.encode("utf-8")
        self.wfile.write(content)

    # ── GET endpoints ────────────────────────────────────────────────

    def do_GET(self):
        import shutil as _shutil

        if self.path == "/api/status":
            ffmpeg_path = _shutil.which("ffmpeg")
            self.jres({
                "connected": True,
                "gamesDir": CFG["games"],
                "previewPath": CFG["preview"],
                "port": CFG.get("port", 8080),
                "agent": True,
                "ffmpeg": ffmpeg_path is not None,
            })

        elif self.path.startswith("/api/ffmpeg-status/"):
            job_id = self.path.split("/api/ffmpeg-status/")[1].split("?")[0]
            self.handle_ffmpeg_status(job_id)

        elif self.path.startswith("/api/ffmpeg-download/"):
            job_id = self.path.split("/api/ffmpeg-download/")[1].split("?")[0]
            self.handle_ffmpeg_download(job_id)

        elif self.path == "/api/browse-folder":
            try:
                import tkinter as tk
                from tkinter import filedialog
                root = tk.Tk()
                root.withdraw()
                root.attributes("-topmost", True)
                folder = filedialog.askdirectory(title="Select Project Folder", initialdir=CFG["games"])
                root.destroy()
                if folder:
                    self.jres({"success": True, "path": folder.replace("/", os.sep)})
                else:
                    self.jres({"success": False, "cancelled": True})
            except Exception as e:
                self.jres({"success": False, "error": str(e)})

        elif self.path.startswith("/api/script-proxy"):
            try:
                qs = parse_qs(urlparse(self.path).query)
                url = unquote(qs.get("url", [""])[0])
                if not url or not url.startswith(("http://", "https://")):
                    self.send_error(400, "Invalid URL")
                    return
                req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
                with urllib.request.urlopen(req, timeout=15) as resp:
                    script_content = resp.read()
                self.send_response(200)
                self.send_header("Content-Type", "application/javascript")
                self.send_header("Cache-Control", "public, max-age=300")
                self.end_headers()
                self.wfile.write(script_content)
            except Exception as e:
                self.send_error(502, "Failed to fetch script: " + str(e))

        elif self.path == "/api/games":
            games = []
            if os.path.exists(CFG["games"]):
                for n in os.listdir(CFG["games"]):
                    game_path = os.path.join(CFG["games"], n)
                    if os.path.isdir(game_path) and os.path.exists(os.path.join(game_path, "index.html")):
                        games.append({"name": n, "path": n})
            self.jres({"games": games})

        elif self.path.startswith("/api/checkpoint/get"):
            qs = parse_qs(urlparse(self.path).query)
            cid = qs.get("id", [""])[0]
            cppath = qs.get("projectPath", [CFG["games"]])[0]
            cpf = os.path.join(cppath, ".sorceress", "checkpoints", cid, "checkpoint.json")
            if os.path.exists(cpf):
                with open(cpf, "r", encoding="utf-8") as rf:
                    self.jres(json.load(rf))
            else:
                self.jres({"error": "Not found"}, 404)

        elif self.path.startswith("/preview"):
            rp = self.path[8:].lstrip("/").split("?")[0] if len(self.path) > 8 else ""
            rp = rp.replace("/", os.sep)
            fp = os.path.normpath(os.path.join(CFG["preview"], rp)) if rp else CFG["preview"]
            self.serve_file(fp)

        elif self.path == "/games" or self.path == "/games/":
            self.send_response(200)
            self.send_header("Content-Type", "text/html")
            self.end_headers()
            html = '<html><body style="background:#111;color:#fff;font-family:sans-serif;"><h1>Games</h1><ul>'
            if os.path.exists(CFG["games"]):
                for name in sorted(os.listdir(CFG["games"])):
                    game_path = os.path.join(CFG["games"], name)
                    if os.path.isdir(game_path) and os.path.exists(os.path.join(game_path, "index.html")):
                        html += f'<li><a href="/games/{name}/" style="color:#a855f7">{name}</a></li>'
            html += "</ul></body></html>"
            self.wfile.write(html.encode())

        elif self.path.startswith("/games/"):
            path = self.path[7:].split("?")[0]
            path = path.replace("/", os.sep)
            file_path = os.path.normpath(os.path.join(CFG["games"], path))
            if os.path.isdir(file_path):
                index_path = os.path.join(file_path, "index.html")
                if os.path.exists(index_path):
                    file_path = index_path
            self.serve_file(file_path)

        else:
            super().do_GET()

    def log_message(self, format, *args):
        pass  # Quiet by default; startup banner is enough


class ThreadedServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
    allow_reuse_address = True
    daemon_threads = True


def run_server(port=8080, host="0.0.0.0", games_dir=None):
    if games_dir:
        CFG["games"] = os.path.abspath(games_dir)
    CFG["preview"] = CFG["games"]
    CFG["port"] = port

    with ThreadedServer((host, port), Handler) as httpd:
        httpd.serve_forever()
