// Package libretranslate provides an HTTP client for a self-hosted
// LibreTranslate instance. It handles text chunking, concurrent translation,
// and reassembly so callers can pass arbitrarily long markdown strings.
package libretranslate

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
	"sync"
	"time"
)

const (
	// maxChunkBytes is the target maximum size of each chunk sent to
	// LibreTranslate. LibreTranslate's default limit is 5000 characters;
	// we stay comfortably below that.
	maxChunkBytes = 4500
	// concurrency is the number of simultaneous translation requests per chapter.
	concurrency = 3
)

// Client translates text via LibreTranslate.
// A nil Client is valid — all calls return the original text unchanged.
type Client interface {
	// Translate translates text from sourceLang to targetLang.
	// text is a raw markdown string. The returned string is the translated
	// markdown, reassembled in original paragraph order.
	Translate(ctx context.Context, text, sourceLang, targetLang string) (string, error)
}

// New returns a Client for the given LibreTranslate URL.
// Returns nil when url is empty, which disables translation.
func New(url, apiKey string) Client {
	if url == "" {
		return nil
	}
	return &httpClient{
		url:    strings.TrimRight(url, "/"),
		apiKey: apiKey,
		http:   &http.Client{Timeout: 60 * time.Second},
	}
}

type httpClient struct {
	url    string
	apiKey string
	http   *http.Client
}

// Translate splits text into paragraph chunks, translates them concurrently
// (up to concurrency goroutines), and reassembles in order.
func (c *httpClient) Translate(ctx context.Context, text, sourceLang, targetLang string) (string, error) {
	paragraphs := splitParagraphs(text)
	if len(paragraphs) == 0 {
		return text, nil
	}
	chunks := binChunks(paragraphs, maxChunkBytes)

	translated := make([]string, len(chunks))
	errs := make([]error, len(chunks))

	sem := make(chan struct{}, concurrency)
	var wg sync.WaitGroup

	for i, chunk := range chunks {
		wg.Add(1)
		sem <- struct{}{}
		go func(idx int, chunkText string) {
			defer wg.Done()
			defer func() { <-sem }()
			result, err := c.translateChunk(ctx, chunkText, sourceLang, targetLang)
			translated[idx] = result
			errs[idx] = err
		}(i, chunk)
	}
	wg.Wait()

	for _, err := range errs {
		if err != nil {
			return "", err
		}
	}

	return strings.Join(translated, "\n\n"), nil
}

// translateChunk sends a single POST /translate request.
func (c *httpClient) translateChunk(ctx context.Context, text, sourceLang, targetLang string) (string, error) {
	reqBody := map[string]string{
		"q":      text,
		"source": sourceLang,
		"target": targetLang,
		"format": "html",
	}
	if c.apiKey != "" {
		reqBody["api_key"] = c.apiKey
	}

	b, err := json.Marshal(reqBody)
	if err != nil {
		return "", fmt.Errorf("libretranslate: marshal request: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/translate", bytes.NewReader(b))
	if err != nil {
		return "", fmt.Errorf("libretranslate: build request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return "", fmt.Errorf("libretranslate: request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		var errBody struct {
			Error string `json:"error"`
		}
		_ = json.NewDecoder(resp.Body).Decode(&errBody)
		return "", fmt.Errorf("libretranslate: status %d: %s", resp.StatusCode, errBody.Error)
	}

	var result struct {
		TranslatedText string `json:"translatedText"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", fmt.Errorf("libretranslate: decode response: %w", err)
	}
	return result.TranslatedText, nil
}

// splitParagraphs splits markdown text on blank lines, preserving non-empty paragraphs.
func splitParagraphs(text string) []string {
	// Normalise line endings.
	text = strings.ReplaceAll(text, "\r\n", "\n")
	// Split on double newlines (blank lines between paragraphs).
	parts := strings.Split(text, "\n\n")
	var paragraphs []string
	for _, p := range parts {
		p = strings.TrimSpace(p)
		if p != "" {
			paragraphs = append(paragraphs, p)
		}
	}
	return paragraphs
}

// binChunks groups paragraphs into chunks each at most maxBytes in length.
// Each chunk is a single string with paragraphs joined by "\n\n".
func binChunks(paragraphs []string, maxBytes int) []string {
	var chunks []string
	var current strings.Builder

	for _, p := range paragraphs {
		needed := len(p)
		if current.Len() > 0 {
			needed += 2 // for the "\n\n" separator
		}

		if current.Len()+needed > maxBytes && current.Len() > 0 {
			// Flush current chunk.
			chunks = append(chunks, current.String())
			current.Reset()
		}

		if current.Len() > 0 {
			current.WriteString("\n\n")
		}
		current.WriteString(p)
	}

	if current.Len() > 0 {
		chunks = append(chunks, current.String())
	}
	return chunks
}
