"""Tiny HTTP file share — browse + upload + download a single directory tree.

Endpoints
---------
GET  /                       index of shared root (HTML)
GET  /browse?path=...        sub-folder (HTML)
GET  /raw?path=...           download a file as-is
POST /upload?path=...        multipart upload (file=@...)
DELETE /delete?path=...      remove a file or empty directory
POST /mkdir?path=...         create a directory

Auth
----
No token required — all paths are publicly readable. Uploads / mkdir / delete
still go through if a token is set, but downloads don't require one.

Run
---
./venv/bin/python file_server.py --root /root/files --port 8789
"""
from __future__ import annotations

import argparse
import html
import mimetypes
import os
import shutil
from datetime import datetime
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, Header, HTTPException, Request, UploadFile, File, Form
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse

SHARE_ROOT = Path(os.environ.get("SHARE_ROOT", "/root/files")).resolve()
SHARE_TOKEN = os.environ.get("SHARE_ROOT_TOKEN", "")  # optional; only for write ops if set
MAX_UPLOAD_MB = int(os.environ.get("SHARE_MAX_UPLOAD_MB", "512"))
BIND_HOST = os.environ.get("SHARE_BIND_HOST", "127.0.0.1")
BIND_PORT = int(os.environ.get("SHARE_BIND_PORT", "8789"))

app = FastAPI(title="sharekit-fileserver", version="0.1.0")


def _is_token_ok(authorization: Optional[str]) -> bool:
    """Write-token gate.

    If SHARE_TOKEN is unset (empty), no token is required and anyone can write.
    If SHARE_TOKEN is set, the Authorization header MUST match it exactly.
    """
    if not SHARE_TOKEN:
        return True
    if not authorization or not authorization.lower().startswith("bearer "):
        return False
    return authorization[7:].strip() == SHARE_TOKEN


def _require_write_token(authorization: Optional[str]) -> None:
    if not _is_token_ok(authorization):
        raise HTTPException(status_code=401, detail="bad or missing write token")


def safe_join(rel: str) -> Path:
    """Resolve a relative path under SHARE_ROOT, refusing .. escapes."""
    p = (SHARE_ROOT / rel).resolve()
    if SHARE_ROOT not in p.parents and p != SHARE_ROOT:
        raise HTTPException(status_code=400, detail="path escapes share root")
    return p


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


def render_browse(p: Path, rel: str) -> str:
    rows = []
    # parent link
    if p != SHARE_ROOT:
        parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
        rows.append(
            f'<tr><td><a href="/browse?path={html.escape(parent)}">📁 ..</a></td>'
            f'<td>—</td><td>—</td><td>—</td><td>—</td></tr>'
        )
    try:
        entries = sorted(p.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
    except OSError as e:
        return f"<p>error: {e}</p>"
    for e in entries:
        if e.name.startswith("."):
            continue
        rel_child = f"{rel}/{e.name}" if rel else e.name
        try:
            st = e.stat()
            size = fmt_size(st.st_size) if e.is_file() else "—"
            mtime = datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M")
        except OSError:
            size, mtime = "—", "—"
        if e.is_dir():
            link = f'/browse?path={html.escape(rel_child)}'
            row = (
                f'<tr><td><a href="{link}">📁 {html.escape(e.name)}/</a></td>'
                f'<td>—</td><td>—</td><td>{mtime}</td>'
                f'<td><form method="post" action="/mkdir?path={html.escape(rel_child)}/new" '
                f'style="display:inline"></form></td></tr>'
            )
            rows.append(row)
        else:
            dl = f"/raw?path={html.escape(rel_child)}"
            row = (
                f'<tr><td>📄 <a href="{dl}">{html.escape(e.name)}</a></td>'
                f'<td>{size}</td><td>{st.st_size}</td><td>{mtime}</td>'
                f'<td><form method="post" action="/delete?path={html.escape(rel_child)}" '
                f'style="display:inline" onsubmit="return confirm(\'delete {html.escape(e.name)}?\')">'
                f'<button type="submit">delete</button></form></td></tr>'
            )
            rows.append(row)
    breadcrumb = f'<a href="/browse?path=">share root</a>'
    if rel:
        parts = []
        acc = ""
        for seg in rel.split("/"):
            acc = f"{acc}/{seg}" if acc else seg
            parts.append(f'<a href="/browse?path={html.escape(acc)}">{html.escape(seg)}</a>')
        breadcrumb += " / " + " / ".join(parts)
    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>sharekit: {html.escape(rel) or '/'}</title>
<style>
  body {{ font: 14px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; max-width: 960px; margin: 24px auto; color: #e6edf3; background: #0d1117; padding: 0 16px; }}
  h1 a {{ color: #58a6ff; text-decoration: none; }}
  table {{ width: 100%; border-collapse: collapse; }}
  th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #30363d; }}
  th {{ color: #8b949e; font-weight: 500; }}
  td {{ font-family: ui-monospace, monospace; font-size: 13px; }}
  a {{ color: #58a6ff; }}
  form.inline {{ display: inline; }}
  input[type=text] {{ background: #0d1117; color: #e6edf3; border: 1px solid #30363d; padding: 4px 8px; border-radius: 4px; }}
  button {{ background: #21262d; color: #e6edf3; border: 1px solid #30363d; padding: 4px 10px; border-radius: 4px; cursor: pointer; }}
  button.danger {{ color: #f85149; }}
  .upload {{ background: #161b22; border: 1px solid #30363d; padding: 16px; border-radius: 8px; margin: 16px 0; }}
</style>
</head>
<body>
<h1>📁 {breadcrumb}</h1>

<div class="upload">
  <h3>Upload to current folder ({html.escape(rel) or '/'})</h3>
  <form method="post" action="/upload?path={html.escape(rel)}" enctype="multipart/form-data">
    <input type="file" name="file" required>
    <input type="submit" value="upload">
  </form>
  <h3>Make subdirectory</h3>
  <form method="post" action="/mkdir?path={html.escape(rel)}">
    <input type="text" name="name" placeholder="new-folder-name" required>
    <input type="submit" value="mkdir">
  </form>
</div>

<table>
<tr><th>name</th><th>size</th><th>bytes</th><th>modified</th><th></th></tr>
{''.join(rows)}
</table>

<p style="color:#8b949e;font-size:11px;margin-top:32px">
  root: <code>{html.escape(str(SHARE_ROOT))}</code>
</p>
</body>
</html>
"""


@app.get("/", response_class=HTMLResponse)
async def index() -> str:
    return render_browse(SHARE_ROOT, "")


@app.get("/browse", response_class=HTMLResponse)
async def browse(path: str = "") -> str:
    p = safe_join(path)
    if not p.exists():
        raise HTTPException(404, "no such path")
    if not p.is_dir():
        return PlainTextResponse(safe_join("..") and "is not a directory", status_code=400)
    return render_browse(p, path)


@app.get("/raw")
async def raw(path: str):
    p = safe_join(path)
    if not p.is_file():
        raise HTTPException(404, "no such file")
    return FileResponse(p, filename=p.name)


@app.post("/upload")
async def upload(
    path: str = Form(""),
    file: UploadFile = File(...),
    authorization: Optional[str] = Header(None),
):
    _require_write_token(authorization)
    dest_dir = safe_join(path)
    if not dest_dir.exists() or not dest_dir.is_dir():
        dest_dir.mkdir(parents=True, exist_ok=True)
    # Avoid path-traversal inside file.filename
    fname = Path(file.filename or "upload").name
    dest = dest_dir / fname
    if dest.exists():
        # Don't silently overwrite — refuse.
        raise HTTPException(409, f"file exists: {dest.relative_to(SHARE_ROOT)}")
    written = 0
    with dest.open("wb") as fh:
        while True:
            chunk = await file.read(1024 * 1024)
            if not chunk:
                break
            written += len(chunk)
            if written > MAX_UPLOAD_MB * 1024 * 1024:
                fh.close()
                dest.unlink(missing_ok=True)
                raise HTTPException(413, f"file exceeds {MAX_UPLOAD_MB} MB")
            fh.write(chunk)
    return JSONResponse({"ok": True, "path": str(dest.relative_to(SHARE_ROOT)), "bytes": written})


@app.post("/delete")
async def delete(path: str, authorization: Optional[str] = Header(None)):
    _require_write_token(authorization)
    p = safe_join(path)
    if p == SHARE_ROOT:
        raise HTTPException(400, "refusing to delete share root")
    if not p.exists():
        raise HTTPException(404, "no such path")
    if p.is_dir():
        # Empty directory only — refuse to recursively nuke a full tree by accident.
        if any(p.iterdir()):
            raise HTTPException(409, "directory not empty; remove contents first")
        p.rmdir()
    else:
        p.unlink()
    return JSONResponse({"ok": True})


@app.post("/mkdir")
async def mkdir(
    path: str = Form(""),
    name: str = Form(...),
    authorization: Optional[str] = Header(None),
):
    _require_write_token(authorization)
    parent = safe_join(path)
    if not parent.exists() or not parent.is_dir():
        parent.mkdir(parents=True, exist_ok=True)
    safe_name = Path(name).name  # disallow path separators
    target = parent / safe_name
    if target.exists():
        raise HTTPException(409, f"path exists: {target.relative_to(SHARE_ROOT)}")
    target.mkdir(parents=False)
    return JSONResponse({"ok": True, "path": str(target.relative_to(SHARE_ROOT))})


@app.get("/health")
async def health() -> dict:
    return {"status": "ok", "root": str(SHARE_ROOT), "write_protected": bool(SHARE_TOKEN)}


def main() -> None:
    global SHARE_ROOT
    parser = argparse.ArgumentParser()
    parser.add_argument("--root", default=str(SHARE_ROOT))
    parser.add_argument("--host", default=BIND_HOST)
    parser.add_argument("--port", type=int, default=BIND_PORT)
    args = parser.parse_args()
    SHARE_ROOT = Path(args.root).resolve()
    SHARE_ROOT.mkdir(parents=True, exist_ok=True)
    print(f"sharekit file server serving {SHARE_ROOT} on {args.host}:{args.port}")
    import uvicorn
    uvicorn.run("file_server:app", host=args.host, port=args.port, reload=False)


if __name__ == "__main__":
    main()
