1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package lunatask
  6
  7import (
  8	"bytes"
  9	"context"
 10	"encoding/json"
 11	"errors"
 12	"fmt"
 13	"io"
 14	"net/http"
 15	"strings"
 16
 17	"github.com/go-playground/validator/v10"
 18)
 19
 20// Source represents a task source like GitHub or other integrations
 21type Source struct {
 22	Source   string `json:"source"`
 23	SourceID string `json:"source_id"`
 24}
 25
 26// Task is only ever used in responses
 27type Task struct {
 28	ID             string   `json:"id,omitempty"`
 29	AreaID         string   `json:"area_id,omitempty"`
 30	GoalID         string   `json:"goal_id,omitempty"`
 31	Status         string   `json:"status,omitempty"`
 32	PreviousStatus string   `json:"previous_status,omitempty"`
 33	Estimate       int      `json:"estimate,omitempty"`
 34	Priority       int      `json:"priority,omitempty"`
 35	Progress       int      `json:"progress,omitempty"`
 36	Motivation     string   `json:"motivation,omitempty"`
 37	Eisenhower     int      `json:"eisenhower,omitempty"`
 38	Sources        []Source `json:"sources,omitempty"`
 39	ScheduledOn    string   `json:"scheduled_on,omitempty"`
 40	CompletedAt    string   `json:"completed_at,omitempty"`
 41	CreatedAt      string   `json:"created_at,omitempty"`
 42	UpdatedAt      string   `json:"updated_at,omitempty"`
 43}
 44
 45// CreateTaskRequest represents the request to create a task in Lunatask
 46type CreateTaskRequest struct {
 47	AreaID      string `json:"area_id,omitempty" validate:"omitempty,uuid4"`
 48	GoalID      string `json:"goal_id,omitempty" validate:"omitempty,uuid4"` // Assuming GoalID remains optional for tasks
 49	Name        string `json:"name" validate:"required,max=100"`
 50	Note        string `json:"note,omitempty" validate:"omitempty"`
 51	Status      string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
 52	Motivation  string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
 53	Estimate    int    `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
 54	Priority    int    `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
 55	Eisenhower  int    `json:"eisenhower,omitempty" validate:"omitempty,min=0,max=4"`
 56	ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
 57	CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
 58	Source      string `json:"source,omitempty" validate:"omitempty"`
 59}
 60
 61// CreateTaskResponse represents the response from Lunatask API when creating a task
 62type CreateTaskResponse struct {
 63	Task struct {
 64		ID string `json:"id"`
 65	} `json:"task"`
 66}
 67
 68// UpdateTaskResponse represents the response from Lunatask API when updating a task
 69type UpdateTaskResponse struct {
 70	Task Task `json:"task"`
 71}
 72
 73// DeleteTaskResponse represents the response from Lunatask API when deleting a task
 74type DeleteTaskResponse struct {
 75	Task Task `json:"task"`
 76}
 77
 78// ValidationError represents errors returned by the validator
 79type ValidationError struct {
 80	Field   string
 81	Tag     string
 82	Message string
 83}
 84
 85// Error implements the error interface for ValidationError
 86func (e ValidationError) Error() string {
 87	return e.Message
 88}
 89
 90// ValidateTask validates the create task request
 91func ValidateTask(task *CreateTaskRequest) error {
 92	validate := validator.New()
 93	if err := validate.Struct(task); err != nil {
 94		var invalidValidationError *validator.InvalidValidationError
 95		if errors.As(err, &invalidValidationError) {
 96			return fmt.Errorf("invalid validation error: %w", err)
 97		}
 98
 99		var validationErrs validator.ValidationErrors
100		if errors.As(err, &validationErrs) {
101			var msgBuilder strings.Builder
102			msgBuilder.WriteString("task validation failed:")
103			for _, e := range validationErrs {
104				fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
105			}
106			return errors.New(msgBuilder.String())
107		}
108		return fmt.Errorf("validation error: %w", err)
109	}
110	return nil
111}
112
113// CreateTask creates a new task in Lunatask
114func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*CreateTaskResponse, error) {
115	// Validate the task
116	if err := ValidateTask(task); err != nil {
117		return nil, err
118	}
119
120	// Marshal the task to JSON
121	payloadBytes, err := json.Marshal(task)
122	if err != nil {
123		return nil, fmt.Errorf("failed to marshal payload: %w", err)
124	}
125
126	// Create the request
127	req, err := http.NewRequestWithContext(
128		ctx,
129		"POST",
130		fmt.Sprintf("%s/tasks", c.BaseURL),
131		bytes.NewBuffer(payloadBytes),
132	)
133	if err != nil {
134		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
135	}
136
137	// Set headers
138	req.Header.Set("Content-Type", "application/json")
139	req.Header.Set("Authorization", "bearer "+c.AccessToken)
140
141	// Send the request
142	resp, err := c.HTTPClient.Do(req)
143	if err != nil {
144		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
145	}
146	defer func() {
147		if resp.Body != nil {
148			if err := resp.Body.Close(); err != nil {
149				// We're in a defer, so we can only log the error
150				fmt.Printf("Error closing response body: %v\n", err)
151			}
152		}
153	}()
154
155	// Handle already exists (no content) case
156	if resp.StatusCode == http.StatusNoContent {
157		return nil, nil // Task already exists (not an error)
158	}
159
160	// Handle error status codes
161	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
162		respBody, _ := io.ReadAll(resp.Body)
163		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
164	}
165
166	// Read and parse the response
167	respBody, err := io.ReadAll(resp.Body)
168	if err != nil {
169		return nil, fmt.Errorf("failed to read response body: %w", err)
170	}
171
172	var response CreateTaskResponse
173	err = json.Unmarshal(respBody, &response)
174	if err != nil {
175		return nil, fmt.Errorf("failed to parse response: %w", err)
176	}
177
178	return &response, nil
179}
180
181// UpdateTask updates an existing task in Lunatask
182func (c *Client) UpdateTask(ctx context.Context, taskID string, task *CreateTaskRequest) (*UpdateTaskResponse, error) {
183	if taskID == "" {
184		return nil, errors.New("task ID cannot be empty")
185	}
186
187	// Validate the task payload
188	// Note: ValidateTask checks fields like Name, Priority, Estimate, etc.
189	// It's assumed that the API handles partial updates correctly,
190	// especially for fields like Name or AreaID in CreateTaskRequest that lack `omitempty`.
191	if err := ValidateTask(task); err != nil {
192		return nil, err
193	}
194
195	// Marshal the task to JSON
196	payloadBytes, err := json.Marshal(task)
197	if err != nil {
198		return nil, fmt.Errorf("failed to marshal payload: %w", err)
199	}
200
201	// Create the request
202	req, err := http.NewRequestWithContext(
203		ctx,
204		"PUT",
205		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
206		bytes.NewBuffer(payloadBytes),
207	)
208	if err != nil {
209		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
210	}
211
212	// Set headers
213	req.Header.Set("Content-Type", "application/json")
214	req.Header.Set("Authorization", "bearer "+c.AccessToken)
215
216	// Send the request
217	resp, err := c.HTTPClient.Do(req)
218	if err != nil {
219		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
220	}
221	defer func() {
222		if resp.Body != nil {
223			if err := resp.Body.Close(); err != nil {
224				fmt.Printf("Error closing response body: %v\n", err)
225			}
226		}
227	}()
228
229	// Handle error status codes
230	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
231		respBody, _ := io.ReadAll(resp.Body)
232		// Consider specific handling for 404 Not Found if needed
233		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
234	}
235
236	// Read and parse the response
237	respBody, err := io.ReadAll(resp.Body)
238	if err != nil {
239		return nil, fmt.Errorf("failed to read response body: %w", err)
240	}
241
242	var response UpdateTaskResponse
243	err = json.Unmarshal(respBody, &response)
244	if err != nil {
245		return nil, fmt.Errorf("failed to parse response: %w", err)
246	}
247
248	return &response, nil
249}
250
251// DeleteTask deletes a task in Lunatask
252func (c *Client) DeleteTask(ctx context.Context, taskID string) (*DeleteTaskResponse, error) {
253	if taskID == "" {
254		return nil, errors.New("task ID cannot be empty")
255	}
256
257	// Create the request
258	req, err := http.NewRequestWithContext(
259		ctx,
260		"DELETE",
261		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
262		nil,
263	)
264	if err != nil {
265		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
266	}
267
268	// Set headers
269	req.Header.Set("Authorization", "bearer "+c.AccessToken)
270
271	// Send the request
272	resp, err := c.HTTPClient.Do(req)
273	if err != nil {
274		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
275	}
276	defer func() {
277		if resp.Body != nil {
278			if err := resp.Body.Close(); err != nil {
279				fmt.Printf("Error closing response body: %v\n", err)
280			}
281		}
282	}()
283
284	// Handle error status codes
285	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
286		respBody, _ := io.ReadAll(resp.Body)
287		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
288	}
289
290	// Read and parse the response
291	respBody, err := io.ReadAll(resp.Body)
292	if err != nil {
293		return nil, fmt.Errorf("failed to read response body: %w", err)
294	}
295
296	var response DeleteTaskResponse
297	err = json.Unmarshal(respBody, &response)
298	if err != nil {
299		return nil, fmt.Errorf("failed to parse response: %w", err)
300	}
301
302	return &response, nil
303}