#!/usr/bin/env python3
"""links.nikolai-linschmann.de — Simple file sharing server.

Run: python3 server.py
- Upload via POST /upload (multipart/form-data)
- Download via GET /d/<id>
- List via GET / (HTML page)
- Delete JSON via POST /delete
"""

import http.server
import json
import os
import hashlib
import mimetypes
import html
import urllib.parse
import uuid
import shutil
import time
import socket
from pathlib import Path
from io import BytesIO

DATA_DIR = Path(os.environ.get("LINKS_DATA_DIR", "/var/lib/links/data"))
LINKS_FILE = Path(os.environ.get("LINKS_LINKS_FILE", "/var/lib/links/links.json"))
BIND_HOST = os.environ.get("LINKS_HOST", "127.0.0.1")
BIND_PORT = int(os.environ.get("LINKS_PORT", "8765"))
MAX_UPLOAD = 200 * 1024 * 1024  # 200 MB


def load_links():
    if LINKS_FILE.exists():
        with open(LINKS_FILE) as f:
            return json.load(f)
    return []


def save_links(links):
    LINKS_FILE.parent.mkdir(parents=True, exist_ok=True)
    with open(LINKS_FILE, "w") as f:
        json.dump(links, f, indent=2)


def generate_id():
    return uuid.uuid4().hex[:12]


def parse_multipart(body, content_type):
    """Parse multipart/form-data without cgi module."""
    boundary = None
    for part in content_type.split(";"):
        part = part.strip()
        if part.startswith("boundary="):
            boundary = part[9:].strip('"').strip("'")
            break
    if not boundary:
        return {}

    boundary = boundary.encode("utf-8")
    delimiter = b"--" + boundary
    delimiter_end = b"--" + boundary + b"--"

    parts = {}
    # Split by boundary
    chunks = body.split(delimiter)

    for chunk in chunks:
        # Skip empty, opening, closing
        if not chunk or chunk.strip() == b"--" or chunk == b"--\r\n":
            continue

        # Split headers from body at first \r\n\r\n
        try:
            header_end = chunk.index(b"\r\n\r\n")
            header_bytes = chunk[:header_end]
            content_bytes = chunk[header_end + 4:]
        except ValueError:
            continue

        # Remove trailing \r\n
        if content_bytes.endswith(b"\r\n"):
            content_bytes = content_bytes[:-2]

        # Parse headers
        headers = {}
        for line in header_bytes.decode("utf-8", errors="replace").split("\r\n"):
            if ":" in line:
                k, v = line.split(":", 1)
                headers[k.strip().lower()] = v.strip()

        disposition = headers.get("content-disposition", "")
        name = None
        filename = None
        for dp in disposition.split(";"):
            dp = dp.strip()
            if dp.startswith("name="):
                name = dp[5:].strip('"').strip("'")
            elif dp.startswith("filename="):
                filename = dp[9:].strip('"').strip("'")

        if name:
            parts[name] = {
                "value": content_bytes,
                "filename": filename,
                "headers": headers,
            }

    return parts


def format_size(n):
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024:
            return f"{n:.0f} {unit}" if n == int(n) else f"{n:.1f} {unit}"
        n /= 1024
    return f"{n:.1f} TB"


PAGE_HEAD = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>links.nikolai-linschmann.de</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #0d1117; color: #c9d1d9; min-height: 100vh;
}
header {
  background: #161b22; border-bottom: 1px solid #30363d; padding: 16px 24px;
  display: flex; align-items: center; justify-content: space-between;
}
header h1 { font-size: 1.25rem; font-weight: 600; }
header h1 a { color: #58a6ff; text-decoration: none; }
header h1 a:hover { text-decoration: underline; }
main { max-width: 800px; margin: 0 auto; padding: 24px; }
.card {
  background: #161b22; border: 1px solid #30363d; border-radius: 8px;
  padding: 20px; margin-bottom: 16px;
}
.card h2 { font-size: 1.1rem; margin-bottom: 12px; color: #f0f6fc; }
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 0.875rem; margin-bottom: 4px; color: #8b949e; }
input[type="file"] {
  width: 100%; padding: 8px; background: #0d1117; border: 1px solid #30363d;
  border-radius: 6px; color: #c9d1d9; font-size: 0.875rem;
}
.btn {
  display: inline-block; padding: 8px 20px; border: none; border-radius: 6px;
  font-size: 0.875rem; font-weight: 500; cursor: pointer; text-decoration: none;
}
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-danger { background: #da3633; color: #fff; }
.btn-danger:hover { background: #f85149; }
.msg { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 0.875rem; }
.msg-success { background: #1a3a1c; border: 1px solid #238636; color: #7ee787; }
.msg-error { background: #3d1414; border: 1px solid #da3633; color: #ffa198; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #21262d; font-size: 0.875rem; }
th { color: #8b949e; font-weight: 600; }
td a { color: #58a6ff; text-decoration: none; }
td a:hover { text-decoration: underline; }
.filename { word-break: break-all; max-width: 240px; }
.size { color: #8b949e; white-space: nowrap; }
.time { color: #8b949e; white-space: nowrap; font-size: 0.75rem; }
.delete-btn {
  background: none; border: 1px solid #30363d; color: #f85149; cursor: pointer;
  padding: 2px 8px; border-radius: 4px; font-size: 0.75rem;
}
.delete-btn:hover { background: #da3633; color: #fff; }
.empty { text-align: center; padding: 40px 20px; color: #8b949e; }
.progress-wrap { display: none; margin-top: 12px; }
.progress-bar {
  width: 100%; height: 8px; background: #21262d; border-radius: 4px; overflow: hidden;
}
.progress-fill {
  height: 100%; width: 0%; background: #238636; border-radius: 4px;
  transition: width .3s;
}
.progress-text { font-size: 0.75rem; color: #8b949e; margin-top: 4px; text-align: center; }
.copy-btn {
  background: none; border: 1px solid #30363d; color: #c9d1d9; cursor: pointer;
  padding: 2px 8px; border-radius: 4px; font-size: 0.75rem;
}
.copy-btn:hover { background: #30363d; }
footer { text-align: center; padding: 24px; color: #484f58; font-size: 0.75rem; }
</style>
</head>
<body>
"""

PAGE_FOOT = """
<script>
function copyLink(btn, url) {
  navigator.clipboard.writeText(url).then(() => {
    const orig = btn.textContent;
    btn.textContent = '✓ Copied!';
    setTimeout(() => btn.textContent = orig, 1500);
  });
}
function deleteFile(id, btn) {
  if (!confirm('Delete this file?')) return;
  btn.textContent = '...';
  fetch('/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id}) })
    .then(r => r.json()).then(d => {
      if (d.ok) { btn.closest('tr').remove(); }
      else { alert(d.error); btn.textContent = 'Delete'; }
    }).catch(() => { alert('Error'); btn.textContent = 'Delete'; });
}
document.getElementById('upload-form')?.addEventListener('submit', function(e) {
  const btn = this.querySelector('button[type="submit"]');
  const pw = document.getElementById('progress-wrap');
  const pf = document.getElementById('progress-fill');
  const pt = document.getElementById('progress-text');
  btn.disabled = true; btn.textContent = 'Uploading...';
  pw.style.display = 'block';
  const xhr = new XMLHttpRequest();
  xhr.upload.onprogress = function(ev) {
    if (ev.lengthComputable) {
      const pct = Math.round(ev.loaded / ev.total * 100);
      pf.style.width = pct + '%';
      pt.textContent = pct + '%';
    }
  };
  xhr.onload = function() {
    pf.style.width = '100%';
    pt.textContent = 'Done!';
    if (xhr.status === 200) { location.reload(); }
    else {
      try {
        const d = JSON.parse(xhr.responseText);
        alert(d.error || 'Upload failed');
      } catch { alert('Upload failed'); }
      btn.disabled = false; btn.textContent = 'Upload';
      pw.style.display = 'none';
    }
  };
  xhr.onerror = function() {
    alert('Upload failed - network error'); btn.disabled = false; btn.textContent = 'Upload';
    pw.style.display = 'none';
  };
  xhr.open('POST', '/upload', true);
  xhr.send(new FormData(this));
  e.preventDefault();
});
</script>
</body>
</html>"""


def render_list():
    links = load_links()
    rows = ""
    if not links:
        rows = '<tr><td colspan="4" class="empty">No files uploaded yet.</td></tr>'
    else:
        for item in reversed(links):
            fid = item["id"]
            fn = html.escape(item["filename"])
            sz = format_size(item["size"])
            ts = item.get("time", "")
            link = f"https://links.nikolai-linschmann.de/d/{fid}"
            # Format time nicely
            try:
                t = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
            except:
                t = ts
            rows += (
                f'<tr>'
                f'<td class="filename"><a href="/d/{fid}" download>{fn}</a></td>'
                f'<td class="size">{sz}</td>'
                f'<td class="time">{t}</td>'
                f'<td>'
                f'<button class="copy-btn" onclick="copyLink(this,\'{link}\')">Copy link</button> '
                f'<button class="delete-btn" onclick="deleteFile(\'{fid}\',this)">Delete</button>'
                f'</td>'
                f'</tr>'
            )

    body = f"""
<header><h1><a href="/">links.nikolai-linschmann.de</a></h1></header>
<main>
<div class="card">
  <h2>Upload File</h2>
  <form id="upload-form" method="post" action="/upload" enctype="multipart/form-data">
    <div class="form-group"><input type="file" name="file" required></div>
    <button type="submit" class="btn btn-primary">Upload</button>
  </form>
  <div id="progress-wrap" class="progress-wrap">
    <div class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
    <div id="progress-text" class="progress-text">0%</div>
  </div>
</div>
<div class="card">
  <h2>Files</h2>
  <table><thead><tr><th>Filename</th><th>Size</th><th>Uploaded</th><th>Actions</th></tr></thead><tbody>{rows}</tbody></table>
</div>
</main>
<footer>links.nikolai-linschmann.de</footer>
"""
    return PAGE_HEAD + body + PAGE_FOOT


class FileSharingHandler(http.server.BaseHTTPRequestHandler):

    def log_message(self, fmt, *args):
        print(f"[links] {self.client_address[0]} - {fmt % args}")

    def _send_json(self, data, status=200):
        body = json.dumps(data).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

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

    def _send_error(self, msg, status=400):
        self._send_json({"ok": False, "error": msg}, status)

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        if path == "/" or path == "/index.html":
            self._send_html(render_list())
        elif path.startswith("/d/"):
            fid = path[3:]
            links = load_links()
            item = None
            for l in links:
                if l["id"] == fid:
                    item = l
                    break
            if not item:
                self._send_error("File not found", 404)
                return
            filepath = DATA_DIR / item["filename"]
            if not filepath.exists():
                self._send_error("File not found on disk", 404)
                return
            mime, _ = mimetypes.guess_type(item["filename"])
            if mime is None:
                mime = "application/octet-stream"
            size = filepath.stat().st_size
            self.send_response(200)
            self.send_header("Content-Type", mime)
            self.send_header("Content-Disposition", f'attachment; filename="{item["filename"]}"')
            self.send_header("Content-Length", str(size))
            self.end_headers()
            with open(filepath, "rb") as f:
                shutil.copyfileobj(f, self.wfile)
        else:
            self._send_error("Not found", 404)

    def do_POST(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        if path == "/upload":
            ctype = self.headers.get("Content-Type", "")
            c_len = int(self.headers.get("Content-Length", "0"))

            if c_len > MAX_UPLOAD:
                self._send_error(f"File too large (max {format_size(MAX_UPLOAD)})", 413)
                return

            body = self.rfile.read(c_len)
            parts = parse_multipart(body, ctype)

            if "file" not in parts:
                self._send_error("No file uploaded")
                return

            file_part = parts["file"]
            content = file_part["value"]
            filename = file_part.get("filename") or "unnamed"
            # Sanitize filename
            filename = os.path.basename(filename)
            if not filename:
                filename = "unnamed"

            # Generate unique ID and store
            fid = generate_id()

            DATA_DIR.mkdir(parents=True, exist_ok=True)
            filepath = DATA_DIR / (fid + "_" + filename)
            with open(filepath, "wb") as f:
                f.write(content)

            links = load_links()
            links.append({
                "id": fid,
                "filename": filename,
                "size": len(content),
                "time": time.time(),
                "stored_as": filepath.name,
            })
            save_links(links)

            self._send_json({
                "ok": True,
                "id": fid,
                "url": f"https://links.nikolai-linschmann.de/d/{fid}",
                "filename": filename,
            })

        elif path == "/delete":
            c_len = int(self.headers.get("Content-Length", "0"))
            body = self.rfile.read(c_len)
            try:
                data = json.loads(body)
            except json.JSONDecodeError:
                self._send_error("Invalid JSON")
                return

            fid = data.get("id")
            if not fid:
                self._send_error("No id provided")
                return

            links = load_links()
            new_links = [l for l in links if l["id"] != fid]
            removed = [l for l in links if l["id"] == fid]

            if not removed:
                self._send_error("File not found", 404)
                return

            # Delete from disk
            for item in removed:
                filepath = DATA_DIR / item.get("stored_as", item["filename"])
                if filepath.exists():
                    filepath.unlink()

            save_links(new_links)
            self._send_json({"ok": True})

        else:
            self._send_error("Not found", 404)


def main():
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    LINKS_FILE.parent.mkdir(parents=True, exist_ok=True)

    server = http.server.HTTPServer((BIND_HOST, BIND_PORT), FileSharingHandler)
    print(f"[links] Starting on {BIND_HOST}:{BIND_PORT}")
    print(f"[links] Data dir: {DATA_DIR}")
    print(f"[links] Links file: {LINKS_FILE}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n[links] Shutting down")
        server.server_close()


if __name__ == "__main__":
    main()
