package api

import (
	"errors"
	"net/http"
	"time"

	"github.com/go-chi/chi/v5"

	"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
	"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// errNoCurrentSession means the request authenticated but the middleware
// didn't record which session did it — which should be impossible on a route
// behind RequireUser. It matters because "log out everywhere else" is defined
// by exclusion: without knowing which session is ours, the safe-looking
// action would sign the caller out too.
var errNoCurrentSession = errors.New("no session id in request context")

// sessionResp is one row of the active-sessions list.
//
// token_hash is absent, and that is the point of storing only a hash: it
// never leaves the database, so this surface can list sessions without
// handing out anything that could be replayed.
type sessionResp struct {
	ID        string `json:"id"`
	UserAgent string `json:"user_agent"`
	// CreatedIP is frozen at issue time; LastIP moves with the session. The
	// pair is what makes a stolen token legible — same device string, but an
	// address the user doesn't recognise.
	CreatedIP  string    `json:"created_ip"`
	LastIP     string    `json:"last_ip"`
	CreatedAt  time.Time `json:"created_at"`
	LastSeenAt time.Time `json:"last_seen_at"`
	// Current marks the session making this request so the UI can label it
	// and not offer a "log out" that signs the user out of the page they're
	// standing on.
	Current bool `json:"current"`
}

type revokedResp struct {
	Revoked int `json:"revoked"`
}

// handleListMySessions implements GET /api/me/sessions.
func (h *handlers) handleListMySessions(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	// Absent id is tolerated here (unlike logout-others): the list still
	// renders, it just won't flag a current row.
	currentID, _ := auth.SessionIDFromContext(r.Context())

	rows, err := dbq.New(h.pool).ListSessionsForUser(r.Context(), user.ID)
	if err != nil {
		h.logger.Error("list sessions: query failed", "err", err)
		writeErr(w, apierror.Internal(err))
		return
	}
	out := make([]sessionResp, 0, len(rows))
	for _, s := range rows {
		out = append(out, sessionResp{
			ID:         uuidToString(s.ID),
			UserAgent:  s.UserAgent,
			CreatedIP:  s.CreatedIp,
			LastIP:     s.LastIp,
			CreatedAt:  s.CreatedAt.Time,
			LastSeenAt: s.LastSeenAt.Time,
			Current:    s.ID == currentID,
		})
	}
	writeJSON(w, http.StatusOK, out)
}

// handleRevokeMySession implements DELETE /api/me/sessions/{id}.
func (h *handlers) handleRevokeMySession(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	id, ok := parseUUID(chi.URLParam(r, "id"))
	if !ok {
		// Malformed and belongs-to-someone-else collapse to one answer on
		// purpose: a distinguishable response would let a caller probe
		// whether another user's session id exists.
		writeErr(w, apierror.NotFound("session"))
		return
	}
	n, err := dbq.New(h.pool).DeleteSessionForUser(r.Context(), dbq.DeleteSessionForUserParams{
		ID:     id,
		UserID: user.ID,
	})
	if err != nil {
		h.logger.Error("revoke session: delete failed", "err", err)
		writeErr(w, apierror.Internal(err))
		return
	}
	if n == 0 {
		writeErr(w, apierror.NotFound("session"))
		return
	}
	audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevoke, nil)
	w.WriteHeader(http.StatusNoContent)
}

// handleRevokeMyOtherSessions implements POST /api/me/sessions/logout-others.
func (h *handlers) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	currentID, ok := auth.SessionIDFromContext(r.Context())
	if !ok {
		// Refuse rather than guess: deleting "all but unknown" is deleting
		// all, which would log the caller out of the page they invoked this
		// from and look exactly like the attack they were defending against.
		h.logger.Error("revoke other sessions: no session id in context")
		writeErr(w, apierror.Internal(errNoCurrentSession))
		return
	}
	n, err := dbq.New(h.pool).DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{
		UserID: user.ID,
		ID:     currentID,
	})
	if err != nil {
		h.logger.Error("revoke other sessions: delete failed", "err", err)
		writeErr(w, apierror.Internal(err))
		return
	}
	audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevokeOthers, nil)
	writeJSON(w, http.StatusOK, revokedResp{Revoked: int(n)})
}
