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