// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
//
// SPDX-License-Identifier: LicenseRef-MutuaL-1.2

// Package cooked calls the Cooked customer API.
package cooked

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/big"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"

	"golang.org/x/net/publicsuffix"
)

const maxResponseBytes = 1 << 20

// Client is an authenticated Cooked API client.
type Client struct {
	baseURL  *url.URL
	username string
	password string
	http     *http.Client

	requestMu             sync.Mutex
	authenticatedUsername string
}

// ShoppingList is the authenticated user's Cooked shopping list.
type ShoppingList struct {
	Aisles  []Aisle  `json:"aisles"`
	Recipes []string `json:"recipes"`
}

// Aisle groups shopping-list product groups.
type Aisle struct {
	ID            string         `json:"aisle-id"`
	Name          string         `json:"aisle-name"`
	ProductGroups []ProductGroup `json:"product-groups"`
}

// ProductGroup is an item on the shopping list.
type ProductGroup struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Quantity string `json:"quantity"`
	Selected bool   `json:"selected"`
}

// AddShoppingListResult is the result of adding ingredients to a shopping list.
type AddShoppingListResult struct {
	AddedCount  int
	Ingredients []string
}

// ShoppingListProductGroupUpdate is a full shopping-list product-group update.
type ShoppingListProductGroupUpdate struct {
	Name     string
	Quantity string
	AisleID  string
	Selected bool
}

// RecipeCard is a saved recipe summary returned by Cooked list and search endpoints.
type RecipeCard struct {
	ID           string `json:"id"`
	Title        string `json:"title"`
	ThumbnailURL string `json:"thumbnail-url,omitempty"`
}

// RecipeMetadata is metadata for a Cooked recipe.
type RecipeMetadata struct {
	Title          string   `json:"title"`
	ImageURLs      []string `json:"image-urls"`
	Owner          string   `json:"owner"`
	EditPermission bool     `json:"edit-permission"`
}

// RecipeContent is the Markdown-style body and portions for a Cooked recipe.
type RecipeContent struct {
	Content  string `json:"content"`
	Portions int    `json:"portions"`
}

// RecipeTextPreview is Cooked's preview of raw recipe text before saving.
type RecipeTextPreview struct {
	Title    string `json:"title"`
	Markdown string `json:"markdown"`
	Portions int    `json:"portions"`
}

// UnmarshalJSON accepts Cooked's integer-valued decimal portions, such as 1.0.
func (p *RecipeTextPreview) UnmarshalJSON(data []byte) error {
	type raw RecipeTextPreview
	var response struct {
		raw
		Portions json.Number `json:"portions"`
	}
	if err := json.Unmarshal(data, &response); err != nil {
		return err
	}

	portions, err := jsonNumberToInt(response.Portions)
	if err != nil {
		return fmt.Errorf("portions: %w", err)
	}

	*p = RecipeTextPreview(response.raw)
	p.Portions = portions

	return nil
}

// RecipeURLImport is the result of importing a recipe URL.
type RecipeURLImport struct {
	RecipeID string
	DraftID  string
}

// NewClient returns a Cooked client with an in-memory cookie jar.
func NewClient(baseURL *url.URL, username, password string) (*Client, error) {
	jar, err := newCookieJar()
	if err != nil {
		return nil, fmt.Errorf("create cookie jar: %w", err)
	}

	return &Client{
		baseURL:  baseURL,
		username: username,
		password: password,
		http: &http.Client{
			Jar:     jar,
			Timeout: 30 * time.Second,
		},
	}, nil
}

// ReadShoppingList logs in when needed and returns the authenticated user's shopping list.
func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) {
	var response shoppingListResponse
	decodeShoppingList := func(decoder *json.Decoder) error {
		return decoder.Decode(&response)
	}
	if err := c.doAuthenticated(
		ctx,
		http.MethodGet,
		"/api/user/{username}/shopping-list",
		nil,
		decodeShoppingList,
	); err != nil {
		return ShoppingList{}, err
	}

	response.ShoppingList.Recipes = response.Recipes
	return response.ShoppingList, nil
}

// ClearShoppingList logs in when needed and clears the authenticated user's shopping list.
func (c *Client) ClearShoppingList(ctx context.Context) error {
	return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list", nil, nil)
}

// AddShoppingListIngredients logs in when needed and adds ingredients to the shopping list.
func (c *Client) AddShoppingListIngredients(
	ctx context.Context,
	ingredients, recipeID string,
) (AddShoppingListResult, error) {
	body, err := json.Marshal(addShoppingListRequest{Ingredients: ingredients, RecipeID: recipeID})
	if err != nil {
		return AddShoppingListResult{}, fmt.Errorf("encode Cooked shopping-list add request: %w", err)
	}

	var response addShoppingListResponse
	decodeAdd := func(decoder *json.Decoder) error {
		return decoder.Decode(&response)
	}
	if err := c.doAuthenticated(ctx, http.MethodPut, "/api/user/shopping-list", body, decodeAdd); err != nil {
		return AddShoppingListResult{}, err
	}
	if response.AddedCount == nil {
		return AddShoppingListResult{}, fmt.Errorf("add shopping-list ingredients response missing added count")
	}
	if strings.TrimSpace(ingredients) != "" && *response.AddedCount == 0 {
		return AddShoppingListResult{}, fmt.Errorf(
			"add shopping-list ingredients response returned added-count 0 for nonblank ingredients",
		)
	}

	return AddShoppingListResult{AddedCount: *response.AddedCount, Ingredients: response.Ingredients}, nil
}

// RemoveShoppingListProductGroups logs in when needed and removes shopping-list product groups.
func (c *Client) RemoveShoppingListProductGroups(ctx context.Context, ids []string) error {
	body, err := json.Marshal(removeShoppingListProductGroupsRequest{IDs: ids})
	if err != nil {
		return fmt.Errorf("encode Cooked shopping-list remove request: %w", err)
	}

	return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list/product-groups", body, nil)
}

// ReplaceShoppingListSelection logs in when needed and replaces the selected shopping-list product groups.
func (c *Client) ReplaceShoppingListSelection(ctx context.Context, ids []string) error {
	body, err := json.Marshal(replaceShoppingListSelectionRequest{Selected: ids})
	if err != nil {
		return fmt.Errorf("encode Cooked shopping-list selection request: %w", err)
	}

	return c.doAuthenticated(
		ctx,
		http.MethodPut,
		"/api/user/{username}/shopping-list/product-groups/selection",
		body,
		nil,
	)
}

// UpdateShoppingListProductGroup logs in when needed and updates a shopping-list product group.
func (c *Client) UpdateShoppingListProductGroup(
	ctx context.Context,
	productGroupID string,
	update ShoppingListProductGroupUpdate,
) error {
	body, err := json.Marshal(updateShoppingListProductGroupRequest(update))
	if err != nil {
		return fmt.Errorf("encode Cooked shopping-list product-group update request: %w", err)
	}

	path := "/api/user/{username}/shopping-list/product-groups/" + url.PathEscape(productGroupID)

	return c.doAuthenticated(ctx, http.MethodPut, path, body, nil)
}

// ListRecipes logs in when needed and returns one page of saved recipes.
func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard, error) {
	query := url.Values{}
	query.Set("page", fmt.Sprintf("%d", page))
	query.Set("page-count", fmt.Sprintf("%d", limit))

	return c.getRecipes(ctx, "/api/user/{username}/recipes?"+query.Encode())
}

// SearchRecipes logs in when needed and searches the authenticated user's saved recipes.
func (c *Client) SearchRecipes(ctx context.Context, queryText string, page int) ([]RecipeCard, error) {
	query := url.Values{}
	query.Set("q", queryText)
	query.Set("page", fmt.Sprintf("%d", page))

	return c.getRecipes(ctx, "/api/user/{username}/recipes/search?"+query.Encode())
}

// ReadRecipeMetadata logs in when needed and returns recipe metadata.
func (c *Client) ReadRecipeMetadata(ctx context.Context, recipeID string) (RecipeMetadata, error) {
	var metadata RecipeMetadata
	decodeMetadata := func(decoder *json.Decoder) error {
		return decoder.Decode(&metadata)
	}
	path := "/api/recipe/" + url.PathEscape(recipeID) + "/metadata"
	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeMetadata); err != nil {
		return RecipeMetadata{}, err
	}

	return metadata, nil
}

// ReadRecipeContent logs in when needed and returns recipe content and portions.
func (c *Client) ReadRecipeContent(ctx context.Context, recipeID string) (RecipeContent, error) {
	var content RecipeContent
	decodeContent := func(decoder *json.Decoder) error {
		return decoder.Decode(&content)
	}
	path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeContent); err != nil {
		return RecipeContent{}, err
	}

	return content, nil
}

// PreviewRecipeText logs in when needed and previews raw recipe text without saving it.
func (c *Client) PreviewRecipeText(ctx context.Context, title, text string) (RecipeTextPreview, error) {
	body, err := json.Marshal(previewRecipeTextRequest{RecipeName: title, RecipeText: text})
	if err != nil {
		return RecipeTextPreview{}, fmt.Errorf("encode Cooked recipe text preview request: %w", err)
	}

	var preview RecipeTextPreview
	decodePreview := func(decoder *json.Decoder) error {
		return decoder.Decode(&preview)
	}
	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new/from-text/preview", body, decodePreview); err != nil {
		return RecipeTextPreview{}, err
	}

	return preview, nil
}

// SavePreparedRecipe logs in when needed and saves prepared recipe markdown as a new recipe.
func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) {
	body, err := json.Marshal(savePreparedRecipeRequest{Title: title, Description: markdown, Portions: portions})
	if err != nil {
		return "", fmt.Errorf("encode Cooked prepared recipe save request: %w", err)
	}

	var response saveRecipeResponse
	decodeSave := func(decoder *json.Decoder) error {
		return decoder.Decode(&response)
	}
	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/recipe/import/save", body, decodeSave); err != nil {
		return "", err
	}
	if strings.TrimSpace(response.RecipeID) == "" {
		return "", fmt.Errorf("save prepared recipe response missing recipe ID")
	}

	return response.RecipeID, nil
}

// SaveRecipeDraft logs in when needed and saves a reviewed URL import draft.
func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions int) (string, error) {
	body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
	if err != nil {
		return "", fmt.Errorf("encode Cooked recipe draft save request: %w", err)
	}

	var response saveRecipeResponse
	decodeSave := func(decoder *json.Decoder) error {
		return decoder.Decode(&response)
	}
	path := "/api/extract/" + url.PathEscape(draftID) + "/save"
	if err := c.doAuthenticated(ctx, http.MethodPost, path, body, decodeSave); err != nil {
		return "", err
	}
	if strings.TrimSpace(response.RecipeID) == "" {
		return "", fmt.Errorf("save recipe draft response missing recipe ID")
	}

	return response.RecipeID, nil
}

// UpdateRecipeContent logs in when needed and updates an existing recipe's content.
func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error {
	body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
	if err != nil {
		return fmt.Errorf("encode Cooked recipe update request: %w", err)
	}

	path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"

	return c.doAuthenticated(ctx, http.MethodPost, path, body, nil)
}

// ImportRecipeURL logs in when needed and starts a recipe URL import.
func (c *Client) ImportRecipeURL(ctx context.Context, recipeURL string) (RecipeURLImport, error) {
	body, err := json.Marshal(importRecipeURLRequest{URL: recipeURL})
	if err != nil {
		return RecipeURLImport{}, fmt.Errorf("encode Cooked recipe URL import request: %w", err)
	}

	var response importRecipeURLResponse
	decodeImport := func(decoder *json.Decoder) error {
		return decoder.Decode(&response)
	}
	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new", body, decodeImport); err != nil {
		return RecipeURLImport{}, err
	}

	if strings.TrimSpace(response.RecipeID) == "" && strings.TrimSpace(response.ExtractionID) == "" {
		return RecipeURLImport{}, fmt.Errorf("import recipe URL response missing recipe or draft ID")
	}

	return RecipeURLImport{RecipeID: response.RecipeID, DraftID: response.ExtractionID}, nil
}

// DeleteRecipe logs in when needed and deletes an existing recipe.
func (c *Client) DeleteRecipe(ctx context.Context, recipeID string) error {
	path := "/api/recipe/" + url.PathEscape(recipeID)

	return c.doAuthenticated(ctx, http.MethodDelete, path, nil, nil)
}

func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) {
	var recipes []RecipeCard
	decodeRecipes := func(decoder *json.Decoder) error {
		return decoder.Decode(&recipes)
	}
	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeRecipes); err != nil {
		return nil, err
	}
	if err := validateRecipeCards(recipes); err != nil {
		return nil, err
	}

	return recipes, nil
}

func validateRecipeCards(recipes []RecipeCard) error {
	if recipes == nil {
		return fmt.Errorf("cooked recipe list response missing recipes")
	}

	for index, recipe := range recipes {
		if strings.TrimSpace(recipe.ID) == "" {
			return fmt.Errorf("cooked recipe list response recipe %d missing id", index)
		}
		if strings.TrimSpace(recipe.Title) == "" {
			return fmt.Errorf("cooked recipe list response recipe %d missing title", index)
		}
	}

	return nil
}

func jsonNumberToInt(number json.Number) (int, error) {
	text := number.String()
	if text == "" {
		return 0, fmt.Errorf("missing")
	}

	rational, ok := new(big.Rat).SetString(text)
	if !ok {
		return 0, fmt.Errorf("invalid JSON number %q", text)
	}
	if !rational.IsInt() {
		return 0, fmt.Errorf("must be integer-valued, got %s", text)
	}

	integer := rational.Num()
	if !integer.IsInt64() {
		return 0, fmt.Errorf("out of range, got %s", text)
	}

	value := integer.Int64()
	maxInt := int64(1<<(strconv.IntSize-1) - 1)
	minInt := -maxInt - 1
	if value < minInt || value > maxInt {
		return 0, fmt.Errorf("out of range for int, got %s", text)
	}

	return int(value), nil
}

func (c *Client) doAuthenticated(
	ctx context.Context,
	method, path string,
	body []byte,
	decodeResponse responseDecoder,
) error {
	c.requestMu.Lock()
	defer c.requestMu.Unlock()

	if err := c.ensureLogin(ctx); err != nil {
		return err
	}

	err := c.do(ctx, method, c.userPath(path), body, decodeResponse)
	if !isUnauthorized(err) {
		return err
	}

	c.resetSession()
	if err := c.ensureLogin(ctx); err != nil {
		return err
	}

	return c.do(ctx, method, c.userPath(path), body, decodeResponse)
}

func (c *Client) ensureLogin(ctx context.Context) error {
	authenticated := c.authenticatedUsername != ""
	if authenticated {
		return nil
	}

	body, err := json.Marshal(loginRequest{Username: c.username, Password: c.password})
	if err != nil {
		return fmt.Errorf("encode Cooked login request: %w", err)
	}

	var login loginResponse
	decodeLogin := func(decoder *json.Decoder) error {
		return decoder.Decode(&login)
	}
	if err := c.do(ctx, http.MethodPost, "/api/public/login", body, decodeLogin); err != nil {
		return fmt.Errorf("authenticate with Cooked: %w", err)
	}
	if login.Username == "" {
		return fmt.Errorf("authenticate with Cooked: missing username in login response")
	}

	c.authenticatedUsername = login.Username

	return nil
}

func (c *Client) resetSession() {
	jar, err := newCookieJar()
	if err != nil {
		return
	}

	c.authenticatedUsername = ""
	c.http = &http.Client{Jar: jar, Timeout: c.http.Timeout}
}

func (c *Client) do(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error {
	request, err := c.newRequest(ctx, method, path, body)
	if err != nil {
		return err
	}

	response, err := c.http.Do(request)
	if err != nil {
		return fmt.Errorf("call Cooked: %w", err)
	}
	defer func() {
		_ = response.Body.Close()
	}()

	if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
		return decodeError(response)
	}

	if decodeResponse == nil {
		return nil
	}
	if err := decodeResponse(json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes))); err != nil {
		return fmt.Errorf("decode Cooked response: %w", err)
	}

	return nil
}

func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
	relativeURL, err := url.Parse(path)
	if err != nil {
		return nil, fmt.Errorf("parse Cooked request path: %w", err)
	}
	requestURL := c.baseURL.ResolveReference(relativeURL)

	var reader io.Reader
	if body != nil {
		reader = bytes.NewReader(body)
	}

	request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), reader)
	if err != nil {
		return nil, fmt.Errorf("build Cooked request: %w", err)
	}
	request.Header.Set("Accept", "application/json")
	if body != nil {
		request.Header.Set("Content-Type", "application/json")
	}

	return request, nil
}

func (c *Client) userPath(path string) string {
	username := c.authenticatedUsername

	return strings.ReplaceAll(path, "{username}", url.PathEscape(username))
}

type responseDecoder func(*json.Decoder) error

type shoppingListResponse struct {
	ShoppingList ShoppingList `json:"shopping-list"`
	Recipes      []string     `json:"recipes"`
}

type addShoppingListRequest struct {
	Ingredients string `json:"ingredients"`
	RecipeID    string `json:"recipe-id,omitempty"`
}

type addShoppingListResponse struct {
	AddedCount  *int     `json:"added-count"`
	Ingredients []string `json:"ingredients"`
}

type removeShoppingListProductGroupsRequest struct {
	IDs []string `json:"ids"`
}

type replaceShoppingListSelectionRequest struct {
	Selected []string `json:"selected"`
}

type updateShoppingListProductGroupRequest struct {
	Name     string `json:"name"`
	Quantity string `json:"quantity"`
	AisleID  string `json:"aisle-id"`
	Selected bool   `json:"selected"`
}

type previewRecipeTextRequest struct {
	RecipeName string `json:"recipe-name"`
	RecipeText string `json:"recipe-text"`
}

type savePreparedRecipeRequest struct {
	Title       string `json:"title"`
	Description string `json:"description"`
	Portions    int    `json:"portions"`
}

type recipeContentRequest struct {
	Description string `json:"description"`
	Portions    int    `json:"portions"`
}

type importRecipeURLRequest struct {
	URL string `json:"url"`
}

type importRecipeURLResponse struct {
	RecipeID     string `json:"recipe-id"`
	ExtractionID string `json:"extraction-id"`
}

type saveRecipeResponse struct {
	RecipeID string `json:"recipe-id"`
}

type loginRequest struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

type loginResponse struct {
	Username string `json:"username"`
}

type cookedError struct {
	StatusCode int
	Code       string
	Message    string
}

func (e cookedError) Error() string {
	var parts []string
	parts = append(parts, fmt.Sprintf("Cooked returned HTTP %d", e.StatusCode))
	if e.Code != "" {
		parts = append(parts, e.Code)
	}
	if e.Message != "" {
		parts = append(parts, e.Message)
	}

	return strings.Join(parts, ": ")
}

func decodeError(response *http.Response) error {
	var payload struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	}
	_ = json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&payload)

	return cookedError{
		StatusCode: response.StatusCode,
		Code:       payload.Code,
		Message:    payload.Message,
	}
}

func isUnauthorized(err error) bool {
	if err == nil {
		return false
	}

	var cookedErr cookedError
	if !errors.As(err, &cookedErr) {
		return false
	}

	return cookedErr.StatusCode == http.StatusUnauthorized
}

func newCookieJar() (*cookiejar.Jar, error) {
	return cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
}
