// Package meili provides a thin Meilisearch client for indexing and searching
// locally scraped books.
//
// Index:
//   - Name: "books"
//   - Primary key: "slug"
//   - Searchable attributes: title, author, genres, summary
//   - Filterable attributes: status, genres
//   - Sortable attributes: rank, rating, total_chapters, meta_updated
//
// The client is intentionally simple: UpsertBook and Search only. All
// Meilisearch-specific details (index management, attribute configuration)
// are handled once in Configure(), called at startup.
package meili

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"

	"github.com/libnovel/backend/internal/domain"
	"github.com/meilisearch/meilisearch-go"
)

const indexName = "books"

// Client is the interface for Meilisearch operations used by runner and backend.
type Client interface {
	// UpsertBook adds or updates a book document in the search index.
	UpsertBook(ctx context.Context, book domain.BookMeta) error
	// BookExists reports whether a book with the given slug is already in the
	// index. Used by the catalogue refresh to skip re-indexing known books.
	BookExists(ctx context.Context, slug string) bool
	// Search returns up to limit books matching query.
	Search(ctx context.Context, query string, limit int) ([]domain.BookMeta, error)
	// Catalogue queries books with optional filters, sort, and pagination.
	// Returns books, the total hit count for pagination, and a FacetResult
	// with available genre and status values from the index.
	Catalogue(ctx context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error)
}

// CatalogueQuery holds parameters for the /api/catalogue endpoint.
type CatalogueQuery struct {
	Q      string // full-text query (may be empty for browse)
	Genre  string // genre filter, e.g. "fantasy" or "all"
	Status string // status filter, e.g. "ongoing", "completed", or "all"
	Sort   string // sort field: "popular", "new", "update", "top-rated", "rank", ""
	Page   int    // 1-indexed
	Limit  int    // items per page, default 20
}

// FacetResult holds the available filter values discovered from the index.
// Values are sorted alphabetically and include only those present in the index.
type FacetResult struct {
	Genres   []string // distinct genre values
	Statuses []string // distinct status values
}

// MeiliClient wraps the meilisearch-go SDK.
type MeiliClient struct {
	idx meilisearch.IndexManager
}

// New creates a MeiliClient. Call Configure() once at startup to ensure the
// index exists and has the correct attribute settings.
func New(host, apiKey string) *MeiliClient {
	cli := meilisearch.New(host, meilisearch.WithAPIKey(apiKey))
	return &MeiliClient{idx: cli.Index(indexName)}
}

// Configure creates the index if absent and sets searchable/filterable
// attributes. It is idempotent — safe to call on every startup.
func Configure(host, apiKey string) error {
	cli := meilisearch.New(host, meilisearch.WithAPIKey(apiKey))

	// Create index with primary key. Returns 202 if exists — ignore.
	task, err := cli.CreateIndex(&meilisearch.IndexConfig{
		Uid:        indexName,
		PrimaryKey: "slug",
	})
	if err != nil {
		// 400 "index_already_exists" is not an error here; the SDK returns
		// an error with Code "index_already_exists" which we can ignore.
		// Any other error is fatal.
		if apiErr, ok := err.(*meilisearch.Error); ok && apiErr.MeilisearchApiError.Code == "index_already_exists" {
			// already exists — continue
		} else {
			return fmt.Errorf("meili: create index: %w", err)
		}
	} else {
		_ = task // task is async; we don't wait for it
	}

	idx := cli.Index(indexName)

	searchable := []string{"title", "author", "genres", "summary"}
	if _, err := idx.UpdateSearchableAttributes(&searchable); err != nil {
		return fmt.Errorf("meili: update searchable attributes: %w", err)
	}

	filterable := []interface{}{"status", "genres"}
	if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
		return fmt.Errorf("meili: update filterable attributes: %w", err)
	}

	sortable := []string{"rank", "rating", "total_chapters", "meta_updated"}
	if _, err := idx.UpdateSortableAttributes(&sortable); err != nil {
		return fmt.Errorf("meili: update sortable attributes: %w", err)
	}

	return nil
}

// bookDoc is the Meilisearch document shape for a book.
type bookDoc struct {
	Slug          string   `json:"slug"`
	Title         string   `json:"title"`
	Author        string   `json:"author"`
	Cover         string   `json:"cover"`
	Status        string   `json:"status"`
	Genres        []string `json:"genres"`
	Summary       string   `json:"summary"`
	TotalChapters int      `json:"total_chapters"`
	SourceURL     string   `json:"source_url"`
	Rank          int      `json:"rank"`
	Rating        float64  `json:"rating"`
	// MetaUpdated is the Unix timestamp (seconds) of the last PocketBase update.
	// Used for sort=update ("recently updated" ordering).
	MetaUpdated int64 `json:"meta_updated"`
}

func toDoc(b domain.BookMeta) bookDoc {
	return bookDoc{
		Slug:          b.Slug,
		Title:         b.Title,
		Author:        b.Author,
		Cover:         b.Cover,
		Status:        b.Status,
		Genres:        b.Genres,
		Summary:       b.Summary,
		TotalChapters: b.TotalChapters,
		SourceURL:     b.SourceURL,
		Rank:          b.Ranking,
		Rating:        b.Rating,
		MetaUpdated:   b.MetaUpdated,
	}
}

func fromDoc(d bookDoc) domain.BookMeta {
	return domain.BookMeta{
		Slug:          d.Slug,
		Title:         d.Title,
		Author:        d.Author,
		Cover:         d.Cover,
		Status:        d.Status,
		Genres:        d.Genres,
		Summary:       d.Summary,
		TotalChapters: d.TotalChapters,
		SourceURL:     d.SourceURL,
		Ranking:       d.Rank,
		Rating:        d.Rating,
		MetaUpdated:   d.MetaUpdated,
	}
}

// UpsertBook adds or replaces the book document in Meilisearch. The operation
// is fire-and-forget (Meilisearch processes tasks asynchronously).
func (c *MeiliClient) UpsertBook(_ context.Context, book domain.BookMeta) error {
	docs := []bookDoc{toDoc(book)}
	pk := "slug"
	if _, err := c.idx.AddDocuments(docs, &meilisearch.DocumentOptions{PrimaryKey: &pk}); err != nil {
		return fmt.Errorf("meili: upsert book %q: %w", book.Slug, err)
	}
	return nil
}

// BookExists reports whether the slug is already present in the index.
// It fetches the document by primary key; a 404 or any error is treated as
// "not present" (safe default: re-index rather than silently skip).
func (c *MeiliClient) BookExists(_ context.Context, slug string) bool {
	var doc bookDoc
	err := c.idx.GetDocument(slug, nil, &doc)
	return err == nil && doc.Slug != ""
}

// Search returns books matching query, up to limit results.
func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]domain.BookMeta, error) {
	if limit <= 0 {
		limit = 20
	}
	res, err := c.idx.Search(query, &meilisearch.SearchRequest{
		Limit: int64(limit),
	})
	if err != nil {
		return nil, fmt.Errorf("meili: search %q: %w", query, err)
	}

	books := make([]domain.BookMeta, 0, len(res.Hits))
	for _, hit := range res.Hits {
		// Hit is map[string]json.RawMessage — unmarshal directly into bookDoc.
		var doc bookDoc
		raw, err := json.Marshal(hit)
		if err != nil {
			continue
		}
		if err := json.Unmarshal(raw, &doc); err != nil {
			continue
		}
		books = append(books, fromDoc(doc))
	}
	return books, nil
}

// Catalogue queries books with optional full-text search, genre/status filters,
// sort order, and pagination. Returns matching books, the total estimate, and
// a FacetResult containing available genre and status values from the index.
func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error) {
	if q.Limit <= 0 {
		q.Limit = 20
	}
	if q.Page <= 0 {
		q.Page = 1
	}

	req := &meilisearch.SearchRequest{
		Limit:  int64(q.Limit),
		Offset: int64((q.Page - 1) * q.Limit),
		// Request facet distribution so the UI can build filter options
		// dynamically without hardcoding genre/status lists.
		Facets: []string{"genres", "status"},
	}

	// Build filter
	var filters []string
	if q.Genre != "" && q.Genre != "all" {
		filters = append(filters, fmt.Sprintf("genres = %q", q.Genre))
	}
	if q.Status != "" && q.Status != "all" {
		filters = append(filters, fmt.Sprintf("status = %q", q.Status))
	}
	if len(filters) > 0 {
		req.Filter = strings.Join(filters, " AND ")
	}

	// Map UI sort tokens to Meilisearch sort expressions.
	switch q.Sort {
	case "rank":
		req.Sort = []string{"rank:asc"}
	case "top-rated":
		req.Sort = []string{"rating:desc"}
	case "new":
		req.Sort = []string{"total_chapters:desc"}
	case "update":
		req.Sort = []string{"meta_updated:desc"}
		// "popular" and "" → relevance (no explicit sort)
	}

	res, err := c.idx.Search(q.Q, req)
	if err != nil {
		return nil, 0, FacetResult{}, fmt.Errorf("meili: catalogue query: %w", err)
	}

	books := make([]domain.BookMeta, 0, len(res.Hits))
	for _, hit := range res.Hits {
		var doc bookDoc
		raw, err := json.Marshal(hit)
		if err != nil {
			continue
		}
		if err := json.Unmarshal(raw, &doc); err != nil {
			continue
		}
		books = append(books, fromDoc(doc))
	}

	facets := parseFacets(res.FacetDistribution)
	return books, res.EstimatedTotalHits, facets, nil
}

// parseFacets extracts sorted genre and status slices from a Meilisearch
// facetDistribution raw JSON value.
// The JSON shape is: {"genres":{"fantasy":12,"action":5},"status":{"ongoing":7}}
func parseFacets(raw json.RawMessage) FacetResult {
	var result FacetResult
	if len(raw) == 0 {
		return result
	}
	var dist map[string]map[string]int64
	if err := json.Unmarshal(raw, &dist); err != nil {
		return result
	}
	if m, ok := dist["genres"]; ok {
		for k := range m {
			result.Genres = append(result.Genres, k)
		}
		sortStrings(result.Genres)
	}
	if m, ok := dist["status"]; ok {
		for k := range m {
			result.Statuses = append(result.Statuses, k)
		}
		sortStrings(result.Statuses)
	}
	return result
}

// sortStrings sorts a slice of strings in place.
func sortStrings(s []string) {
	for i := 1; i < len(s); i++ {
		for j := i; j > 0 && s[j] < s[j-1]; j-- {
			s[j], s[j-1] = s[j-1], s[j]
		}
	}
}

// NoopClient is a no-op Client used when Meilisearch is not configured.
type NoopClient struct{}

func (NoopClient) UpsertBook(_ context.Context, _ domain.BookMeta) error { return nil }
func (NoopClient) BookExists(_ context.Context, _ string) bool           { return false }
func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) {
	return nil, nil
}
func (NoopClient) Catalogue(_ context.Context, _ CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error) {
	return nil, 0, FacetResult{}, nil
}
