package api

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"time"

	"drivervault/apiserver/internal/models"
)

// deletionCooldown is how long an account-deletion request sits before it can
// be finalized, giving the user a window to change their mind.
const deletionCooldown = 3 * 24 * time.Hour

// userRecord is the PocketBase-facing shape of a user (mixes PocketBase's own
// camelCase system fields with our snake_case custom ones).
type userRecord struct {
	ID                  string `json:"id"`
	Email               string `json:"email"`
	Verified            bool   `json:"verified"`
	Name                string `json:"name"`
	Avatar              string `json:"avatar"`
	Bio                 string `json:"bio"`
	Theme               string `json:"theme"`
	Locale              string `json:"locale"`
	DateFormat          string `json:"date_format"`
	TimeFormat          string `json:"time_format"`
	Currency            string `json:"currency"`
	FontSize            string `json:"font_size"`
	DragLocked          bool   `json:"drag_locked"`
	DeletionRequestedAt string `json:"deletion_requested_at"`
	Role                string `json:"role"`
	Organization        string `json:"organization"`
	Created             string `json:"created"`

	// Garage arrangement. Raw because PocketBase hands back whatever a json field
	// holds — null on a record that has never been arranged, and "" on one
	// PocketBase stored as an empty value — neither of which is a []string.
	CarOrder json.RawMessage `json:"car_order"`
	// The charging page's tab arrangement, stored the same way and for the same
	// reason.
	ChargerTabOrder  json.RawMessage `json:"charger_tab_order"`
	ChargerCardOrder json.RawMessage `json:"charger_card_order"`
	// Which tab each tabbed page opens on. Raw for the same reason as the
	// arrangements: never-set records come back as null or "".
	DefaultTabs json.RawMessage `json:"default_tabs"`
}

// carOrder decodes the stored garage arrangement, treating anything unexpected
// as "not arranged yet" rather than failing the whole profile read.
func (rec userRecord) carOrder() []string { return decodeStringList(rec.CarOrder) }

func (rec userRecord) chargerTabOrder() []string { return decodeStringList(rec.ChargerTabOrder) }

func (rec userRecord) chargerCardOrder() []string { return decodeStringList(rec.ChargerCardOrder) }

func (rec userRecord) defaultTabs() map[string]string { return decodeStringMap(rec.DefaultTabs) }

// decodeStringList reads a PocketBase json field that holds a list of strings,
// treating anything unexpected as empty rather than failing the whole read.
func decodeStringList(raw json.RawMessage) []string {
	if len(raw) == 0 {
		return nil
	}
	var out []string
	if err := json.Unmarshal(raw, &out); err != nil {
		return nil
	}
	return out
}

// decodeStringMap is decodeStringList for a json field holding a flat
// string->string map, and is just as forgiving about what it finds.
func decodeStringMap(raw json.RawMessage) map[string]string {
	if len(raw) == 0 {
		return nil
	}
	var out map[string]string
	if err := json.Unmarshal(raw, &out); err != nil {
		return nil
	}
	return out
}

func (rec userRecord) toModel() models.User {
	u := models.User{
		ID:         rec.ID,
		Email:      rec.Email,
		Verified:   rec.Verified,
		Name:       rec.Name,
		Bio:        rec.Bio,
		HasAvatar:  rec.Avatar != "",
		Theme:      orDefault(rec.Theme, "system"),
		Locale:     orDefault(rec.Locale, "en-US"),
		DateFormat: orDefault(rec.DateFormat, "YMD"),
		TimeFormat: orDefault(rec.TimeFormat, "auto"),
		Currency:   orDefault(rec.Currency, "USD"),
		FontSize:   orDefault(rec.FontSize, "medium"),
		DragLocked: rec.DragLocked,
		Role:       orDefault(rec.Role, "user"),
		Created:    rec.Created,

		Organization:     rec.Organization,
		CarOrder:         rec.carOrder(),
		ChargerTabOrder:  rec.chargerTabOrder(),
		ChargerCardOrder: rec.chargerCardOrder(),
		DefaultTabs:      rec.defaultTabs(),
	}
	if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
		u.DeletionRequestedAt = &t
	}
	return u
}

func orDefault(v, fallback string) string {
	if v == "" {
		return fallback
	}
	return v
}

// profileOf builds the profile payload for a user record, resolving their
// organization's name so the clients can label the membership without a second
// round trip. The lookup is best effort — an unresolvable name leaves the id.
func (s *Server) profileOf(r *http.Request, rec *userRecord) models.User {
	u := rec.toModel()
	if u.Organization != "" {
		u.OrganizationName = s.orgName(r.Context(), u.Organization)
	}
	return u
}

func (s *Server) fetchUser(r *http.Request, id string) (*userRecord, error) {
	var rec userRecord
	if err := s.pb.GetOne(r.Context(), s.usersCollection(), id, &rec); err != nil {
		return nil, err
	}
	return &rec, nil
}

func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	rec, err := s.fetchUser(r, claims.ID)
	if err != nil {
		writePBError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, s.profileOf(r, rec))
}

type updateMeRequest struct {
	Name             *string   `json:"name"`
	Bio              *string   `json:"bio"`
	Theme            *string   `json:"theme"`
	Locale           *string   `json:"locale"`
	DateFormat       *string   `json:"dateFormat"`
	TimeFormat       *string   `json:"timeFormat"`
	Currency         *string   `json:"currency"`
	FontSize         *string   `json:"fontSize"`
	DragLocked       *bool     `json:"dragLocked"`
	CarOrder         *[]string `json:"carOrder"`
	ChargerTabOrder  *[]string `json:"chargerTabOrder"`
	ChargerCardOrder *[]string `json:"chargerCardOrder"`

	// The whole map every time — the clients send it back with one page changed,
	// so a partial merge here would only make "no default" impossible to express.
	DefaultTabs *map[string]string `json:"defaultTabs"`
}

// maxCarOrder bounds the stored arrangement. listCars fetches at most 200 owned
// plus 200 shared cars, so this leaves room without letting a client park an
// unbounded blob on the record.
const maxCarOrder = 500

// normalizeCarOrder cleans a client-supplied garage arrangement: blanks out,
// duplicates dropped (first position wins), length capped. The ids are not
// checked against real cars — that would cost a lookup per entry, and an id for
// a car the user no longer has is harmless: listCars ignores what it can't
// match, and the next drag rewrites the list anyway.
func normalizeCarOrder(in []string) ([]string, error) {
	return normalizeOrder("carOrder", in, maxCarOrder)
}

// maxChargerTabOrder bounds the charging page's tab arrangement. The page has
// two tabs; the room is for tabs a later release adds, not for a client to park
// a blob on the record.
const maxChargerTabOrder = 20

func normalizeChargerTabOrder(in []string) ([]string, error) {
	return normalizeOrder("chargerTabOrder", in, maxChargerTabOrder)
}

func normalizeChargerCardOrder(in []string) ([]string, error) {
	return normalizeOrder("chargerCardOrder", in, maxChargerTabOrder)
}

// The tabbed pages a default tab can be set for, and the tabs each one has.
// Kept in step with the web app's src/lib/tabs.js. A key the page does not have
// would simply never match a button and the page would open on the front of its
// bar anyway — this is here to keep junk off the record, not to protect the
// clients from themselves.
var validDefaultTabs = map[string]map[string]bool{
	"charging": {"public": true, "home": true},
	"car": {
		"provider": true, "info": true, "services": true, "technical": true,
		"maintenance": true, "fuel": true, "charging": true, "documents": true,
		"parts": true, "reminders": true,
	},
	"settings": {"personal": true, "users": true, "organization": true, "integrations": true},
}

// normalizeDefaultTabs cleans a client-supplied default-tab map. An empty value
// is how a client says "no default, open on the front of the bar", and is stored
// as an absent key rather than an empty string so the two cannot drift apart.
func normalizeDefaultTabs(in map[string]string) (map[string]string, error) {
	out := make(map[string]string, len(in))
	for page, tab := range in {
		tabs, ok := validDefaultTabs[page]
		if !ok {
			return nil, fmt.Errorf("defaultTabs: %q is not a tabbed page", page)
		}
		tab = strings.TrimSpace(tab)
		if tab == "" {
			continue
		}
		if !tabs[tab] {
			return nil, fmt.Errorf("defaultTabs: %q is not a tab on the %s page", tab, page)
		}
		out[page] = tab
	}
	return out, nil
}

func normalizeOrder(field string, in []string, max int) ([]string, error) {
	if len(in) > max {
		return nil, fmt.Errorf("%s is too long (max %d)", field, max)
	}
	out := make([]string, 0, len(in))
	seen := make(map[string]bool, len(in))
	for _, id := range in {
		id = strings.TrimSpace(id)
		if id == "" || seen[id] {
			continue
		}
		seen[id] = true
		out = append(out, id)
	}
	return out, nil
}

var validThemes = map[string]bool{"light": true, "dark": true, "system": true}
var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true, "MDY": true}

// "auto" reads the clock the way the chosen region writes it, which is what
// the app did before there was a setting; the other two say it outright, for
// the people whose region and habit disagree.
var validTimeFormats = map[string]bool{"auto": true, "24": true, "12": true}
var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true}

// Kept in step with the users.currency select options in setup-pocketbase.mjs:
// PocketBase rejects anything outside its own list, so accepting a wider set
// here would only turn a clear 400 into a confusing upstream error.
var validCurrencies = map[string]bool{
	"EUR": true, "GBP": true, "CHF": true, "PLN": true, "CZK": true, "HUF": true,
	"RON": true, "BGN": true, "DKK": true, "SEK": true, "NOK": true, "ISK": true,
	"ALL": true, "AMD": true, "AZN": true, "BAM": true, "BYN": true, "GEL": true,
	"MDL": true, "MKD": true, "RSD": true, "RUB": true, "TRY": true, "UAH": true,
	"USD": true, "CAD": true, "AUD": true, "JPY": true,
}

// The clients pick language and region separately and join them into this tag,
// so the stored value is only ever language-REGION. Enforcing that shape here
// keeps a bad tag out of the record: the web app feeds the locale straight to
// Intl, which throws on a malformed one rather than falling back.
var localePattern = regexp.MustCompile(`^[a-z]{2}-[A-Z]{2}$`)

// handleUpdateMe applies a partial update — only fields present in the request
// body are touched, so the Account/Profile/Appearance sections of the settings
// panel can each save independently without clobbering the others.
func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	var in updateMeRequest
	if err := decodeJSON(r, &in); err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}

	payload := map[string]any{}
	if in.Name != nil {
		payload["name"] = *in.Name
	}
	if in.Bio != nil {
		payload["bio"] = *in.Bio
	}
	if in.Theme != nil {
		if !validThemes[*in.Theme] {
			writeError(w, http.StatusBadRequest, "theme must be light, dark, or system")
			return
		}
		payload["theme"] = *in.Theme
	}
	if in.Locale != nil {
		if !localePattern.MatchString(*in.Locale) {
			writeError(w, http.StatusBadRequest, "locale must look like en-US")
			return
		}
		payload["locale"] = *in.Locale
	}
	if in.DateFormat != nil {
		if !validDateFormats[*in.DateFormat] {
			writeError(w, http.StatusBadRequest, "dateFormat must be YMD, DMY, or MDY")
			return
		}
		payload["date_format"] = *in.DateFormat
	}
	if in.TimeFormat != nil {
		if !validTimeFormats[*in.TimeFormat] {
			writeError(w, http.StatusBadRequest, "timeFormat must be auto, 24, or 12")
			return
		}
		payload["time_format"] = *in.TimeFormat
	}
	if in.Currency != nil {
		if !validCurrencies[*in.Currency] {
			writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code")
			return
		}
		payload["currency"] = *in.Currency
	}
	if in.FontSize != nil {
		if !validFontSizes[*in.FontSize] {
			writeError(w, http.StatusBadRequest, "fontSize must be small, medium, or large")
			return
		}
		payload["font_size"] = *in.FontSize
	}
	if in.DragLocked != nil {
		// No value to validate — either the arrangements on this account's pages
		// are held still or they are not.
		payload["drag_locked"] = *in.DragLocked
	}
	if in.CarOrder != nil {
		ids, err := normalizeCarOrder(*in.CarOrder)
		if err != nil {
			writeError(w, http.StatusBadRequest, err.Error())
			return
		}
		payload["car_order"] = ids
	}
	if in.ChargerTabOrder != nil {
		keys, err := normalizeChargerTabOrder(*in.ChargerTabOrder)
		if err != nil {
			writeError(w, http.StatusBadRequest, err.Error())
			return
		}
		payload["charger_tab_order"] = keys
	}
	if in.ChargerCardOrder != nil {
		keys, err := normalizeChargerCardOrder(*in.ChargerCardOrder)
		if err != nil {
			writeError(w, http.StatusBadRequest, err.Error())
			return
		}
		payload["charger_card_order"] = keys
	}
	if in.DefaultTabs != nil {
		tabs, err := normalizeDefaultTabs(*in.DefaultTabs)
		if err != nil {
			writeError(w, http.StatusBadRequest, err.Error())
			return
		}
		payload["default_tabs"] = tabs
	}

	var rec userRecord
	if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil {
		writePBError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, s.profileOf(r, &rec))
}

type changePasswordRequest struct {
	OldPassword string `json:"oldPassword"`
	NewPassword string `json:"newPassword"`
}

func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	var in changePasswordRequest
	if err := decodeJSON(r, &in); err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	if in.OldPassword == "" {
		writeError(w, http.StatusBadRequest, "current password is required")
		return
	}
	if len(in.NewPassword) < 8 {
		writeError(w, http.StatusBadRequest, "new password must be at least 8 characters")
		return
	}

	// Verify the current password the same way login does, since the API
	// Server otherwise only ever talks to PocketBase as a superuser.
	if _, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection(), claims.Email, in.OldPassword); err != nil {
		writeError(w, http.StatusUnauthorized, "current password is incorrect")
		return
	}

	payload := map[string]any{"password": in.NewPassword, "passwordConfirm": in.NewPassword}
	if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
		writePBError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	if err := r.ParseMultipartForm(5 << 20); err != nil {
		writeError(w, http.StatusBadRequest, "avatar upload must be under 5MB")
		return
	}
	file, header, err := r.FormFile("avatar")
	if err != nil {
		writeError(w, http.StatusBadRequest, "missing avatar file")
		return
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not read upload")
		return
	}

	if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection(), claims.ID, nil, "avatar", header.Filename, data); err != nil {
		writePBError(w, err)
		return
	}
	rec, err := s.fetchUser(r, claims.ID)
	if err != nil {
		writePBError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, s.profileOf(r, rec))
}

func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, map[string]any{"avatar": ""}, nil); err != nil {
		writePBError(w, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	rec, err := s.fetchUser(r, claims.ID)
	if err != nil {
		writePBError(w, err)
		return
	}
	if rec.Avatar == "" {
		writeError(w, http.StatusNotFound, "no avatar set")
		return
	}
	data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection(), rec.ID, rec.Avatar)
	if err != nil {
		writePBError(w, err)
		return
	}
	w.Header().Set("Content-Type", contentType)
	w.Header().Set("Cache-Control", "private, max-age=300")
	w.Write(data)
}

func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	if err := s.pb.RequestVerification(r.Context(), s.usersCollection(), claims.Email); err != nil {
		writePBError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "requested"})
}

// handleExportData bundles the account profile plus every car the user owns
// (with its service records and parts) into one downloadable JSON file. Cars
// merely shared with the user are not exported — only cars they own.
func (s *Server) handleExportData(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	user, err := s.fetchUser(r, claims.ID)
	if err != nil {
		writePBError(w, err)
		return
	}

	carsRes, err := s.pb.List(r.Context(), colCars, url.Values{
		"filter":  {fmt.Sprintf("owner='%s'", claims.ID)},
		"sort":    {"name"},
		"perPage": {"200"},
	})
	if err != nil {
		writePBError(w, err)
		return
	}
	var carRecs []carRecord
	if err := json.Unmarshal(carsRes.Items, &carRecs); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}

	type carExport struct {
		models.Car
		ServiceRecords []models.ServiceRecord `json:"serviceRecords"`
		Parts          []models.Part          `json:"parts"`
	}
	exportCars := make([]carExport, 0, len(carRecs))
	for _, cr := range carRecs {
		car := cr.toModel()

		svcRes, err := s.pb.List(r.Context(), colServices, url.Values{
			"filter": {fmt.Sprintf("car='%s'", car.ID)}, "sort": {"-date"}, "perPage": {"500"},
		})
		if err != nil {
			writePBError(w, err)
			return
		}
		var svcRecs []serviceRecord
		if err := json.Unmarshal(svcRes.Items, &svcRecs); err != nil {
			writeError(w, http.StatusInternalServerError, err.Error())
			return
		}
		services := make([]models.ServiceRecord, 0, len(svcRecs))
		for _, sr := range svcRecs {
			m := sr.toModel()
			m.ComputeDerived(&car)
			services = append(services, m)
		}

		partsRes, err := s.pb.List(r.Context(), colParts, url.Values{
			"filter": {fmt.Sprintf("car='%s'", car.ID)}, "perPage": {"500"},
		})
		if err != nil {
			writePBError(w, err)
			return
		}
		var partRecs []partRecord
		if err := json.Unmarshal(partsRes.Items, &partRecs); err != nil {
			writeError(w, http.StatusInternalServerError, err.Error())
			return
		}
		parts := make([]models.Part, 0, len(partRecs))
		for _, p := range partRecs {
			parts = append(parts, p.toModel())
		}

		exportCars = append(exportCars, carExport{Car: car, ServiceRecords: services, Parts: parts})
	}

	out := map[string]any{
		"exportedAt": time.Now().UTC().Format(time.RFC3339),
		"account":    user.toModel(),
		"cars":       exportCars,
	}

	filename := fmt.Sprintf("car-control-export-%s.json", time.Now().UTC().Format("2006-01-02"))
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
	_ = json.NewEncoder(w).Encode(out)
}

// importCar mirrors handleExportData's per-car shape. The "id" and the
// service records'/parts' "car" fields in the uploaded file are ignored —
// this always creates brand-new records, remapped to the newly created car,
// rather than trying to match or overwrite anything that already exists.
type importCar struct {
	models.Car
	ServiceRecords []models.ServiceRecord `json:"serviceRecords"`
	Parts          []models.Part          `json:"parts"`
}

type importRequest struct {
	Cars []importCar `json:"cars"`
}

type importResult struct {
	CarsImported     int `json:"carsImported"`
	ServicesImported int `json:"servicesImported"`
	PartsImported    int `json:"partsImported"`
}

// handleImportData adds cars/service-records/parts from a previously exported
// JSON file (or a hand-built one in the same shape). It only ever creates new
// records — it does not merge with or overwrite existing shared household
// data. Deliberately lenient about unknown top-level fields (e.g. the
// export's "account"/"exportedAt"), since round-tripping the exact export
// file is the main use case.
func (s *Server) handleImportData(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}

	var in importRequest
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeError(w, http.StatusBadRequest, "invalid import file: "+err.Error())
		return
	}
	if len(in.Cars) == 0 {
		writeError(w, http.StatusBadRequest, "import file has no cars")
		return
	}

	var result importResult
	for _, ic := range in.Cars {
		car := ic.Car
		if car.Name == "" {
			continue // skip malformed entries rather than failing the whole import
		}
		applyCarDefaults(&car)

		// Imported cars are owned by the importing user, regardless of any
		// owner in the file.
		payload := carPayload(car)
		payload["owner"] = claims.ID

		var rec carRecord
		if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
			writePBError(w, err)
			return
		}
		result.CarsImported++

		for _, svc := range ic.ServiceRecords {
			svc.Car = rec.ID
			if err := s.pb.Create(r.Context(), colServices, servicePayload(svc), nil); err != nil {
				writePBError(w, err)
				return
			}
			result.ServicesImported++
		}
		for _, p := range ic.Parts {
			p.Car = rec.ID
			if err := s.pb.Create(r.Context(), colParts, partPayload(p), nil); err != nil {
				writePBError(w, err)
				return
			}
			result.PartsImported++
		}
	}

	writeJSON(w, http.StatusOK, result)
}

type deleteAccountRequest struct {
	ConfirmEmail string `json:"confirmEmail"`
}

// handleRequestDeletion starts the cooldown. The account is not touched yet —
// handleFinalizeDeletion is a separate, later call once the cooldown elapses.
func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	var in deleteAccountRequest
	if err := decodeJSON(r, &in); err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	if !strings.EqualFold(strings.TrimSpace(in.ConfirmEmail), claims.Email) {
		writeError(w, http.StatusBadRequest, "typed email does not match your account email")
		return
	}

	now := time.Now().UTC()
	payload := map[string]any{"deletion_requested_at": formatPBDate(now)}
	if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
		writePBError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"deletionRequestedAt": now.Format(time.RFC3339),
		"eligibleAt":          now.Add(deletionCooldown).Format(time.RFC3339),
	})
}

func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	payload := map[string]any{"deletion_requested_at": ""}
	if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
		writePBError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
}

// handleFinalizeDeletion actually deletes the account, but only once the
// cooldown started by handleRequestDeletion has elapsed. Deleting the user
// record cascades to their "sessions" rows (cascadeDelete relation); the
// shared cars/service-records/parts data is untouched, since it belongs to
// the household, not to one account.
func (s *Server) handleFinalizeDeletion(w http.ResponseWriter, r *http.Request) {
	claims := caller(r)
	if claims == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	rec, err := s.fetchUser(r, claims.ID)
	if err != nil {
		writePBError(w, err)
		return
	}
	requestedAt := parsePBDate(rec.DeletionRequestedAt)
	if requestedAt.IsZero() {
		writeError(w, http.StatusBadRequest, "no deletion request is pending")
		return
	}
	if time.Now().UTC().Before(requestedAt.Add(deletionCooldown)) {
		writeError(w, http.StatusForbidden, "the cooldown period has not elapsed yet")
		return
	}
	if err := s.pb.Delete(r.Context(), s.usersCollection(), claims.ID); err != nil {
		writePBError(w, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}
