package httpx

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

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

	"bountyboard/internal/atomize"
	"bountyboard/internal/domain"
	"bountyboard/internal/store"
)

func (s *Server) routesTasks(mux *http.ServeMux) {
	mux.Handle("GET /api/v1/consultant/board", s.authedRole("consultant", s.handleConsultantBoard))
	mux.Handle("POST /api/v1/tasks/{id}/subdivide", s.authedRole("consultant", s.handleSubdivide))
	mux.Handle("POST /api/v1/tasks/{id}/extend", s.authedRole("consultant", s.handleExtend))
	mux.Handle("PATCH /api/v1/tasks/{id}", s.authedRole("consultant", s.handleEditTask))
	mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne))
	mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
	mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
	mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
	mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
	mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
}

// consultantTask loads a task and authorizes the current user: admins and
// every consultant assigned to the task's customer may manage it (§4.4).
func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, 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, nil, false
	}
	c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
	if err != nil {
		s.internalError(w, r, "load customer", err)
		return nil, nil, false
	}
	u := CurrentUser(r.Context())
	if !u.Roles.Admin && !(u.Roles.Consultant && c.HasConsultant(u.ID)) {
		writeError(w, http.StatusForbidden, "forbidden", "you are not assigned to this customer")
		return nil, nil, false
	}
	return t, c, true
}

func (s *Server) handleConsultantBoard(w http.ResponseWriter, r *http.Request) {
	u := CurrentUser(r.Context())
	consultantID := u.ID
	if u.Roles.Admin {
		consultantID = "" // admins see all customers
	}
	customers, err := s.store.ListCustomers(r.Context(), consultantID, false)
	if err != nil {
		s.internalError(w, r, "list customers", err)
		return
	}
	customerID := r.URL.Query().Get("customerId")
	ids := []string{}
	for _, c := range customers {
		if customerID == "" || c.ID == customerID {
			ids = append(ids, c.ID)
		}
	}
	filter := store.TaskFilter{
		CustomerIDs: ids,
		Statuses: []domain.TaskStatus{
			domain.StatusImported, domain.StatusAtomizing, domain.StatusAtomized,
			domain.StatusPublished, domain.StatusClaimRequested,
		},
		Limit: 500,
		Sort:  "updated",
	}
	if st := r.URL.Query().Get("status"); st != "" {
		filter.Statuses = []domain.TaskStatus{domain.TaskStatus(st)}
	}
	tasks := []domain.Task{}
	if len(ids) > 0 {
		if tasks, err = s.store.ListTasks(r.Context(), filter); err != nil {
			s.internalError(w, r, "list tasks", err)
			return
		}
	}
	out := make([]map[string]any, len(customers))
	for i := range customers {
		out[i] = customerJSON(&customers[i])
	}
	writeJSON(w, http.StatusOK, map[string]any{"customers": out, "tasks": tasks})
}

func (s *Server) handleSubdivide(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Note        string `json:"note"`
		Constraints *struct {
			MinTasks int `json:"minTasks"`
			MaxTasks int `json:"maxTasks"`
		} `json:"constraints"`
		ConfirmReplace bool `json:"confirmReplace"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}
	t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	if t.Status != domain.StatusImported && t.Status != domain.StatusAtomized {
		writeError(w, http.StatusConflict, "bad_status",
			"only imported or atomized tasks can be subdivided (current: "+string(t.Status)+")")
		return
	}
	minT, maxT := 0, 0
	if req.Constraints != nil {
		minT, maxT = req.Constraints.MinTasks, req.Constraints.MaxTasks
		if minT < 0 || maxT < 0 || (maxT > 0 && minT > maxT) || maxT > 20 {
			writeError(w, http.StatusBadRequest, "bad_constraints", "constraints must satisfy 0 <= min <= max <= 20")
			return
		}
	}

	// Re-run subdivision replaces unpublished children after confirmation
	// (§11.7).
	children, err := s.store.ListTasks(r.Context(), store.TaskFilter{ParentID: t.ID, Limit: 500})
	if err != nil {
		s.internalError(w, r, "list children", err)
		return
	}
	var unpublished []string
	for _, c := range children {
		if c.Status == domain.StatusAtomized && c.Origin == domain.OriginSubdivided {
			unpublished = append(unpublished, c.ID)
		}
	}
	if len(children) > 0 && !req.ConfirmReplace {
		writeError(w, http.StatusConflict, "confirm_replace",
			"this task already has subtasks; re-running replaces the unpublished ones — confirm to proceed")
		return
	}
	if len(unpublished) > 0 {
		if _, err := s.store.DeleteTasks(r.Context(), unpublished); err != nil {
			s.internalError(w, r, "delete unpublished children", err)
			return
		}
	}

	fromStatus := string(t.Status)
	if err := s.store.TransitionTask(r.Context(), t, domain.StatusAtomizing,
		CurrentUser(r.Context()).ID, "subdivide_requested",
		map[string]any{"note": req.Note}, nil); err != nil {
		s.taskTransitionError(w, err)
		return
	}
	jobID, err := s.enqueue(r.Context(), atomize.JobKindSubdivide, atomize.SubdividePayload{
		TaskID: t.ID, Note: req.Note, MinTasks: minT, MaxTasks: maxT,
		ActorID: CurrentUser(r.Context()).ID, FromStatus: fromStatus,
	})
	if err != nil {
		s.internalError(w, r, "enqueue subdivide", err)
		return
	}
	s.Publish("board", "task.atomizing", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
	writeJSON(w, http.StatusAccepted, map[string]string{"jobId": jobID, "status": "queued"})
}

func (s *Server) handleExtend(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Note string `json:"note"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}
	if strings.TrimSpace(req.Note) == "" {
		writeError(w, http.StatusBadRequest, "note_required", "extension requires a note (§1.1)")
		return
	}
	t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	if t.Status != domain.StatusImported && t.Status != domain.StatusAtomized {
		writeError(w, http.StatusConflict, "bad_status",
			"only imported or atomized tasks can be extended (current: "+string(t.Status)+")")
		return
	}
	jobID, err := s.enqueue(r.Context(), atomize.JobKindExtend, atomize.ExtendPayload{
		TaskID: t.ID, Note: req.Note, ActorID: CurrentUser(r.Context()).ID,
	})
	if err != nil {
		s.internalError(w, r, "enqueue extend", err)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]string{"jobId": jobID, "status": "queued"})
}

func (s *Server) handleEditTask(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Version            *int64    `json:"version"`
		Title              *string   `json:"title"`
		Description        *string   `json:"description"`
		AcceptanceCriteria *[]string `json:"acceptanceCriteria"`
		Links              *[]string `json:"links"`
		EffortCoefficient  *float64  `json:"effortCoefficient"`
		Budget             *float64  `json:"budget"`
		CascadeBudget      bool      `json:"cascadeBudget"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}
	t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	// §6.1: bounty is frozen once approved; archived tasks are immutable.
	if t.Status == domain.StatusApproved || t.Status == domain.StatusArchived {
		writeError(w, http.StatusConflict, "immutable", "approved/archived tasks cannot be edited")
		return
	}

	set := bson.M{}
	coeff, budget := t.EffortCoefficient, t.Budget
	if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
		set["title"] = strings.TrimSpace(*req.Title)
	}
	if req.Description != nil {
		set["description"] = *req.Description
	}
	if req.AcceptanceCriteria != nil {
		set["acceptanceCriteria"] = *req.AcceptanceCriteria
	}
	if req.Links != nil {
		set["links"] = *req.Links
	}
	if req.EffortCoefficient != nil {
		coeff = *req.EffortCoefficient
		maxCoeff := 1.0
		if t.Origin == domain.OriginExtended {
			maxCoeff = 2.0 // §5.1: extensions may exceed 1
		}
		if coeff <= 0 || coeff > maxCoeff {
			writeError(w, http.StatusBadRequest, "bad_coefficient",
				"effort coefficient out of range")
			return
		}
		set["effortCoefficient"] = coeff
	}
	if req.Budget != nil {
		budget = *req.Budget
		if budget < 0 {
			writeError(w, http.StatusBadRequest, "bad_budget", "budget must be >= 0")
			return
		}
		set["budget"] = budget
	}
	if len(set) == 0 {
		writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields")
		return
	}
	set["bounty"] = domain.ComputeBounty(coeff, budget)

	version := t.Version
	if req.Version != nil {
		version = *req.Version
	}
	err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("tasks"), t.ID, version, bson.M{
		"$set": set,
		"$push": bson.M{"timeline": domain.TimelineEntry{
			At: time.Now().UTC(), ActorID: CurrentUser(r.Context()).ID, Event: "edited",
		}},
	})
	if err != nil {
		if errors.Is(err, store.ErrVersionConflict) {
			writeError(w, http.StatusConflict, "conflict", "task was modified concurrently — reload")
			return
		}
		s.internalError(w, r, "edit task", err)
		return
	}

	// Optional budget cascade to descendants whose bounty is not yet frozen.
	if req.Budget != nil && req.CascadeBudget {
		if err := s.cascadeBudget(r.Context(), t.ID, budget); err != nil {
			s.log.Warn("cascade budget", "task", t.ID, "err", err)
		}
	}

	fresh, err := s.store.TaskByID(r.Context(), t.ID)
	if err != nil {
		s.internalError(w, r, "reload task", err)
		return
	}
	s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
	writeJSON(w, http.StatusOK, map[string]any{"task": fresh})
}

// cascadeBudget walks descendants breadth-first updating budget + bounty for
// tasks that are not approved/archived.
func (s *Server) cascadeBudget(ctx context.Context, rootID string, budget float64) error {
	frontier := []string{rootID}
	for len(frontier) > 0 {
		children, err := s.store.ListTasks(ctx, store.TaskFilter{ParentID: frontier[0], Limit: 500})
		if err != nil {
			return err
		}
		frontier = frontier[1:]
		for _, c := range children {
			if c.Status == domain.StatusApproved || c.Status == domain.StatusArchived {
				continue
			}
			_, err := s.store.DB.Collection("tasks").UpdateOne(ctx,
				bson.M{"_id": c.ID},
				bson.M{"$set": bson.M{
					"budget":    budget,
					"bounty":    domain.ComputeBounty(c.EffortCoefficient, budget),
					"updatedAt": time.Now().UTC(),
				}, "$inc": bson.M{"version": 1}})
			if err != nil {
				return err
			}
			frontier = append(frontier, c.ID)
		}
	}
	return nil
}

func (s *Server) publishTask(ctx context.Context, t *domain.Task, actorID string) error {
	return s.store.TransitionTask(ctx, t, domain.StatusPublished, actorID, "published", nil,
		map[string]any{"publishedAt": time.Now().UTC()})
}

func (s *Server) handlePublishOne(w http.ResponseWriter, r *http.Request) {
	t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	if err := s.publishTask(r.Context(), t, CurrentUser(r.Context()).ID); err != nil {
		s.taskTransitionError(w, err)
		return
	}
	s.Publish("board", "task.published", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
	fresh, _ := s.store.TaskByID(r.Context(), t.ID)
	writeJSON(w, http.StatusOK, map[string]any{"task": fresh})
}

func (s *Server) handlePublishBulk(w http.ResponseWriter, r *http.Request) {
	var req struct {
		IDs []string `json:"ids"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}
	if len(req.IDs) == 0 || len(req.IDs) > 100 {
		writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
		return
	}
	published := []string{}
	failed := map[string]string{}
	for _, id := range req.IDs {
		t, err := s.store.TaskByID(r.Context(), id)
		if err != nil {
			failed[id] = "not found"
			continue
		}
		c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
		if err != nil {
			failed[id] = "customer missing"
			continue
		}
		u := CurrentUser(r.Context())
		if !u.Roles.Admin && !c.HasConsultant(u.ID) {
			failed[id] = "not your customer"
			continue
		}
		if err := s.publishTask(r.Context(), t, u.ID); err != nil {
			failed[id] = err.Error()
			continue
		}
		published = append(published, id)
		s.Publish("board", "task.published", map[string]any{"taskId": id, "customerId": t.CustomerID})
	}
	writeJSON(w, http.StatusOK, map[string]any{"published": published, "failed": failed})
}

func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) {
	u := CurrentUser(r.Context())
	if !u.Roles.Admin && !u.Roles.Consultant {
		writeError(w, http.StatusForbidden, "forbidden", "requires consultant or admin role")
		return
	}
	t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
	if !ok {
		return
	}
	if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
		s.taskTransitionError(w, err)
		return
	}
	s.Publish("board", "task.archived", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
	w.WriteHeader(http.StatusNoContent)
}

// handleArchiveBulk implements §11.9 bulk archive.
func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) {
	var req struct {
		IDs []string `json:"ids"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}
	if len(req.IDs) == 0 || len(req.IDs) > 100 {
		writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
		return
	}
	u := CurrentUser(r.Context())
	archived := []string{}
	failed := map[string]string{}
	for _, id := range req.IDs {
		t, err := s.store.TaskByID(r.Context(), id)
		if err != nil {
			failed[id] = "not found"
			continue
		}
		c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
		if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) {
			failed[id] = "not your customer"
			continue
		}
		if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
			failed[id] = err.Error()
			continue
		}
		archived = append(archived, id)
	}
	if len(archived) > 0 {
		s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived})
	}
	writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed})
}

// handleTaskDetail is role-scoped: admins and customer consultants always;
// developers when they are the assignee, have a claim, or the task is on the
// public board (published/claim_requested).
func (s *Server) handleTaskDetail(w http.ResponseWriter, r *http.Request) {
	t, err := s.store.TaskByID(r.Context(), r.PathValue("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
	}
	u := CurrentUser(r.Context())
	allowed := u.Roles.Admin
	if !allowed && u.Roles.Consultant {
		if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil && c.HasConsultant(u.ID) {
			allowed = true
		}
	}
	if !allowed && u.Roles.Developer {
		allowed = t.IsAssignedTo(u.ID) || t.HasClaimFrom(u.ID) ||
			t.Status == domain.StatusPublished || t.Status == domain.StatusClaimRequested
	}
	if !allowed {
		writeError(w, http.StatusForbidden, "forbidden", "no access to this task")
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"task": t})
}

// handleServiceHealth powers UI gating (§11.16): disable Subdivide/Extend
// when the atomizer is down, independently of the work performer.
func (s *Server) handleServiceHealth(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
	defer cancel()
	atom := s.probeExternal(ctx, "atomizer", s.effectiveAtomizerBaseURL(ctx))
	perf := s.probeExternal(ctx, "work-performer", s.cfg.WorkPerformerBaseURL)
	if s.atomizer != nil {
		atom.Breaker = s.atomizer.BreakerState()
	}
	if s.performer != nil {
		perf.Breaker = s.performer.BreakerState()
	}
	writeJSON(w, http.StatusOK, map[string]any{"atomizer": atom, "workPerformer": perf})
}

func (s *Server) taskTransitionError(w http.ResponseWriter, err error) {
	var bad *domain.ErrBadTransition
	switch {
	case errors.As(err, &bad):
		writeError(w, http.StatusConflict, "bad_status", bad.Error())
	case errors.Is(err, store.ErrVersionConflict):
		writeError(w, http.StatusConflict, "conflict", "task was modified concurrently — reload")
	case errors.Is(err, store.ErrNotFound):
		writeError(w, http.StatusNotFound, "not_found", "task not found")
	default:
		writeError(w, http.StatusInternalServerError, "internal", "internal server error")
	}
}
