package httpx

import (
	"errors"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"

	"go.mongodb.org/mongo-driver/v2/bson"

	"bountyboard/internal/domain"
	"bountyboard/internal/files"
	"bountyboard/internal/store"
)

// validThemes are the selectable UI themes (must match web/static/js/theme.js).
var validThemes = map[string]bool{
	"light": true, "dark": true, "neon": true,
	"terminal": true, "blueprint": true, "sunset": true,
}

func (s *Server) routesProfile(mux *http.ServeMux) {
	mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile)))
	mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile))
	mux.Handle("POST /api/v1/profile/avatar", s.authed(s.handleAvatarUpload))
	mux.HandleFunc("GET /files/{id}", s.handleGetFile)
}

func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{"user": CurrentUser(r.Context())})
}

// handlePatchProfile applies a partial profile update. Fields absent from
// the body are untouched. When "version" is supplied the update is
// optimistic-concurrency checked (409 on conflict); theme-only updates from
// the nav toggle omit it.
func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Version  *int64          `json:"version"`
		Name     *string         `json:"name"`
		Bio      *string         `json:"bio"`
		Contact  *domain.Contact `json:"contact"`
		Extra    *map[string]any `json:"extra"`
		Settings *struct {
			Theme             *string                   `json:"theme"`
			NavLayout         *string                   `json:"navLayout"`
			Notifications     *domain.NotificationPrefs `json:"notifications"`
			LeaderboardOptOut *bool                     `json:"leaderboardOptOut"`
		} `json:"settings"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}

	set := bson.M{}
	if req.Name != nil {
		name := strings.TrimSpace(*req.Name)
		if name == "" {
			writeError(w, http.StatusBadRequest, "invalid_name", "name cannot be empty")
			return
		}
		set["name"] = name
	}
	if req.Bio != nil {
		set["bio"] = *req.Bio
	}
	if req.Contact != nil {
		if req.Contact.Links == nil {
			req.Contact.Links = []string{}
		}
		set["contact"] = *req.Contact
	}
	if req.Extra != nil {
		set["extra"] = *req.Extra
	}
	if req.Settings != nil {
		if req.Settings.Theme != nil {
			theme := *req.Settings.Theme
			if !validThemes[theme] {
				writeError(w, http.StatusBadRequest, "invalid_theme", "unknown theme")
				return
			}
			set["settings.theme"] = theme
		}
		if req.Settings.NavLayout != nil {
			nav := *req.Settings.NavLayout
			if nav != "side" && nav != "top" {
				writeError(w, http.StatusBadRequest, "invalid_nav", "navLayout must be side or top")
				return
			}
			set["settings.navLayout"] = nav
		}
		if req.Settings.Notifications != nil {
			set["settings.notifications"] = *req.Settings.Notifications
		}
		if req.Settings.LeaderboardOptOut != nil {
			set["settings.leaderboardOptOut"] = *req.Settings.LeaderboardOptOut
		}
	}
	if len(set) == 0 {
		writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update")
		return
	}

	u := CurrentUser(r.Context())
	version := u.Version
	if req.Version != nil {
		version = *req.Version
	}
	err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), u.ID, version, bson.M{"$set": set})
	switch {
	case errors.Is(err, store.ErrVersionConflict):
		writeError(w, http.StatusConflict, "conflict", "profile was modified elsewhere; reload and retry")
		return
	case err != nil:
		s.internalError(w, r, "update profile", err)
		return
	}
	fresh, err := s.store.UserByID(r.Context(), u.ID)
	if err != nil {
		s.internalError(w, r, "reload profile", err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"user": fresh})
}

func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
	u := CurrentUser(r.Context())
	r.Body = http.MaxBytesReader(w, r.Body, int64(s.cfg.MaxUploadMB)<<20+1<<20)
	file, header, err := r.FormFile("file")
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_request", "multipart field 'file' is required")
		return
	}
	defer file.Close()

	meta, err := s.files.Save(r.Context(), files.ScopeAvatar, u.ID, header.Filename,
		header.Header.Get("Content-Type"), file)
	if err != nil {
		if errors.Is(err, files.ErrTooLarge) {
			writeError(w, http.StatusRequestEntityTooLarge, "too_large", err.Error())
			return
		}
		s.internalError(w, r, "save avatar", err)
		return
	}
	if !strings.HasPrefix(meta.MimeType, "image/") {
		if delErr := s.files.Delete(r.Context(), meta.ID); delErr != nil {
			s.log.Warn("delete rejected avatar", "err", delErr)
		}
		writeError(w, http.StatusBadRequest, "not_an_image", "avatar must be an image file")
		return
	}

	old := u.AvatarFileID
	err = store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), u.ID, u.Version,
		bson.M{"$set": bson.M{"avatarFileId": meta.ID}})
	if err != nil {
		if delErr := s.files.Delete(r.Context(), meta.ID); delErr != nil {
			s.log.Warn("delete orphaned avatar", "err", delErr)
		}
		if errors.Is(err, store.ErrVersionConflict) {
			writeError(w, http.StatusConflict, "conflict", "profile was modified elsewhere; reload and retry")
			return
		}
		s.internalError(w, r, "update avatar", err)
		return
	}
	if old != "" {
		if delErr := s.files.Delete(r.Context(), old); delErr != nil && !errors.Is(delErr, files.ErrNotFound) {
			s.log.Warn("delete previous avatar", "err", delErr)
		}
	}
	writeJSON(w, http.StatusOK, map[string]string{"fileId": meta.ID})
}

// handleGetFile serves stored files (§4.7): a valid session OR a valid
// signed token (?st=, §5.1) grants access. Scope rules: avatars are visible
// to any authenticated user; chat/task files additionally require ownership
// until their features land (tightened per-scope in later phases).
func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")

	signedOK := false
	if st := r.URL.Query().Get("st"); st != "" {
		signedOK = files.VerifyToken([]byte(s.cfg.SessionSecret), id, st, time.Now())
	}
	var user *domain.User
	if !signedOK {
		user = s.pageUser(r)
		if user == nil {
			writeError(w, http.StatusUnauthorized, "unauthenticated", "session or signed token required")
			return
		}
	}

	rc, meta, err := s.files.Open(r.Context(), id)
	if err != nil {
		if errors.Is(err, files.ErrNotFound) {
			writeError(w, http.StatusNotFound, "not_found", "file not found")
			return
		}
		s.internalError(w, r, "open file", err)
		return
	}
	defer rc.Close()

	if !signedOK {
		allowed := false
		switch meta.Scope {
		case files.ScopeAvatar:
			allowed = true // avatars are visible to all logged-in users
		case files.ScopeChat:
			// uploader always; otherwise participant of the conversation the
			// file is attached to
			allowed = meta.OwnerID == user.ID
			if !allowed {
				ok, err := s.store.UserCanAccessChatFile(r.Context(), user.ID, id)
				if err != nil {
					s.internalError(w, r, "chat file access", err)
					return
				}
				allowed = ok
			}
		default: // task scope: owner, admins, consultants, or the assignee
			allowed = meta.OwnerID == user.ID || user.Roles.Admin || user.Roles.Consultant
			if !allowed && user.Roles.Developer {
				// developers can fetch task files for tasks they can see;
				// signed URLs cover the external-service path
				allowed = true
			}
		}
		if !allowed {
			writeError(w, http.StatusForbidden, "forbidden", "no access to this file")
			return
		}
	}

	w.Header().Set("Content-Type", meta.MimeType)
	w.Header().Set("X-Content-Type-Options", "nosniff")
	if meta.SHA256 != "" {
		etag := `"` + meta.SHA256 + `"`
		w.Header().Set("ETag", etag)
		if r.Header.Get("If-None-Match") == etag {
			w.WriteHeader(http.StatusNotModified)
			return
		}
	}
	disposition := "attachment"
	if strings.HasPrefix(meta.MimeType, "image/") || meta.MimeType == "application/pdf" ||
		strings.HasPrefix(meta.MimeType, "text/") {
		disposition = "inline"
	}
	w.Header().Set("Content-Disposition", disposition+`; filename="`+strings.ReplaceAll(meta.Name, `"`, "")+`"`)
	w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10))
	if _, err := io.Copy(w, rc); err != nil {
		s.log.Warn("stream file", "id", id, "err", err)
	}
}
