package handler

import (
	"encoding/json"
	"errors"
	"fmt"
	"html"
	"net/http"
	"strings"
	"time"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)

// TokensPathPrefix is the URL of the user-facing self-service token
// management page. Browser GET returns an HTML page; the page fetches
// the JSON API at TokensAPIPathPrefix.
const TokensPathPrefix = "/.tokens"

// TokensAPIPathPrefix is the URL prefix for the token JSON API.
//
//	GET    /.api/tokens          → list current user's tokens (metadata only)
//	POST   /.api/tokens          → create a new token; returns plaintext once
//	DELETE /.api/tokens/<id>     → revoke a token by short ID or full hash
const TokensAPIPathPrefix = "/.api/tokens"

// MaxTokenDescription caps how much free-form text a user can attach
// to a token. Keeps the on-disk YAML small and the HTML rendering
// simple.
const MaxTokenDescription = 200

// MaxTokensPerUser is a soft cap; refusing to create a new token at
// the limit is enough to prevent runaway accumulation. Operators who
// need more can revoke first.
const MaxTokensPerUser = 50

// tokenAPIView is the JSON shape returned to the management UI. Never
// includes the plaintext token (which is only returned on creation).
type tokenAPIView struct {
	ID          string    `json:"id"`
	Email       string    `json:"email"`
	Created     time.Time `json:"created"`
	Expires     time.Time `json:"expires,omitempty"`
	Description string    `json:"description,omitempty"`
}

// tokenCreateRequest is the body of POST /.api/tokens.
type tokenCreateRequest struct {
	Description string    `json:"description,omitempty"`
	Expires     time.Time `json:"expires,omitempty"`
}

// tokenCreateResponse is the body of a successful POST /.api/tokens.
// The plaintext Token is returned exactly once and is never derivable
// from the persisted file again.
type tokenCreateResponse struct {
	Token       string    `json:"token"`
	ID          string    `json:"id"`
	Email       string    `json:"email"`
	Created     time.Time `json:"created"`
	Expires     time.Time `json:"expires,omitempty"`
	Description string    `json:"description,omitempty"`
}

// ServeTokensAPI dispatches requests to the JSON token API. Anonymous
// requests are 401; missing token store yields 503 (token issuance is
// disabled when storage isn't reachable).
func ServeTokensAPI(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
	email := EmailFromContext(r)
	if email == "" {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if store == nil {
		http.Error(w, "Token store unavailable", http.StatusServiceUnavailable)
		return
	}

	rest := strings.TrimPrefix(r.URL.Path, TokensAPIPathPrefix)
	switch {
	case rest == "" || rest == "/":
		switch r.Method {
		case http.MethodGet:
			handleTokensList(store, email, w)
		case http.MethodPost:
			handleTokensCreate(store, email, w, r)
		default:
			w.Header().Set("Allow", "GET, POST")
			http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(rest, "/"):
		id := strings.TrimPrefix(rest, "/")
		if id == "" || strings.Contains(id, "/") {
			http.NotFound(w, r)
			return
		}
		switch r.Method {
		case http.MethodDelete:
			handleTokensRevoke(store, email, id, w)
		default:
			w.Header().Set("Allow", "DELETE")
			http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
		}
	default:
		http.NotFound(w, r)
	}
}

func handleTokensList(store *auth.Store, email string, w http.ResponseWriter) {
	list, err := store.List(email)
	if err != nil {
		http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
		return
	}
	views := make([]tokenAPIView, 0, len(list))
	for _, t := range list {
		views = append(views, tokenAPIView{
			ID:          t.ID(),
			Email:       t.Email,
			Created:     t.Created,
			Expires:     t.Expires,
			Description: t.Description,
		})
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	_ = json.NewEncoder(w).Encode(views)
}

func handleTokensCreate(store *auth.Store, email string, w http.ResponseWriter, r *http.Request) {
	defer r.Body.Close()
	var req tokenCreateRequest
	if r.ContentLength > 0 || r.Header.Get("Content-Type") != "" {
		dec := json.NewDecoder(r.Body)
		dec.DisallowUnknownFields()
		if err := dec.Decode(&req); err != nil {
			http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
			return
		}
	}

	desc := strings.TrimSpace(req.Description)
	if len(desc) > MaxTokenDescription {
		http.Error(w, fmt.Sprintf("Bad Request: description longer than %d chars", MaxTokenDescription), http.StatusBadRequest)
		return
	}
	if !req.Expires.IsZero() && req.Expires.Before(time.Now()) {
		http.Error(w, "Bad Request: expires is in the past", http.StatusBadRequest)
		return
	}

	// Soft cap to prevent runaway accumulation.
	existing, err := store.List(email)
	if err == nil && len(existing) >= MaxTokensPerUser {
		http.Error(w, fmt.Sprintf("Conflict: token cap (%d) reached; revoke an existing token first", MaxTokensPerUser), http.StatusConflict)
		return
	}

	plaintext, tok, err := store.Generate(email, desc, req.Expires)
	if err != nil {
		http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(tokenCreateResponse{
		Token:       plaintext,
		ID:          tok.ID(),
		Email:       tok.Email,
		Created:     tok.Created,
		Expires:     tok.Expires,
		Description: tok.Description,
	})
}

func handleTokensRevoke(store *auth.Store, email, id string, w http.ResponseWriter) {
	err := store.Revoke(email, id)
	if err == nil {
		w.WriteHeader(http.StatusNoContent)
		return
	}
	if errors.Is(err, auth.ErrNotFound) {
		http.NotFound(w, nil)
		return
	}
	http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
}

// ServeTokensPage renders the self-service token management HTML page.
// The page fetches /.api/tokens via JavaScript; the server-side render
// only emits a static skeleton plus the authenticated user's email so
// the page can show "you are signed in as alice@..." without a flash.
func ServeTokensPage(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet && r.Method != http.MethodHead {
		w.Header().Set("Allow", "GET, HEAD")
		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
		return
	}
	email := EmailFromContext(r)
	if email == "" {
		http.Error(w, "Unauthorized — log in via the master's auth proxy first.", http.StatusUnauthorized)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	if r.Method == http.MethodHead {
		return
	}
	storeAvailable := store != nil
	body := renderTokensPage(email, storeAvailable)
	_, _ = w.Write([]byte(body))
}

// renderTokensPage builds the HTML for the management page. Kept inline
// (no template files) to match the rest of the handler package; the
// page is small enough that string-concat is readable and there are no
// untrusted-data injection concerns beyond the `email` parameter,
// which is escaped via html.EscapeString.
func renderTokensPage(email string, storeAvailable bool) string {
	storeNote := ""
	if !storeAvailable {
		storeNote = `<p class="warn">Token store is unavailable — the server failed to initialise <code>.zddc.d/tokens/</code>. Operations on this page will return 503.</p>`
	}
	const css = `
		body { font: 14px/1.4 system-ui, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
		h1 { font-size: 1.4rem; margin-bottom: 0.2em; }
		.who { color: #666; margin-bottom: 1.5em; }
		fieldset { border: 1px solid #ccc; padding: 1em; margin-bottom: 1.5em; border-radius: 4px; }
		legend { font-weight: 600; padding: 0 0.4em; }
		label { display: block; margin: 0.5em 0; }
		label span { display: inline-block; min-width: 9em; }
		input[type=text], input[type=datetime-local] { padding: 0.3em; min-width: 18em; }
		button { padding: 0.4em 0.9em; cursor: pointer; }
		.danger { color: #b00; border-color: #b00; }
		.warn { background: #fff3cd; border: 1px solid #ffc107; padding: 0.6em; border-radius: 4px; }
		table { width: 100%; border-collapse: collapse; margin-top: 0.5em; }
		th, td { text-align: left; padding: 0.4em; border-bottom: 1px solid #eee; }
		.token-secret { font-family: ui-monospace, monospace; background: #f5f5f5; padding: 0.7em; word-break: break-all; border: 1px dashed #999; border-radius: 4px; }
		.empty { color: #888; font-style: italic; }
	`
	const js = `
		const api = "/.api/tokens";

		async function refresh() {
			const tbody = document.querySelector("#tokens tbody");
			tbody.innerHTML = "";
			const r = await fetch(api, { headers: {"Accept": "application/json"} });
			if (!r.ok) {
				tbody.innerHTML = '<tr><td colspan="4" class="empty">Could not load tokens (' + r.status + ')</td></tr>';
				return;
			}
			const list = await r.json();
			if (!list.length) {
				tbody.innerHTML = '<tr><td colspan="4" class="empty">No tokens issued yet.</td></tr>';
				return;
			}
			for (const t of list) {
				const tr = document.createElement("tr");
				const expires = t.expires && t.expires !== "0001-01-01T00:00:00Z" ? new Date(t.expires).toLocaleString() : "(never)";
				const created = new Date(t.created).toLocaleString();
				tr.innerHTML = '<td><code>' + escapeHTML(t.id) + '</code></td>'
					+ '<td>' + escapeHTML(t.description || "") + '</td>'
					+ '<td>' + escapeHTML(created) + '</td>'
					+ '<td>' + escapeHTML(expires) + '</td>'
					+ '<td><button class="danger" data-id="' + escapeHTML(t.id) + '">Revoke</button></td>';
				tbody.appendChild(tr);
			}
			tbody.querySelectorAll("button.danger").forEach(b => {
				b.addEventListener("click", () => revoke(b.dataset.id));
			});
		}

		function escapeHTML(s) {
			return String(s).replace(/[&<>"']/g, c =>
				({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
		}

		async function revoke(id) {
			if (!confirm("Revoke token " + id + "? Any clients using it will be cut off immediately.")) return;
			const r = await fetch(api + "/" + encodeURIComponent(id), { method: "DELETE" });
			if (!r.ok) {
				alert("Revoke failed: " + r.status + " " + r.statusText);
				return;
			}
			refresh();
		}

		document.getElementById("create").addEventListener("submit", async (ev) => {
			ev.preventDefault();
			const desc = document.getElementById("desc").value.trim();
			const expRaw = document.getElementById("expires").value;
			const body = {};
			if (desc) body.description = desc;
			if (expRaw) body.expires = new Date(expRaw).toISOString();
			const r = await fetch(api, {
				method: "POST",
				headers: {"Content-Type": "application/json"},
				body: JSON.stringify(body),
			});
			const out = document.getElementById("created");
			if (!r.ok) {
				out.innerHTML = '<p class="warn">Create failed: ' + r.status + ' ' + escapeHTML(await r.text()) + '</p>';
				return;
			}
			const data = await r.json();
			out.innerHTML = '<p>New token (copy now — it is shown only once):</p><div class="token-secret">' + escapeHTML(data.token) + '</div>';
			document.getElementById("desc").value = "";
			document.getElementById("expires").value = "";
			refresh();
		});

		refresh();
	`
	return `<!doctype html>
<html><head><meta charset="utf-8"><title>ZDDC tokens</title>
<style>` + css + `</style>
</head><body>
<h1>API tokens</h1>
<p class="who">Signed in as <strong>` + html.EscapeString(email) + `</strong></p>
` + storeNote + `
<fieldset>
<legend>Create a new token</legend>
<form id="create">
<label><span>Description</span><input type="text" id="desc" maxlength="200" placeholder="e.g. Field laptop"></label>
<label><span>Expires (optional)</span><input type="datetime-local" id="expires"></label>
<button type="submit">Create token</button>
</form>
<div id="created"></div>
</fieldset>
<fieldset>
<legend>Existing tokens</legend>
<table id="tokens">
<thead><tr><th>ID</th><th>Description</th><th>Created</th><th>Expires</th><th></th></tr></thead>
<tbody></tbody>
</table>
</fieldset>
<script>` + js + `</script>
</body></html>`
}
