#!/usr/bin/env python3
"""
MP3 Player — Python single-file server
Usage: python server.py [--root /path/to/music] [--port 3000]
"""

import argparse
import hashlib
import hmac
import json
import io
import os
import zipfile
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse

SITENAME = "X-site"
SLOGAN   = "The worst web"

# ── CLI args ───────────────────────────────────────────────────────────────────
parser = argparse.ArgumentParser(description="MP3 Player")
parser.add_argument("--root",     default="./music", help="Music root directory")
parser.add_argument("--port",     type=int, default=3000, help="Port to listen on")
parser.add_argument("--password", default="", help="Protect the site with this password")
parser.add_argument("--sitename", default="", help="Name of the site")
parser.add_argument("--slogan",   default="", help="Catchy tagline")
parser.add_argument("--theme",    default="", help="Path to a theme CSS file (overrides CSS variables)")
args = parser.parse_args()

if args.sitename: SITENAME = args.sitename
if args.slogan:   SLOGAN   = args.slogan

AUTH_PASSWORD  = args.password
SESSION_TOKEN  = hashlib.sha256(f"session:{AUTH_PASSWORD}".encode()).hexdigest() if AUTH_PASSWORD else ""

MUSIC_ROOT = Path(args.root).resolve()
PORT       = args.port
THEME_CSS  = Path(args.theme).resolve() if args.theme else None

if THEME_CSS and not THEME_CSS.exists():
    print(f"  ⚠  Theme file not found: {THEME_CSS}")
    THEME_CSS = None

AUDIO_EXTS = {".mp3", ".flac", ".ogg", ".wav", ".m4a", ".aac"}
AUDIO_MIME = {
    ".mp3": "audio/mpeg", ".flac": "audio/flac", ".ogg": "audio/ogg",
    ".wav": "audio/wav",  ".m4a":  "audio/mp4",  ".aac": "audio/aac",
}

if not MUSIC_ROOT.exists():
    MUSIC_ROOT.mkdir(parents=True)
    print(f"  ⚠  Created music root: {MUSIC_ROOT}")

# ── optional tag reading ───────────────────────────────────────────────────────
try:
    from mutagen import File as MutagenFile
    HAS_MUTAGEN = True
except ImportError:
    HAS_MUTAGEN = False

def read_tags(abs_path: Path) -> dict:
    if not HAS_MUTAGEN:
        return {"title": "", "artist": ""}
    try:
        audio = MutagenFile(abs_path, easy=True)
        if not audio:
            return {"title": "", "artist": ""}
        return {
            "title":  str(audio.get("title",  [""])[0]).strip(),
            "artist": str(audio.get("artist", [""])[0]).strip(),
        }
    except Exception:
        return {"title": "", "artist": ""}

def safe_path(rel: str) -> Path | None:
    rel = rel.lstrip("/\\")
    abs_path = (MUSIC_ROOT / rel).resolve()
    try:
        abs_path.relative_to(MUSIC_ROOT)
        return abs_path
    except ValueError:
        return None

# ── static file helpers ────────────────────────────────────────────────────────
_HERE = Path(__file__).parent

def _inject_config(html: str) -> bytes:
    """Inject SITENAME/SLOGAN as a JSON config block into any HTML file."""
    config = f'<script>window.SITE_CONFIG={json.dumps({"sitename": SITENAME, "slogan": SLOGAN})};</script>'
    return html.replace("</head>", f"{config}\n</head>", 1).encode()

def _load_login() -> bytes:
    return _inject_config((_HERE / "login.html").read_text(encoding="utf-8"))

def _load_index() -> bytes:
    return _inject_config((_HERE / "index.html").read_text(encoding="utf-8"))

# ── request handler ────────────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):

    def log_message(self, fmt, *a):
        print(f"  {self.address_string()} {fmt % a}")

    def send_json(self, code, data):
        body = json.dumps(data).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", len(body))
        self.end_headers()
        self.wfile.write(body)

    def _check_auth(self) -> bool:
        if not AUTH_PASSWORD:
            return True
        cookie_header = self.headers.get("Cookie", "")
        expected = f"session={SESSION_TOKEN}"
        return any(p.strip() == expected for p in cookie_header.split(";"))

    def _serve_login(self):
        body = _load_login()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", len(body))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        parsed   = urlparse(self.path)
        pathname = parsed.path
        query    = parse_qs(parsed.query)

        # Auth gate — login page and its assets are always accessible
        PUBLIC = {"/login", "/login.css", "/theme.css"}
        if pathname not in PUBLIC and not self._check_auth():
            print(f"  [auth] DENIED {pathname!r} cookie={self.headers.get('Cookie', '')!r}")
            print(f"  [auth] expected session={SESSION_TOKEN!r}")
            self._serve_login()
            return

        # ── static assets ────────────────────────────────────────────────────
        if pathname in ("/", "/index.html"):
            body = _load_index()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", len(body))
            self.end_headers()
            self.wfile.write(body)
            return

        if pathname == "/theme.css":
            body = THEME_CSS.read_bytes() if THEME_CSS else b""
            self.send_response(200)
            self.send_header("Content-Type", "text/css; charset=utf-8")
            self.send_header("Content-Length", len(body))
            self.end_headers()
            self.wfile.write(body)
            return

        if pathname == "/player.js":
            body = (_HERE / "player.js").read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "application/javascript; charset=utf-8")
            self.send_header("Content-Length", len(body))
            self.end_headers()
            self.wfile.write(body)
            return

        if pathname == "/player.css":
            body = (_HERE / "player.css").read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "text/css; charset=utf-8")
            self.send_header("Content-Length", len(body))
            self.end_headers()
            self.wfile.write(body)
            return

        if pathname == "/login.css":
            body = (_HERE / "login.css").read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "text/css; charset=utf-8")
            self.send_header("Content-Length", len(body))
            self.end_headers()
            self.wfile.write(body)
            return

        # ── API: browse ──────────────────────────────────────────────────────
        if pathname == "/api/browse":
            rel      = unquote(query.get("path", [""])[0])
            abs_path = safe_path(rel)
            if not abs_path:
                return self.send_json(403, {"error": "Forbidden"})
            if not abs_path.exists():
                return self.send_json(404, {"error": "Not found"})

            entries = list(abs_path.iterdir())
            folders = sorted(
                [{"name": e.name, "path": f"{rel}/{e.name}".lstrip("/")}
                 for e in entries if e.is_dir()],
                key=lambda x: x["name"].lower(),
                reverse=True,
            )
            def make_file_entry(e):
                tags = read_tags(abs_path / e.name)
                return {"name": e.name,
                        "path": f"{rel}/{e.name}".lstrip("/"),
                        "size": e.stat().st_size,
                        "title": tags["title"],
                        "artist": tags["artist"]}
            files = sorted(
                [make_file_entry(e) for e in entries
                 if e.is_file() and e.suffix.lower() in AUDIO_EXTS],
                key=lambda x: x["name"].lower(),
            )
            parts       = [p for p in rel.split("/") if p]
            breadcrumbs = [{"name": "Root", "path": ""}]
            for i, p in enumerate(parts):
                breadcrumbs.append({"name": p, "path": "/".join(parts[:i+1])})
            return self.send_json(200, {
                "path": rel, "folders": folders,
                "files": files, "breadcrumbs": breadcrumbs,
            })

        # ── API: browse-recursive ────────────────────────────────────────────
        if pathname == "/api/browse-recursive":
            rel      = unquote(query.get("path", [""])[0])
            abs_path = safe_path(rel)
            if not abs_path:
                return self.send_json(403, {"error": "Forbidden"})
            if not abs_path.exists():
                return self.send_json(404, {"error": "Not found"})

            files = []
            for root, dirs, fnames in os.walk(abs_path):
                dirs.sort()
                for fname in sorted(fnames):
                    if Path(fname).suffix.lower() in AUDIO_EXTS:
                        full     = Path(root) / fname
                        file_rel = str(full.relative_to(MUSIC_ROOT)).replace("\\", "/")
                        tags     = read_tags(full)
                        files.append({"name": fname, "path": file_rel,
                                      "size": full.stat().st_size,
                                      "title": tags["title"], "artist": tags["artist"]})
            parts       = [p for p in rel.split("/") if p]
            breadcrumbs = [{"name": "Root", "path": ""}]
            for i, p in enumerate(parts):
                breadcrumbs.append({"name": p, "path": "/".join(parts[:i+1])})
            return self.send_json(200, {
                "path": rel, "folders": [], "files": files, "breadcrumbs": breadcrumbs,
            })

        # ── API: stream ──────────────────────────────────────────────────────
        if pathname == "/api/stream":
            rel      = unquote(query.get("path", [""])[0])
            abs_path = safe_path(rel)
            if not abs_path or not abs_path.is_file():
                self.send_response(404); self.end_headers(); return

            mime = AUDIO_MIME.get(abs_path.suffix.lower(), "audio/mpeg")
            size = abs_path.stat().st_size
            rng  = self.headers.get("Range")

            if rng:
                rng = rng.replace("bytes=", "")
                start_s, _, end_s = rng.partition("-")
                start  = int(start_s)
                end    = int(end_s) if end_s else size - 1
                length = end - start + 1
                self.send_response(206)
                self.send_header("Content-Range",  f"bytes {start}-{end}/{size}")
                self.send_header("Accept-Ranges",  "bytes")
                self.send_header("Content-Length", length)
                self.send_header("Content-Type",   mime)
                self.end_headers()
                with open(abs_path, "rb") as f:
                    f.seek(start)
                    remaining = length
                    while remaining:
                        chunk = f.read(min(65536, remaining))
                        if not chunk: break
                        self.wfile.write(chunk)
                        remaining -= len(chunk)
            else:
                self.send_response(200)
                self.send_header("Content-Length", size)
                self.send_header("Content-Type",   mime)
                self.send_header("Accept-Ranges",  "bytes")
                self.end_headers()
                with open(abs_path, "rb") as f:
                    while chunk := f.read(65536):
                        self.wfile.write(chunk)
            return

        self.send_response(404)
        self.end_headers()
        self.wfile.write(b"Not found")

    def do_POST(self):
        pathname = urlparse(self.path).path

        # ── login ────────────────────────────────────────────────────────────
        if pathname == "/login":
            length = int(self.headers.get("Content-Length", 0))
            body   = self.rfile.read(length).decode()
            params = parse_qs(body)
            pw     = params.get("password", [""])[0]
            print(f"  [login] password match: {AUTH_PASSWORD and hmac.compare_digest(pw, AUTH_PASSWORD)}")
            if AUTH_PASSWORD and hmac.compare_digest(pw, AUTH_PASSWORD):
                body = b'<html><head><meta http-equiv="refresh" content="0;url=/"></head></html>'
                self.send_response(200)
                self.send_header("Set-Cookie",
                    f"session={SESSION_TOKEN}; Path=/; HttpOnly; SameSite=Strict")
                self.send_header("Content-Type", "text/html")
                self.send_header("Content-Length", len(body))
                self.end_headers()
                self.wfile.write(body)
            else:
                # Re-serve login page with error message injected
                html = (_HERE / "login.html").read_text(encoding="utf-8")
                html = html.replace("</form>", '<p class="err">Wrong password</p></form>', 1)
                body = _inject_config(html)
                self.send_response(200)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.send_header("Content-Length", len(body))
                self.end_headers()
                self.wfile.write(body)
            return

        # ── API: zip ─────────────────────────────────────────────────────────
        if pathname == "/api/zip":
            if not self._check_auth():
                self.send_response(403); self.end_headers(); return
            length = int(self.headers.get("Content-Length", 0))
            body   = self.rfile.read(length).decode()
            try:
                data  = json.loads(body)
                paths = data.get("paths", [])
            except Exception:
                self.send_response(400); self.end_headers(); return

            buf = io.BytesIO()
            with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
                for rel in paths:
                    abs_path = safe_path(rel)
                    if abs_path and abs_path.is_file():
                        zf.write(abs_path, abs_path.name)
            zip_bytes = buf.getvalue()

            self.send_response(200)
            self.send_header("Content-Type",        "application/zip")
            self.send_header("Content-Length",       len(zip_bytes))
            self.send_header("Content-Disposition", 'attachment; filename="tracks.zip"')
            self.end_headers()
            self.wfile.write(zip_bytes)
            return

        self.send_response(404)
        self.end_headers()


class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
    daemon_threads = True

# ── main ───────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    server = ThreadedHTTPServer(("", PORT), Handler)
    print(f"\n🎵  {SITENAME} MP3 Player  →  http://localhost:{PORT}")
    print(f"    Music root : {MUSIC_ROOT}")
    print(f"    Static dir : {_HERE}")
    print(f"    Theme      : {THEME_CSS or '(default)'}")
    if AUTH_PASSWORD:
        print(f"    Auth       : password-protected (cookie session)")
    print()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n  Bye!")
        server.shutdown()
