package httpx

import (
	"context"
	"errors"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"time"

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

	"bountyboard/internal/chat"
	"bountyboard/internal/domain"
	"bountyboard/internal/store"
	"bountyboard/internal/ulid"
)

func (s *Server) routesBoard(mux *http.ServeMux) {
	dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) }
	mux.Handle("GET /api/v1/board", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleBoard))))
	mux.Handle("GET /api/v1/my-tasks", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleMyTasks))))
	mux.Handle("POST /api/v1/tasks/{id}/claim", dev(s.handleClaim))
	mux.Handle("POST /api/v1/tasks/{id}/claim/withdraw", dev(s.handleWithdrawClaim))
	mux.Handle("POST /api/v1/tasks/{id}/start", dev(s.handleStartTask))
	mux.Handle("POST /api/v1/tasks/{id}/submit-review", dev(s.handleSubmitReview))
	mux.Handle("POST /api/v1/tasks/{id}/abandon", dev(s.handleAbandonTask))
	mux.Handle("POST /api/v1/tasks/{id}/comments", s.authed(s.handleAddComment))
	mux.Handle("POST /api/v1/tasks/{id}/time", dev(s.handleLogTime))
	mux.Handle("GET /api/v1/notifications", s.requireAuth(http.HandlerFunc(s.handleListNotifications)))
	mux.Handle("POST /api/v1/notifications/read", s.authed(s.handleReadNotifications))
	mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard)))
}

// boardCustomers resolves the developer's visibility scope: every customer
// that has at least one consultant who selected this developer into a pool.
// Visibility is customer-based, not task-owner-based — §4.4: all consultants
// assigned to a customer manage its tasks, so a task published by any of
// them belongs to the same board.
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
	consultants, err := s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
	if err != nil {
		return nil, err
	}
	seen := map[string]bool{}
	out := []domain.Customer{}
	for _, cid := range consultants {
		customers, err := s.store.ListCustomers(r.Context(), cid, false)
		if err != nil {
			return nil, err
		}
		for _, c := range customers {
			if !seen[c.ID] {
				seen[c.ID] = true
				out = append(out, c)
			}
		}
	}
	return out, nil
}

func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
	customers, err := s.boardCustomers(r)
	if err != nil {
		s.internalError(w, r, "board scope", err)
		return
	}
	q := r.URL.Query()
	customerIDs := []string{}
	for _, c := range customers {
		if want := q.Get("customerId"); want == "" || want == c.ID {
			customerIDs = append(customerIDs, c.ID)
		}
	}
	tasks := []domain.Task{}
	if len(customerIDs) > 0 {
		minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
		filter := store.TaskFilter{
			CustomerIDs: customerIDs,
			Statuses:    []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
			Search:      strings.TrimSpace(q.Get("q")),
			MinBounty:   minBounty,
			Sort:        q.Get("sort"), // newest (default) | bounty
			Limit:       200,
		}
		if tasks, err = s.store.ListTasks(r.Context(), filter); err != nil {
			s.internalError(w, r, "list board", err)
			return
		}
	}

	// §10: optionally hide competing claim requests from other developers.
	settings, err := s.store.GetSettings(r.Context())
	hideCompeting := err == nil && settings.HideCompetingClaims
	me := CurrentUser(r.Context()).ID
	for i := range tasks {
		mine := []domain.ClaimRequest{}
		for _, cr := range tasks[i].ClaimRequests {
			if cr.DeveloperID == me || !hideCompeting {
				mine = append(mine, cr)
			}
		}
		tasks[i].ClaimRequests = mine
	}

	// customers for the filter dropdown (full visibility scope)
	customersOut := make([]map[string]any, len(customers))
	for i, c := range customers {
		customersOut[i] = map[string]any{"id": c.ID, "name": c.Name}
	}
	writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut})
}

func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
	me := CurrentUser(r.Context()).ID
	tasks, err := s.store.ListTasks(r.Context(), store.TaskFilter{
		AssigneeID: me,
		Statuses: []domain.TaskStatus{
			domain.StatusAssigned, domain.StatusInProgress, domain.StatusInReview,
			domain.StatusChangesRequested, domain.StatusApproved,
		},
		Sort:  "updated",
		Limit: 200,
	})
	if err != nil {
		s.internalError(w, r, "list my tasks", err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
}

// developerTask loads a task ensuring the developer can see it: the task's
// customer is in their pool-derived visibility scope, or they are the
// assignee (access survives mid-flight pool removal).
func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, bool) {
	t, err := s.store.TaskByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			writeError(w, http.StatusNotFound, "not_found", "task not found")
		} else {
			s.internalError(w, r, "load task", err)
		}
		return nil, false
	}
	if t.IsAssignedTo(CurrentUser(r.Context()).ID) {
		return t, true
	}
	customers, err := s.boardCustomers(r)
	if err != nil {
		s.internalError(w, r, "board scope", err)
		return nil, false
	}
	for _, c := range customers {
		if c.ID == t.CustomerID {
			return t, true
		}
	}
	writeError(w, http.StatusForbidden, "forbidden", "this task is not on your board")
	return nil, false
}

func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Note string `json:"note"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}
	t, ok := s.developerTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	me := CurrentUser(r.Context())
	if t.HasClaimFrom(me.ID) {
		writeError(w, http.StatusConflict, "already_requested", "you already requested this task")
		return
	}
	claim := domain.ClaimRequest{DeveloperID: me.ID, Note: req.Note, At: time.Now().UTC()}

	switch t.Status {
	case domain.StatusPublished:
		err := s.store.TransitionTask(r.Context(), t, domain.StatusClaimRequested,
			me.ID, "claim_requested", map[string]any{"note": req.Note},
			map[string]any{"claimRequests": []domain.ClaimRequest{claim}})
		if err != nil {
			s.taskTransitionError(w, err)
			return
		}
	case domain.StatusClaimRequested:
		// additional developers may pile on (visible per settings)
		err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("tasks"), t.ID, t.Version, bson.M{
			"$push": bson.M{
				"claimRequests": claim,
				"timeline": domain.TimelineEntry{
					At: time.Now().UTC(), ActorID: me.ID, Event: "claim_requested",
					Data: map[string]any{"note": req.Note},
				},
			},
		})
		if err != nil {
			s.taskTransitionError(w, err)
			return
		}
	default:
		writeError(w, http.StatusConflict, "bad_status", "task is not open for claims")
		return
	}
	s.notifyUser(r.Context(), t.ConsultantID, "claim_requested",
		"Assignment requested: "+t.Title, me.Name+" wants this task.", "/tasks/"+t.ID)
	s.Publish("board", "task.claimed", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) handleWithdrawClaim(w http.ResponseWriter, r *http.Request) {
	t, ok := s.developerTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	me := CurrentUser(r.Context())
	if !t.HasClaimFrom(me.ID) {
		writeError(w, http.StatusConflict, "no_claim", "you have no claim on this task")
		return
	}
	s.removeClaim(w, r, t, me.ID, me.ID, "claim_withdrawn")
}

// removeClaim drops one developer's claim; the task returns to published
// when no claims remain (§4.4).
func (s *Server) removeClaim(w http.ResponseWriter, r *http.Request, t *domain.Task,
	developerID, actorID, event string) {
	remaining := []domain.ClaimRequest{}
	for _, cr := range t.ClaimRequests {
		if cr.DeveloperID != developerID {
			remaining = append(remaining, cr)
		}
	}
	update := bson.M{
		"$set": bson.M{"claimRequests": remaining},
		"$push": bson.M{"timeline": domain.TimelineEntry{
			At: time.Now().UTC(), ActorID: actorID, Event: event,
			Data: map[string]any{"developerId": developerID},
		}},
	}
	if len(remaining) == 0 && t.Status == domain.StatusClaimRequested {
		if err := domain.Transition(t.Status, domain.StatusPublished); err != nil {
			s.taskTransitionError(w, err)
			return
		}
		update["$set"].(bson.M)["status"] = domain.StatusPublished
	}
	if err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("tasks"), t.ID, t.Version, update); err != nil {
		s.taskTransitionError(w, err)
		return
	}
	s.Publish("board", "task.claim_removed", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) handleStartTask(w http.ResponseWriter, r *http.Request) {
	t, ok := s.developerTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	me := CurrentUser(r.Context())
	if !t.IsAssignedTo(me.ID) {
		writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can start work")
		return
	}
	// assigned → in_progress, and changes_requested → in_progress (resume)
	if err := s.store.TransitionTask(r.Context(), t, domain.StatusInProgress,
		me.ID, "work_started", nil, nil); err != nil {
		s.taskTransitionError(w, err)
		return
	}
	s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) handleSubmitReview(w http.ResponseWriter, r *http.Request) {
	t, ok := s.developerTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	me := CurrentUser(r.Context())
	if !t.IsAssignedTo(me.ID) {
		writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can submit for review")
		return
	}
	if err := s.store.TransitionTask(r.Context(), t, domain.StatusInReview,
		me.ID, "submitted_for_review", nil, nil); err != nil {
		s.taskTransitionError(w, err)
		return
	}
	s.notifyUser(r.Context(), t.ConsultantID, "review_requested",
		"Ready for review: "+t.Title, me.Name+" submitted this task.", "/tasks/"+t.ID)
	s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
	t, ok := s.developerTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	me := CurrentUser(r.Context())
	if !t.IsAssignedTo(me.ID) {
		writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can abandon the task")
		return
	}
	if err := s.store.TransitionTask(r.Context(), t, domain.StatusPublished,
		me.ID, "abandoned", nil, map[string]any{"assignee": nil}); err != nil {
		s.taskTransitionError(w, err)
		return
	}
	s.notifyUser(r.Context(), t.ConsultantID, "task_abandoned",
		"Task abandoned: "+t.Title, me.Name+" returned this task to the board.", "/tasks/"+t.ID)
	s.Publish("board", "task.published", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
	w.WriteHeader(http.StatusNoContent)
}

var mentionRe = regexp.MustCompile(`@([\w.+-]+@[\w.-]+\.\w+|\w+)`)

func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Body string `json:"body"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}
	body := chat.SanitizeHTML(req.Body)
	if strings.TrimSpace(chat.SanitizePlain(body)) == "" {
		writeError(w, http.StatusBadRequest, "empty_comment", "comment cannot be empty")
		return
	}
	me := CurrentUser(r.Context())
	t, err := s.store.TaskByID(r.Context(), r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", "task not found")
		return
	}
	// commenters: consultants of the customer, the assignee, claimers
	canComment := me.Roles.Admin || t.IsAssignedTo(me.ID) || t.HasClaimFrom(me.ID)
	if !canComment && me.Roles.Consultant {
		if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil && c.HasConsultant(me.ID) {
			canComment = true
		}
	}
	if !canComment {
		writeError(w, http.StatusForbidden, "forbidden", "no access to comment on this task")
		return
	}

	comment := domain.Comment{ID: ulid.New(), AuthorID: me.ID, Body: body, At: time.Now().UTC()}
	_, err = s.store.DB.Collection("tasks").UpdateOne(r.Context(), bson.M{"_id": t.ID}, bson.M{
		"$push": bson.M{"comments": comment},
		"$set":  bson.M{"updatedAt": time.Now().UTC()},
		"$inc":  bson.M{"version": 1},
	})
	if err != nil {
		s.internalError(w, r, "add comment", err)
		return
	}

	s.notifyMentions(r, t, me, body)
	// always tell the "other side"
	if t.IsAssignedTo(me.ID) || t.HasClaimFrom(me.ID) {
		s.notifyUser(r.Context(), t.ConsultantID, "comment",
			"New comment on "+t.Title, me.Name+" commented.", "/tasks/"+t.ID)
	} else if t.Assignee != nil && t.Assignee.Kind == "human" && t.Assignee.UserID != me.ID {
		s.notifyUser(r.Context(), t.Assignee.UserID, "comment",
			"New comment on "+t.Title, me.Name+" commented.", "/tasks/"+t.ID)
	}
	s.Publish("board", "task.comment", map[string]any{"taskId": t.ID})
	writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
}

// notifyMentions resolves @name / @email tokens against task participants
// (§11.3).
func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
	plain := chat.SanitizePlain(body)
	matches := mentionRe.FindAllStringSubmatch(plain, 10)
	if len(matches) == 0 {
		return
	}
	// candidate participants
	ids := map[string]bool{t.ConsultantID: true}
	if t.Assignee != nil && t.Assignee.UserID != "" {
		ids[t.Assignee.UserID] = true
	}
	for _, cr := range t.ClaimRequests {
		ids[cr.DeveloperID] = true
	}
	for _, c := range t.Comments {
		ids[c.AuthorID] = true
	}
	notified := map[string]bool{}
	for _, m := range matches {
		token := strings.ToLower(m[1])
		for id := range ids {
			if id == author.ID || notified[id] {
				continue
			}
			u, err := s.store.UserByID(r.Context(), id)
			if err != nil {
				continue
			}
			first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
			if token == strings.ToLower(u.Email) || token == first {
				notified[id] = true
				s.notifyUser(r.Context(), id, "mention",
					author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
			}
		}
	}
}

func (s *Server) handleLogTime(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Minutes int    `json:"minutes"`
		Note    string `json:"note"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}
	if req.Minutes <= 0 || req.Minutes > 24*60 {
		writeError(w, http.StatusBadRequest, "bad_minutes", "minutes must be 1..1440")
		return
	}
	t, ok := s.developerTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	me := CurrentUser(r.Context())
	if !t.IsAssignedTo(me.ID) {
		writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can log time")
		return
	}
	entry := domain.TimeLogEntry{DeveloperID: me.ID, Minutes: req.Minutes,
		Note: req.Note, At: time.Now().UTC()}
	_, err := s.store.DB.Collection("tasks").UpdateOne(r.Context(), bson.M{"_id": t.ID}, bson.M{
		"$push": bson.M{"timeLog": entry},
		"$set":  bson.M{"updatedAt": time.Now().UTC()},
		"$inc":  bson.M{"version": 1},
	})
	if err != nil {
		s.internalError(w, r, "log time", err)
		return
	}
	writeJSON(w, http.StatusCreated, map[string]any{"entry": entry})
}

func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	me := CurrentUser(r.Context()).ID
	list, err := s.store.ListNotifications(r.Context(), me,
		q.Get("unread") == "true", q.Get("cursor"), atoiDefault(q.Get("limit"), 30))
	if err != nil {
		s.internalError(w, r, "list notifications", err)
		return
	}
	unread, err := s.store.CountUnreadNotifications(r.Context(), me)
	if err != nil {
		s.internalError(w, r, "count unread", err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"notifications": list, "unread": unread})
}

func (s *Server) handleReadNotifications(w http.ResponseWriter, r *http.Request) {
	var req struct {
		IDs []string `json:"ids"` // empty = mark all read
	}
	if !decodeJSON(w, r, &req) {
		return
	}
	n, err := s.store.MarkNotificationsRead(r.Context(), CurrentUser(r.Context()).ID, req.IDs)
	if err != nil {
		s.internalError(w, r, "mark read", err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"marked": n})
}

// handleUserCard serves the public profile hover card (§11.13).
func (s *Server) handleUserCard(w http.ResponseWriter, r *http.Request) {
	u, err := s.store.UserByID(r.Context(), r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", "user not found")
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"card": map[string]any{
		"id": u.ID, "name": u.Name, "bio": u.Bio, "roles": u.Roles,
		"avatarFileId": u.AvatarFileID, "contact": u.Contact, "extra": u.Extra,
	}})
}

// notifyUser inserts an in-app notification and pushes it live.
func (s *Server) notifyUser(ctx context.Context, userID, kind, title, body, link string) {
	n := &store.Notification{UserID: userID, Kind: kind, Title: title, Body: body, Link: link}
	if err := s.store.InsertNotification(ctx, n); err != nil {
		s.log.Warn("insert notification", "err", err)
		return
	}
	s.Publish("notifications", "notification", n)
}
