import argparse
import html
import json
import mimetypes
import os
import random
import re
import sys
import uuid
from pathlib import Path
from string import Template

import aiosqlite
import uvicorn
from fastapi import FastAPI, Request, UploadFile
from fastapi.responses import (
    HTMLResponse,
    PlainTextResponse,
    Response,
)

PACKAGE_DIR = Path(__file__).parent


def default_data_dir() -> Path:
    if sys.platform == "win32":
        base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
    elif sys.platform == "darwin":
        base = Path.home() / "Library" / "Application Support"
    else:
        base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
    return base / "clipbox"


app = FastAPI()
DATA_DIR = Path(os.environ.get("CLIPBOX_DATA", str(default_data_dir())))
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH = os.environ.get("CLIPBOX_DB", str(DATA_DIR / "clipbox.db"))
FILES_DIR = DATA_DIR / "files"
FILES_DIR.mkdir(parents=True, exist_ok=True)

WORDS = [
    "alpha",
    "bravo",
    "charlie",
    "delta",
    "echo",
    "foxtrot",
    "golf",
    "hotel",
    "india",
    "juliet",
    "kilo",
    "lima",
    "mike",
    "november",
    "oscar",
    "papa",
    "quebec",
    "romeo",
    "sierra",
    "tango",
    "uniform",
    "victor",
    "whiskey",
    "xray",
    "yankee",
    "zulu",
]


def load_template(name: str) -> Template:
    return Template((PACKAGE_DIR / "templates" / name).read_text())


def load_static(name: str) -> str:
    return (PACKAGE_DIR / "static" / name).read_text()


INDEX_TEMPLATE = load_template("index.html")
PAGE_TEMPLATE = load_template("editor.html")
FILEBOX_TEMPLATE = load_template("filebox.html")


async def generate_name(db: aiosqlite.Connection) -> str:
    rows = await db.execute_fetchall("SELECT name FROM buffers")
    taken = {r[0] for r in rows}
    available = [w for w in WORDS if w not in taken]
    if available:
        return random.choice(available)
    for _ in range(100):
        name = random.choice(WORDS) + str(random.randint(1, 999))
        if name not in taken:
            return name
    return "buffer-" + str(random.randint(1000, 9999))


ICON_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
  <rect x="0" y="0" width="128" height="128" rx="28" fill="#1f1f1f"/>
  <rect x="44" y="12" width="40" height="18" rx="6" fill="#828282"/>
  <rect x="36" y="48" width="56" height="5" rx="2" fill="#666"/>
  <rect x="36" y="64" width="46" height="5" rx="2" fill="#666"/>
  <rect x="36" y="80" width="50" height="5" rx="2" fill="#666"/>
  <rect x="36" y="96" width="38" height="5" rx="2" fill="#666"/>
</svg>"""

MANIFEST_JSON = json.dumps(
    {
        "name": "clipbox",
        "short_name": "clipbox",
        "start_url": "/",
        "display": "standalone",
        "background_color": "#0d0d0d",
        "theme_color": "#0d0d0d",
        "icons": [{"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml"}],
    }
)


def tabs_html(active: str) -> str:
    clip_cls = ' class="active"' if active == "clipbox" else ""
    file_cls = ' class="active"' if active == "filebox" else ""
    return (
        '<nav class="tabs">'
        f'<a href="/"{clip_cls}>clipbox</a>'
        f'<a href="/files"{file_cls}>filebox</a>'
        "</nav>"
    )


# ---------------------------------------------------------------------------
# Static assets
# ---------------------------------------------------------------------------


@app.get("/icon.svg")
async def icon():
    return Response(content=ICON_SVG, media_type="image/svg+xml")


@app.get("/manifest.json")
async def manifest():
    return Response(content=MANIFEST_JSON, media_type="application/manifest+json")


@app.get("/static/{name}")
async def static_file(name: str):
    safe_name = re.sub(r"[^a-zA-Z0-9_.\-]", "", name)
    path = PACKAGE_DIR / "static" / safe_name
    if not path.exists() or not path.is_file():
        return PlainTextResponse("not found", status_code=404)
    content_type = mimetypes.guess_type(safe_name)[0] or "text/plain"
    return Response(content=path.read_text(), media_type=content_type)


# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------


async def get_db():
    db = await aiosqlite.connect(DB_PATH)
    await db.execute("PRAGMA journal_mode=WAL")
    await db.execute(
        "CREATE TABLE IF NOT EXISTS buffers ("
        "  name TEXT PRIMARY KEY,"
        "  content TEXT NOT NULL DEFAULT '',"
        "  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
        ")"
    )
    await db.execute(
        "CREATE TABLE IF NOT EXISTS files ("
        "  name TEXT PRIMARY KEY,"
        "  disk_name TEXT NOT NULL,"
        "  size INTEGER NOT NULL DEFAULT 0,"
        "  uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
        ")"
    )
    await db.commit()
    return db


def wants_html(request: Request) -> bool:
    return "text/html" in request.headers.get("accept", "")


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


# ---------------------------------------------------------------------------
# Clipbox index
# ---------------------------------------------------------------------------

HELP_TEXT = """clipbox - shared clipboard & file sharing

buffers:
  curl http://host:port/<name>                          read a buffer
  curl -X PUT -d 'text' http://host:port/<name>         write to a buffer
  curl -X PUT -d 'text' http://host:port/               write with auto-generated name
  curl -X PATCH -d 'new-name' http://host:port/<name>   rename a buffer
  curl -X DELETE http://host:port/<name>                delete a buffer
  curl http://host:port/api/buffers                     list all buffers (JSON)

files:
  curl -F 'file=@photo.jpg' http://host:port/files/     upload a file
  curl http://host:port/files/<filename>                download a file
  curl -X DELETE http://host:port/files/<filename>      delete a file
  curl http://host:port/api/files                       list all files (JSON)
"""

FILES_HELP_TEXT = """filebox - file sharing

  curl -F 'file=@photo.jpg' http://host:port/files/     upload a file
  curl http://host:port/files/<filename>                download a file
  curl -X DELETE http://host:port/files/<filename>      delete a file
  curl http://host:port/api/files                       list all files (JSON)
"""


@app.get("/")
async def index(request: Request):
    if not wants_html(request):
        return PlainTextResponse(HELP_TEXT)
    db = await get_db()
    try:
        rows = await db.execute_fetchall(
            "SELECT name, content, length(content), updated_at FROM buffers ORDER BY updated_at DESC"
        )
        if rows:
            items = []
            for name, content, size, _updated in rows:
                size_str = f"{size} chars" if size else "empty"
                esc = html.escape(name)
                content_attr = html.escape(content or "", quote=True)
                preview = (
                    html.escape((content or "").split("\n", 1)[0][:120]) or "empty"
                )
                items.append(
                    f'<li data-name="{esc}">'
                    f'<button class="del-btn" onclick="deleteBuf(\'{esc}\')"'
                    f' title="Delete buffer">&#x2715;</button>'
                    f'<a href="/{esc}"><span class="top-row"><span class="name">{esc}</span>'
                    f'<span class="meta">{size_str}</span></span>'
                    f'<span class="preview">{preview}</span></a>'
                    f'<button class="action-btn" data-content="{content_attr}" onclick="copyBuf(this)"'
                    f' title="Copy contents">&#x2398;</button></li>'
                )
            buffer_list = '<ul class="items">' + "".join(items) + "</ul>"
        else:
            buffer_list = '<p class="empty">No buffers yet. Create one above.</p>'
    finally:
        await db.close()
    return HTMLResponse(
        INDEX_TEMPLATE.substitute(
            tabs=tabs_html("clipbox"),
            buffer_list=buffer_list,
        )
    )


# ---------------------------------------------------------------------------
# Clipbox API
# ---------------------------------------------------------------------------


@app.get("/api/buffers")
async def api_list_buffers():
    db = await get_db()
    try:
        rows = await db.execute_fetchall(
            "SELECT name, content, length(content), updated_at FROM buffers ORDER BY updated_at DESC"
        )
    finally:
        await db.close()
    buffers = [
        {"name": name, "content": content, "size": size, "updated_at": updated}
        for name, content, size, updated in rows
    ]
    return Response(content=json.dumps(buffers), media_type="application/json")


@app.post("/api/create")
async def api_create_buffer(request: Request):
    body = await request.json()
    content = body.get("content", "")
    db = await get_db()
    try:
        name = await generate_name(db)
        await db.execute(
            "INSERT INTO buffers (name, content, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
            (name, content),
        )
        await db.commit()
    finally:
        await db.close()
    return Response(content=json.dumps({"name": name}), media_type="application/json")


@app.post("/api/rename")
async def api_rename_buffer(request: Request):
    body = await request.json()
    old_name = body.get("old_name", "")
    new_name = re.sub(r"[^a-zA-Z0-9_\-]", "", body.get("new_name", "").strip())
    if not old_name or not new_name or old_name == new_name:
        return Response(
            content=json.dumps({"error": "invalid"}),
            media_type="application/json",
            status_code=400,
        )
    db = await get_db()
    try:
        existing = await db.execute_fetchall(
            "SELECT 1 FROM buffers WHERE name = ?", (new_name,)
        )
        if existing:
            return Response(
                content=json.dumps({"error": "name taken"}),
                media_type="application/json",
                status_code=409,
            )
        await db.execute(
            "UPDATE buffers SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
            (new_name, old_name),
        )
        await db.commit()
    finally:
        await db.close()
    return Response(
        content=json.dumps({"name": new_name}), media_type="application/json"
    )


# ---------------------------------------------------------------------------
# Filebox
# ---------------------------------------------------------------------------


async def unique_filename(db: aiosqlite.Connection, original: str) -> str:
    rows = await db.execute_fetchall("SELECT name FROM files")
    taken = {r[0] for r in rows}
    if original not in taken:
        return original
    stem = Path(original).stem
    suffix = Path(original).suffix
    i = 2
    while True:
        candidate = f"{stem}-{i}{suffix}"
        if candidate not in taken:
            return candidate
        i += 1


@app.get("/files")
@app.get("/files/")
async def filebox_index(request: Request):
    if not wants_html(request):
        return PlainTextResponse(FILES_HELP_TEXT)
    db = await get_db()
    try:
        rows = await db.execute_fetchall(
            "SELECT name, size, uploaded_at FROM files ORDER BY uploaded_at DESC"
        )
        if rows:
            items = []
            for name, size, _uploaded in rows:
                esc = html.escape(name)
                size_str = format_size(size)
                items.append(
                    f"<li>"
                    f'<button class="del-btn" onclick="deleteFile(\'{esc}\')"'
                    f' title="Delete file">&#x2715;</button>'
                    f'<a href="/files/{esc}"><span class="top-row"><span class="name">{esc}</span>'
                    f'<span class="meta">{size_str}</span></span></a>'
                    f'<a class="action-btn" href="/files/{esc}" download title="Download">&#x2913;</a></li>'
                )
            file_list = '<ul class="items">' + "".join(items) + "</ul>"
        else:
            file_list = '<p class="empty">No files yet. Upload one above.</p>'
    finally:
        await db.close()
    return HTMLResponse(
        FILEBOX_TEMPLATE.substitute(
            tabs=tabs_html("filebox"),
            file_list=file_list,
        )
    )


@app.get("/api/files")
async def api_list_files():
    db = await get_db()
    try:
        rows = await db.execute_fetchall(
            "SELECT name, size, uploaded_at FROM files ORDER BY uploaded_at DESC"
        )
    finally:
        await db.close()
    files = [
        {"name": name, "size": size, "uploaded_at": uploaded}
        for name, size, uploaded in rows
    ]
    return Response(content=json.dumps(files), media_type="application/json")


@app.post("/files")
@app.post("/files/")
async def upload_file(file: UploadFile):
    data = await file.read()
    original_name = file.filename or "unnamed"
    db = await get_db()
    try:
        name = await unique_filename(db, original_name)
        disk_name = uuid.uuid4().hex + "_" + name
        disk_path = FILES_DIR / disk_name
        disk_path.write_bytes(data)
        await db.execute(
            "INSERT INTO files (name, disk_name, size, uploaded_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)",
            (name, disk_name, len(data)),
        )
        await db.commit()
    finally:
        await db.close()
    if "text/html" in str(file.content_type or ""):
        return Response(status_code=303, headers={"Location": "/files"})
    return PlainTextResponse(f"uploaded: {name} ({format_size(len(data))})\n")


@app.get("/files/{name}")
async def download_file(name: str):
    db = await get_db()
    try:
        row = await db.execute_fetchall(
            "SELECT disk_name FROM files WHERE name = ?", (name,)
        )
    finally:
        await db.close()
    if not row:
        return PlainTextResponse("not found\n", status_code=404)
    disk_path = FILES_DIR / row[0][0]
    if not disk_path.exists():
        return PlainTextResponse("file missing from disk\n", status_code=404)
    data = disk_path.read_bytes()
    content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
    inline_types = (
        "image/",
        "application/pdf",
        "text/",
        "audio/",
        "video/",
    )
    if any(content_type.startswith(t) for t in inline_types):
        disposition = f'inline; filename="{name}"'
    else:
        disposition = f'attachment; filename="{name}"'
    return Response(
        content=data,
        media_type=content_type,
        headers={"Content-Disposition": disposition},
    )


@app.delete("/files/{name}")
async def delete_file(name: str):
    db = await get_db()
    try:
        row = await db.execute_fetchall(
            "SELECT disk_name FROM files WHERE name = ?", (name,)
        )
        if row:
            disk_path = FILES_DIR / row[0][0]
            if disk_path.exists():
                disk_path.unlink()
            await db.execute("DELETE FROM files WHERE name = ?", (name,))
            await db.commit()
    finally:
        await db.close()
    return PlainTextResponse("ok")


# ---------------------------------------------------------------------------
# Buffer catch-all routes (must be after /files to avoid shadowing)
# ---------------------------------------------------------------------------


@app.get("/{name}")
async def get_buffer(name: str, request: Request):
    db = await get_db()
    try:
        row = await db.execute_fetchall(
            "SELECT content FROM buffers WHERE name = ?", (name,)
        )
    finally:
        await db.close()
    content = row[0][0] if row else ""

    if not wants_html(request):
        return PlainTextResponse(content)

    return HTMLResponse(
        PAGE_TEMPLATE.substitute(
            tabs=tabs_html("clipbox"),
            name_esc=html.escape(name),
            content_esc=html.escape(content),
            name_json=json.dumps(name),
        )
    )


@app.put("/")
async def put_buffer_auto(request: Request):
    content = (await request.body()).decode()
    db = await get_db()
    try:
        name = await generate_name(db)
        await db.execute(
            "INSERT INTO buffers (name, content, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
            (name, content),
        )
        await db.commit()
    finally:
        await db.close()
    return PlainTextResponse(f"created: {name}\n")


@app.put("/{name}")
async def put_buffer(name: str, request: Request):
    content = (await request.body()).decode()
    db = await get_db()
    try:
        await db.execute(
            "INSERT INTO buffers (name, content, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) "
            "ON CONFLICT(name) DO UPDATE SET content=excluded.content, updated_at=excluded.updated_at",
            (name, content),
        )
        await db.commit()
    finally:
        await db.close()
    return PlainTextResponse("ok")


@app.patch("/{name}")
async def patch_buffer(name: str, request: Request):
    new_name = (await request.body()).decode().strip()
    new_name = re.sub(r"[^a-zA-Z0-9_\-]", "", new_name)
    if not new_name or new_name == name:
        return PlainTextResponse("invalid name\n", status_code=400)
    db = await get_db()
    try:
        existing = await db.execute_fetchall(
            "SELECT 1 FROM buffers WHERE name = ?", (new_name,)
        )
        if existing:
            return PlainTextResponse("name taken\n", status_code=409)
        await db.execute(
            "UPDATE buffers SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
            (new_name, name),
        )
        await db.commit()
    finally:
        await db.close()
    return PlainTextResponse(f"renamed: {name} -> {new_name}\n")


@app.delete("/{name}")
async def delete_buffer(name: str):
    db = await get_db()
    try:
        await db.execute("DELETE FROM buffers WHERE name = ?", (name,))
        await db.commit()
    finally:
        await db.close()
    return PlainTextResponse("ok")


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def main():
    parser = argparse.ArgumentParser(description="clipbox - shared clipboard")
    parser.add_argument("-p", "--port", type=int, default=8080)
    parser.add_argument("--host", default="0.0.0.0")
    args = parser.parse_args()
    print(f"clipbox running on http://{args.host}:{args.port}")
    uvicorn.run(app, host=args.host, port=args.port, log_level="warning")


if __name__ == "__main__":
    main()
