// Package project 内的 handler.go 是三个聚合根的 HTTP 接入层。
//
// 路由约定（PM spec §3.1）：
//
//	/api/v1/projects                                CRUD + archive
//	/api/v1/projects/{slug}/requirements/...        CRUD + phase + close
//	/api/v1/projects/{slug}/issues/...              CRUD + assign + close
//
// 设计取舍：
//   - 把 Caller 提取做成 callerFromCtx helper：从 middleware.UserIDFromContext
//     读 uid，再通过 AdminLookup 拿 is_admin。AdminLookup 是窄接口，避免直接
//     依赖 internal/user 的具体类型（也方便测试时注入 stub）。
//   - phase 切换 / close / reopen / archive / assign 都走独立 POST endpoint：
//     便于 audit 区分语义；PATCH 仅改 title/description/visibility/owner_id。
package project

import (
	"context"
	"encoding/json"
	"net/http"
	"strconv"

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

	"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
	httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
	"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)

// AdminLookup 根据 user id 判 is_admin。
type AdminLookup interface {
	IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
}

// Handler 把三个 service 与一个 AdminLookup 拼到 HTTP 路由上。
type Handler struct {
	projects     ProjectService
	requirements RequirementService
	issues       IssueService
	artifacts    ArtifactService
	resolver     middleware.SessionResolver
	users        AdminLookup
}

// NewHandler 构造 Handler。
func NewHandler(p ProjectService, r RequirementService, i IssueService, arts ArtifactService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
	return &Handler{projects: p, requirements: r, issues: i, artifacts: arts, resolver: resolver, users: users}
}

// Mount 把所有 PM 路由挂到 r 上，并装入 Auth 中间件。
func (h *Handler) Mount(r chi.Router) {
	r.Route("/api/v1/projects", func(r chi.Router) {
		r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))

		r.Post("/", h.createProject)
		r.Get("/", h.listProjects)
		r.Get("/{slug}", h.getProject)
		r.Patch("/{slug}", h.updateProject)
		r.Post("/{slug}/archive", h.archiveProject)
		r.Post("/{slug}/unarchive", h.unarchiveProject)

		// 成员/角色 ACL（autonomy roadmap §11）：list 需可读；add/remove 仅
		// project-admin 或 global-admin（service 层 assertCanManageMembers 兜底）。
		r.Get("/{slug}/members", h.listMembers)
		r.Post("/{slug}/members", h.addMember)
		r.Delete("/{slug}/members/{userID}", h.removeMember)

		r.Post("/{slug}/requirements", h.createRequirement)
		r.Get("/{slug}/requirements", h.listRequirements)
		r.Get("/{slug}/requirements/{number}", h.getRequirement)
		r.Patch("/{slug}/requirements/{number}", h.updateRequirement)
		r.Post("/{slug}/requirements/{number}/phase", h.changeRequirementPhase)
		r.Post("/{slug}/requirements/{number}/close", h.closeRequirement)
		r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement)
		r.Post("/{slug}/requirements/{number}/artifacts", h.createArtifact)
		r.Get("/{slug}/requirements/{number}/artifacts", h.listArtifacts)
		r.Get("/{slug}/requirements/{number}/artifacts/{phase}/{version}", h.getArtifact)
		r.Post("/{slug}/requirements/{number}/artifacts/{phase}/{version}/approve", h.approveArtifact)

		r.Post("/{slug}/issues", h.createIssue)
		r.Get("/{slug}/issues", h.listIssues)
		r.Get("/{slug}/issues/{number}", h.getIssue)
		r.Patch("/{slug}/issues/{number}", h.updateIssue)
		r.Post("/{slug}/issues/{number}/assign", h.assignIssue)
		r.Post("/{slug}/issues/{number}/close", h.closeIssue)
		r.Post("/{slug}/issues/{number}/reopen", h.reopenIssue)
	})
}

// callerFromCtx 从 ctx 取出 uid，再调用 AdminLookup 取 is_admin。
// 没有 uid 时返回 401（Auth 中间件已经在前面拦了，这里是双保险）。
func (h *Handler) callerFromCtx(r *http.Request) (Caller, error) {
	uid, ok := middleware.UserIDFromContext(r.Context())
	if !ok {
		return Caller{}, errs.New(errs.CodeUnauthorized, "未认证")
	}
	isAdmin, err := h.users.IsAdmin(r.Context(), uid)
	if err != nil {
		return Caller{}, err
	}
	return Caller{UserID: uid, IsAdmin: isAdmin}, nil
}

// numberParam 解析 chi URL 中的 {number}。失败时返回 invalid_input。
func numberParam(r *http.Request) (int, error) {
	raw := chi.URLParam(r, "number")
	n, err := strconv.Atoi(raw)
	if err != nil || n <= 0 {
		return 0, errs.New(errs.CodeInvalidInput, "number 非法")
	}
	return n, nil
}

func writeErr(w http.ResponseWriter, r *http.Request, err error) {
	httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
}

func decodeBody(r *http.Request, dst any) error {
	if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
		return errs.New(errs.CodeInvalidInput, "请求体无效")
	}
	return nil
}

// ===== DTO =====

type projectDTO struct {
	ID          string  `json:"id"`
	Slug        string  `json:"slug"`
	Name        string  `json:"name"`
	Description string  `json:"description"`
	Visibility  string  `json:"visibility"`
	OwnerID     string  `json:"owner_id"`
	ArchivedAt  *string `json:"archived_at,omitempty"`
	CreatedAt   string  `json:"created_at"`
	UpdatedAt   string  `json:"updated_at"`
}

type requirementDTO struct {
	ID          string  `json:"id"`
	ProjectID   string  `json:"project_id"`
	WorkspaceID *string `json:"workspace_id"`
	Number      int     `json:"number"`
	Title       string  `json:"title"`
	Description string  `json:"description"`
	Phase       string  `json:"phase"`
	Status      string  `json:"status"`
	OwnerID     string  `json:"owner_id"`
	ClosedAt    *string `json:"closed_at,omitempty"`
	CreatedAt   string  `json:"created_at"`
	UpdatedAt   string  `json:"updated_at"`
}

type issueDTO struct {
	ID            string  `json:"id"`
	ProjectID     string  `json:"project_id"`
	RequirementID *string `json:"requirement_id"`
	WorkspaceID   *string `json:"workspace_id"`
	Number        int     `json:"number"`
	Title         string  `json:"title"`
	Description   string  `json:"description"`
	Status        string  `json:"status"`
	AssigneeID    *string `json:"assignee_id"`
	CreatedBy     string  `json:"created_by"`
	ClosedAt      *string `json:"closed_at,omitempty"`
	CreatedAt     string  `json:"created_at"`
	UpdatedAt     string  `json:"updated_at"`
}

type artifactDTO struct {
	ID              string `json:"id"`
	RequirementID   string `json:"requirement_id"`
	Phase           string `json:"phase"`
	Version         int    `json:"version"`
	Content         string `json:"content"`
	Note            string `json:"note"`
	SourceMessageID *int64 `json:"source_message_id,omitempty"`
	CreatedBy       string `json:"created_by"`
	CreatedAt       string `json:"created_at"`
	Verdict         string `json:"verdict"`
}

type memberDTO struct {
	ProjectID string  `json:"project_id"`
	UserID    string  `json:"user_id"`
	Role      string  `json:"role"`
	AddedBy   *string `json:"added_by,omitempty"`
	CreatedAt string  `json:"created_at"`
}

func toMemberDTO(m *Member) memberDTO {
	d := memberDTO{
		ProjectID: m.ProjectID.String(),
		UserID:    m.UserID.String(),
		Role:      string(m.Role),
		CreatedAt: m.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
	}
	if m.AddedBy != nil {
		s := m.AddedBy.String()
		d.AddedBy = &s
	}
	return d
}

func toProjectDTO(p *Project) projectDTO {
	d := projectDTO{
		ID: p.ID.String(), Slug: p.Slug, Name: p.Name, Description: p.Description,
		Visibility: string(p.Visibility), OwnerID: p.OwnerID.String(),
		CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
		UpdatedAt: p.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
	}
	if p.ArchivedAt != nil {
		s := p.ArchivedAt.UTC().Format("2006-01-02T15:04:05Z")
		d.ArchivedAt = &s
	}
	return d
}

func toRequirementDTO(r *Requirement) requirementDTO {
	d := requirementDTO{
		ID: r.ID.String(), ProjectID: r.ProjectID.String(),
		Number: r.Number, Title: r.Title, Description: r.Description,
		Phase: string(r.Phase), Status: string(r.Status), OwnerID: r.OwnerID.String(),
		CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
		UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
	}
	if r.WorkspaceID != nil {
		s := r.WorkspaceID.String()
		d.WorkspaceID = &s
	}
	if r.ClosedAt != nil {
		s := r.ClosedAt.UTC().Format("2006-01-02T15:04:05Z")
		d.ClosedAt = &s
	}
	return d
}

func toIssueDTO(i *Issue) issueDTO {
	d := issueDTO{
		ID: i.ID.String(), ProjectID: i.ProjectID.String(),
		Number: i.Number, Title: i.Title, Description: i.Description,
		Status: string(i.Status), CreatedBy: i.CreatedBy.String(),
		CreatedAt: i.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
		UpdatedAt: i.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
	}
	if i.RequirementID != nil {
		s := i.RequirementID.String()
		d.RequirementID = &s
	}
	if i.WorkspaceID != nil {
		s := i.WorkspaceID.String()
		d.WorkspaceID = &s
	}
	if i.AssigneeID != nil {
		s := i.AssigneeID.String()
		d.AssigneeID = &s
	}
	if i.ClosedAt != nil {
		s := i.ClosedAt.UTC().Format("2006-01-02T15:04:05Z")
		d.ClosedAt = &s
	}
	return d
}

func toArtifactDTO(a *Artifact) artifactDTO {
	return artifactDTO{
		ID: a.ID.String(), RequirementID: a.RequirementID.String(),
		Phase: string(a.Phase), Version: a.Version, Content: a.Content, Note: a.Note,
		SourceMessageID: a.SourceMessageID,
		CreatedBy:       a.CreatedBy.String(),
		CreatedAt:       a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
		Verdict:         string(a.Verdict),
	}
}

// ===== Project endpoints =====

type createProjectReq struct {
	Slug        string `json:"slug"`
	Name        string `json:"name"`
	Description string `json:"description"`
	Visibility  string `json:"visibility"`
}

func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req createProjectReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	vis := Visibility(req.Visibility)
	if req.Visibility == "" {
		vis = VisibilityPrivate
	}
	out, err := h.projects.Create(r.Context(), c, CreateProjectInput{
		Slug: req.Slug, Name: req.Name, Description: req.Description, Visibility: vis,
	})
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusCreated, toProjectDTO(out))
}

func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	includeArchived := r.URL.Query().Get("archived") == "true"
	out, err := h.projects.List(r.Context(), c, includeArchived)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	dtos := make([]projectDTO, 0, len(out))
	for _, p := range out {
		dtos = append(dtos, toProjectDTO(p))
	}
	httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
}

func (h *Handler) getProject(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out, err := h.projects.Get(r.Context(), c, chi.URLParam(r, "slug"))
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toProjectDTO(out))
}

type updateProjectReq struct {
	Name        *string `json:"name,omitempty"`
	Description *string `json:"description,omitempty"`
	Visibility  *string `json:"visibility,omitempty"`
}

func (h *Handler) updateProject(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req updateProjectReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	in := UpdateProjectInput{Name: req.Name, Description: req.Description}
	if req.Visibility != nil {
		v := Visibility(*req.Visibility)
		in.Visibility = &v
	}
	out, err := h.projects.Update(r.Context(), c, chi.URLParam(r, "slug"), in)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toProjectDTO(out))
}

func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, true) }
func (h *Handler) unarchiveProject(w http.ResponseWriter, r *http.Request) {
	h.toggleArchive(w, r, false)
}

func (h *Handler) toggleArchive(w http.ResponseWriter, r *http.Request, archive bool) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	slug := chi.URLParam(r, "slug")
	if archive {
		err = h.projects.Archive(r.Context(), c, slug)
	} else {
		err = h.projects.Unarchive(r.Context(), c, slug)
	}
	if err != nil {
		writeErr(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// ===== Member endpoints（autonomy roadmap §11）=====

type addMemberReq struct {
	UserID string `json:"user_id"`
	Role   string `json:"role"`
}

func (h *Handler) listMembers(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out, err := h.projects.ListMembers(r.Context(), c, chi.URLParam(r, "slug"))
	if err != nil {
		writeErr(w, r, err)
		return
	}
	dtos := make([]memberDTO, 0, len(out))
	for _, m := range out {
		dtos = append(dtos, toMemberDTO(m))
	}
	httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
}

func (h *Handler) addMember(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req addMemberReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	uid, err := uuid.Parse(req.UserID)
	if err != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 非法"))
		return
	}
	out, err := h.projects.AddMember(r.Context(), c, chi.URLParam(r, "slug"), AddMemberInput{
		UserID: uid, Role: Role(req.Role),
	})
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toMemberDTO(out))
}

func (h *Handler) removeMember(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	uid, err := uuid.Parse(chi.URLParam(r, "userID"))
	if err != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 非法"))
		return
	}
	if err := h.projects.RemoveMember(r.Context(), c, chi.URLParam(r, "slug"), uid); err != nil {
		writeErr(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// ===== Requirement endpoints =====

type createRequirementReq struct {
	Title       string  `json:"title"`
	Description string  `json:"description"`
	OwnerID     *string `json:"owner_id,omitempty"`
	WorkspaceID *string `json:"workspace_id,omitempty"`
}

func (h *Handler) createRequirement(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req createRequirementReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	in := CreateRequirementInput{Title: req.Title, Description: req.Description}
	if req.OwnerID != nil {
		uid, perr := uuid.Parse(*req.OwnerID)
		if perr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "owner_id 非法"))
			return
		}
		in.OwnerID = &uid
	}
	if req.WorkspaceID != nil {
		wid, perr := uuid.Parse(*req.WorkspaceID)
		if perr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "workspace_id 非法"))
			return
		}
		in.WorkspaceID = &wid
	}
	out, err := h.requirements.Create(r.Context(), c, chi.URLParam(r, "slug"), in)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusCreated, toRequirementDTO(out))
}

func (h *Handler) listRequirements(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out, err := h.requirements.List(r.Context(), c, chi.URLParam(r, "slug"), RequirementFilter{
		Phase:  Phase(r.URL.Query().Get("phase")),
		Status: Status(r.URL.Query().Get("status")),
	})
	if err != nil {
		writeErr(w, r, err)
		return
	}
	dtos := make([]requirementDTO, 0, len(out))
	for _, x := range out {
		dtos = append(dtos, toRequirementDTO(x))
	}
	httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
}

func (h *Handler) getRequirement(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	req, err := h.requirements.Get(r.Context(), c, chi.URLParam(r, "slug"), num)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	// 详情携带挂在其下的 issues 摘要
	issues, _ := h.issues.List(r.Context(), c, chi.URLParam(r, "slug"), IssueFilter{Requirement: &num})
	idtos := make([]issueDTO, 0, len(issues))
	for _, i := range issues {
		idtos = append(idtos, toIssueDTO(i))
	}
	httpx.WriteJSON(w, http.StatusOK, map[string]any{
		"requirement": toRequirementDTO(req),
		"issues":      idtos,
	})
}

// updateRequirementReq 用 json.RawMessage 区分 workspace_id 的 "未传"/"显式 null"/"传值"。
type updateRequirementReq struct {
	Title       *string         `json:"title,omitempty"`
	Description *string         `json:"description,omitempty"`
	OwnerID     *string         `json:"owner_id,omitempty"`
	WorkspaceID json.RawMessage `json:"workspace_id,omitempty"`
}

func (h *Handler) updateRequirement(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req updateRequirementReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	in := UpdateRequirementInput{Title: req.Title, Description: req.Description}
	if req.OwnerID != nil {
		uid, perr := uuid.Parse(*req.OwnerID)
		if perr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "owner_id 非法"))
			return
		}
		in.OwnerID = &uid
	}
	if err := parseWorkspaceIDPatch(req.WorkspaceID, &in.WorkspaceID); err != nil {
		writeErr(w, r, err)
		return
	}
	out, err := h.requirements.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out))
}

// parseWorkspaceIDPatch 把 PATCH 请求里的 json.RawMessage workspace_id 字段
// 翻译为 *uuid.UUID 三态：
//   - 字段缺省（len == 0）：保留 *dst = nil
//   - 显式 null：*dst = &uuid.Nil（解绑 sentinel）
//   - UUID 字符串：解析并赋值；解析失败返回 invalid_input
func parseWorkspaceIDPatch(raw json.RawMessage, dst **uuid.UUID) error {
	if len(raw) == 0 {
		return nil
	}
	if string(raw) == "null" {
		nilUUID := uuid.Nil
		*dst = &nilUUID
		return nil
	}
	var s string
	if err := json.Unmarshal(raw, &s); err != nil {
		return errs.New(errs.CodeInvalidInput, "workspace_id 非法")
	}
	wid, perr := uuid.Parse(s)
	if perr != nil {
		return errs.New(errs.CodeInvalidInput, "workspace_id 非法")
	}
	*dst = &wid
	return nil
}

type phaseReq struct {
	Phase string `json:"phase"`
}

func (h *Handler) changeRequirementPhase(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req phaseReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	out, err := h.requirements.ChangePhase(r.Context(), c, chi.URLParam(r, "slug"), num, Phase(req.Phase))
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out))
}

func (h *Handler) closeRequirement(w http.ResponseWriter, r *http.Request) {
	h.toggleReqStatus(w, r, true)
}
func (h *Handler) reopenRequirement(w http.ResponseWriter, r *http.Request) {
	h.toggleReqStatus(w, r, false)
}

func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	if doClose {
		err = h.requirements.Close(r.Context(), c, chi.URLParam(r, "slug"), num)
	} else {
		err = h.requirements.Reopen(r.Context(), c, chi.URLParam(r, "slug"), num)
	}
	if err != nil {
		writeErr(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// ===== Artifact endpoints =====

type createArtifactReq struct {
	Phase           string `json:"phase"`
	Content         string `json:"content"`
	Note            string `json:"note"`
	SourceMessageID *int64 `json:"source_message_id"`
}

func (h *Handler) createArtifact(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req createArtifactReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	out, err := h.artifacts.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreateArtifactInput{
		Phase: Phase(req.Phase), Content: req.Content, Note: req.Note,
		SourceMessageID: req.SourceMessageID,
	})
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusCreated, toArtifactDTO(out))
}

func (h *Handler) listArtifacts(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	phase := Phase(r.URL.Query().Get("phase"))
	out, err := h.artifacts.List(r.Context(), c, chi.URLParam(r, "slug"), num, phase)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	dtos := make([]artifactDTO, 0, len(out))
	for _, x := range out {
		dtos = append(dtos, toArtifactDTO(x))
	}
	httpx.WriteJSON(w, http.StatusOK, dtos)
}

func (h *Handler) getArtifact(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	ver, err := strconv.Atoi(chi.URLParam(r, "version"))
	if err != nil || ver <= 0 {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法"))
		return
	}
	phase := Phase(chi.URLParam(r, "phase"))
	out, err := h.artifacts.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, phase, ver)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out))
}

type approveArtifactReq struct {
	Verdict string `json:"verdict"`
}

func (h *Handler) approveArtifact(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	ver, err := strconv.Atoi(chi.URLParam(r, "version"))
	if err != nil || ver <= 0 {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法"))
		return
	}
	var req approveArtifactReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	phase := Phase(chi.URLParam(r, "phase"))
	out, err := h.artifacts.Approve(r.Context(), c, chi.URLParam(r, "slug"), num, phase, ver, Verdict(req.Verdict))
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out))
}

// ===== Issue endpoints =====

type createIssueReq struct {
	Title             string  `json:"title"`
	Description       string  `json:"description"`
	RequirementNumber *int    `json:"requirement_number,omitempty"`
	AssigneeID        *string `json:"assignee_id,omitempty"`
	WorkspaceID       *string `json:"workspace_id,omitempty"`
}

func (h *Handler) createIssue(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req createIssueReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	in := CreateIssueInput{Title: req.Title, Description: req.Description, RequirementNumber: req.RequirementNumber}
	if req.AssigneeID != nil {
		uid, perr := uuid.Parse(*req.AssigneeID)
		if perr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee_id 非法"))
			return
		}
		in.AssigneeID = &uid
	}
	if req.WorkspaceID != nil {
		wid, perr := uuid.Parse(*req.WorkspaceID)
		if perr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "workspace_id 非法"))
			return
		}
		in.WorkspaceID = &wid
	}
	out, err := h.issues.Create(r.Context(), c, chi.URLParam(r, "slug"), in)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusCreated, toIssueDTO(out))
}

func (h *Handler) listIssues(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	q := r.URL.Query()
	filter := IssueFilter{Status: Status(q.Get("status"))}
	switch req := q.Get("requirement"); req {
	case "":
		// 不过滤
	case "none", "null":
		zero := 0
		filter.Requirement = &zero
	default:
		n, perr := strconv.Atoi(req)
		if perr != nil || n <= 0 {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement 参数非法"))
			return
		}
		filter.Requirement = &n
	}
	if a := q.Get("assignee"); a != "" {
		uid, perr := uuid.Parse(a)
		if perr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee 非法"))
			return
		}
		filter.AssigneeID = &uid
	}
	out, err := h.issues.List(r.Context(), c, chi.URLParam(r, "slug"), filter)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	dtos := make([]issueDTO, 0, len(out))
	for _, x := range out {
		dtos = append(dtos, toIssueDTO(x))
	}
	httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
}

func (h *Handler) getIssue(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out, err := h.issues.Get(r.Context(), c, chi.URLParam(r, "slug"), num)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
}

// updateIssueReq 用 json.RawMessage 区分 "未传"、"显式传 null"、"传值"。
type updateIssueReq struct {
	Title             *string         `json:"title,omitempty"`
	Description       *string         `json:"description,omitempty"`
	RequirementNumber json.RawMessage `json:"requirement_number,omitempty"`
	WorkspaceID       json.RawMessage `json:"workspace_id,omitempty"`
}

func (h *Handler) updateIssue(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req updateIssueReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	in := UpdateIssueInput{Title: req.Title, Description: req.Description}
	if len(req.RequirementNumber) > 0 {
		// 字段在 JSON 中存在
		if string(req.RequirementNumber) == "null" {
			var nilPtr *int
			in.RequirementNumber = &nilPtr
		} else {
			var n int
			if jerr := json.Unmarshal(req.RequirementNumber, &n); jerr != nil || n <= 0 {
				writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement_number 非法"))
				return
			}
			pn := &n
			in.RequirementNumber = &pn
		}
	}
	if err := parseWorkspaceIDPatch(req.WorkspaceID, &in.WorkspaceID); err != nil {
		writeErr(w, r, err)
		return
	}
	out, err := h.issues.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
}

type assignReq struct {
	AssigneeID *string `json:"assignee_id"`
}

func (h *Handler) assignIssue(w http.ResponseWriter, r *http.Request) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req assignReq
	if err := decodeBody(r, &req); err != nil {
		writeErr(w, r, err)
		return
	}
	var assignee *uuid.UUID
	if req.AssigneeID != nil {
		uid, perr := uuid.Parse(*req.AssigneeID)
		if perr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee_id 非法"))
			return
		}
		assignee = &uid
	}
	out, err := h.issues.Assign(r.Context(), c, chi.URLParam(r, "slug"), num, assignee)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
}

func (h *Handler) closeIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, true) }
func (h *Handler) reopenIssue(w http.ResponseWriter, r *http.Request) {
	h.toggleIssueStatus(w, r, false)
}

func (h *Handler) toggleIssueStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
	c, err := h.callerFromCtx(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	num, err := numberParam(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	if doClose {
		err = h.issues.Close(r.Context(), c, chi.URLParam(r, "slug"), num)
	} else {
		err = h.issues.Reopen(r.Context(), c, chi.URLParam(r, "slug"), num)
	}
	if err != nil {
		writeErr(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}
