package services

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	configs "astrology-api/configs"
	userdto "astrology-api/dto/kundali"
	usermodel "astrology-api/models/usermodel"
	"astrology-api/notifications"
	repositories "astrology-api/repositories"

	"errors"

	"gorm.io/gorm"
)

type KundaliService interface {
	AddKundali(userID uint, req *userdto.AddKundaliRequest) (*userdto.AddKundaliResponse, error)

	GenerateKundliViaVedic(lang string, name string, lat float64, long float64, dob string, tob string, timezone float64, pob string, pdfType string, matchType string) (*GeneratedKundaliResult, error)

	DownloadAndSavePDF(pdfURL string, name string) (string, error)

	GetKundalis(userID uint, req *userdto.GetKundaliRequest) (*userdto.GetKundaliResponse, error)

	ShowKundali(userID uint, kundaliID uint) (*userdto.ShowKundaliResponse, error)

	UpdateKundali(userID uint, kundaliID uint, req *userdto.UpdateKundaliRequest) (*userdto.UpdateKundaliResponse, error)

	DeleteKundali(userID uint, req *userdto.DeleteKundaliRequest) (*userdto.DeleteKundaliResponse, error)

	DownloadKundali(userID uint, kundaliID uint) (*userdto.DownloadKundaliResponse, error)

	GenerateCompleteKundli(opts KundaliOptions) (*CompleteKundaliResult, error)

	ChartImage(req userdto.ChartImageRequest) (*ChartImage, error)
}

// KundaliOptions is one person's birth details plus what to fetch for them.
type KundaliOptions struct {
	Lang string

	Name string

	Gender string

	Latitude float64

	Longitude float64

	// BirthDate is YYYY-MM-DD, BirthTime is HH:MM.
	BirthDate string

	BirthTime string

	Timezone float64

	BirthPlace string

	PDFType string

	// ChartStyle is "north" or "south".
	ChartStyle string

	// Sections limits which response groups are assembled, and with them which
	// upstream calls are made. Empty means every group.
	Sections []string

	// SkipPDF skips building the report PDF.
	SkipPDF bool

	// VendorPDF additionally asks VedicAstroAPI for its own report PDF. Opt-in:
	// the endpoint is not on every plan and currently fails on this one.
	VendorPDF bool
}

// CompleteKundaliResult is everything one generation produced.
//
// A missing PDF is not a failure: the Vedic plan's pdf/horoscope endpoint is
// not always available, so the reason lands in Errors["pdf"] and the JSON
// sections are returned regardless.
type CompleteKundaliResult struct {
	LocalPDFPath string

	LocalPDFURL string

	VedicPDFURL string

	Details *userdto.KundaliDetails

	DetailsJSON string

	Errors map[string]string

	FromCache bool
}

// ChartImage is a chart proxied from VedicAstroAPI, ready to stream back.
type ChartImage struct {
	ContentType string

	Body []byte
}

type kundaliService struct {
	db          *gorm.DB
	kundaliRepo repositories.KundaliRepository
}

func NewKundaliService() KundaliService {
	return &kundaliService{
		db:          configs.DB,
		kundaliRepo: repositories.NewKundaliRepository(configs.DB),
	}
}

type GeneratedKundaliResult struct {
	LocalPDFURL string
	VedicPDFURL string
	DetailsJSON string
}

//////////////////////////////////////////////////////////////
// PDF Storage
//////////////////////////////////////////////////////////////

// Generated PDFs live under uploads/, which main.go serves statically at
// /uploads. The original code wrote them to public/kundli and advertised them
// at /kundli/..., a path nothing serves, so every download link 404ed.
const (
	kundaliPDFDir = "uploads/kundli"

	kundaliPDFRoute = "uploads/kundli"
)

//////////////////////////////////////////////////////////////
// Section Groups
//////////////////////////////////////////////////////////////

// Section group names accepted in AddKundaliRequest.Sections. Each one is a
// top-level key of the response, so asking for a group and reading it back
// use the same vocabulary.
const (
	SectionBasic = "basic"

	SectionChart = "chart"

	SectionKP = "kp"

	SectionAshtakvarga = "ashtakvarga"

	SectionDasha = "dasha"

	SectionReports = "reports"

	SectionOthers = "others"
)

// defaultSections is what an empty "sections" asks for: the complete kundali.
// It is the most expensive request the API makes — around forty metered
// upstream calls — so a screen that renders one tab should name its group.
var defaultSections = []string{
	SectionBasic,
	SectionChart,
	SectionKP,
	SectionAshtakvarga,
	SectionDasha,
	SectionReports,
	SectionOthers,
}

// resolveSections turns the requested list into a lookup. Unknown names are
// ignored rather than rejected, so a newer app asking for a section this build
// does not know about still gets a kundali.
func resolveSections(requested []string) map[string]bool {

	selected := map[string]bool{}

	names := requested

	if len(names) == 0 {
		names = defaultSections
	}

	for _, name := range names {
		selected[strings.ToLower(strings.TrimSpace(name))] = true
	}

	return selected
}

//////////////////////////////////////////////////////////////
// Details Cache
//////////////////////////////////////////////////////////////

// One complete kundali costs upwards of twenty metered VedicAstroAPI calls, and
// generation is now free and reachable without a login. Memoising by birth
// details keeps a retry, a double tap, or two people casting the same chart
// from spending the quota twice. In-process only — Redis is not in play here.
type cachedKundaliDetails struct {
	result *CompleteKundaliResult

	expiresAt time.Time
}

const (
	detailsCacheTTL = 24 * time.Hour

	detailsCacheMaxEntries = 500
)

var (
	detailsCacheMu sync.Mutex

	detailsCache = map[string]cachedKundaliDetails{}
)

// detailsCacheKey covers every input that changes the astrology, plus the
// section selection — a cached "core only" result must not satisfy a later
// request that also wants the planet reports.
func detailsCacheKey(opts KundaliOptions, sections map[string]bool) string {

	names := make([]string, 0, len(sections))

	for name, on := range sections {
		if on {
			names = append(names, name)
		}
	}

	sort.Strings(names)

	return strings.Join([]string{
		opts.BirthDate,
		opts.BirthTime,
		fmt.Sprintf("%f", opts.Latitude),
		fmt.Sprintf("%f", opts.Longitude),
		fmt.Sprintf("%.2f", opts.Timezone),
		opts.Lang,
		opts.ChartStyle,
		fmt.Sprintf("%t/%t", opts.SkipPDF, opts.VendorPDF),
		strings.Join(names, ","),
	}, "|")
}

func cachedDetails(key string) *CompleteKundaliResult {

	detailsCacheMu.Lock()

	defer detailsCacheMu.Unlock()

	entry, found := detailsCache[key]

	if !found {
		return nil
	}

	if time.Now().After(entry.expiresAt) {

		delete(detailsCache, key)

		return nil
	}

	// Copy, so a caller mutating Errors cannot corrupt the cached entry.
	copied := *entry.result

	copied.Errors = copyErrors(entry.result.Errors)

	copied.FromCache = true

	return &copied
}

func storeCachedDetails(key string, result *CompleteKundaliResult) {

	detailsCacheMu.Lock()

	defer detailsCacheMu.Unlock()

	// Crude bound: the map is a cache, not a store, so dropping the whole thing
	// when it grows too large is cheaper than tracking an eviction order.
	if len(detailsCache) >= detailsCacheMaxEntries {
		detailsCache = map[string]cachedKundaliDetails{}
	}

	stored := *result

	stored.Errors = copyErrors(result.Errors)

	stored.FromCache = false

	detailsCache[key] = cachedKundaliDetails{
		result:    &stored,
		expiresAt: time.Now().Add(detailsCacheTTL),
	}
}

func copyErrors(src map[string]string) map[string]string {

	if src == nil {
		return nil
	}

	dst := make(map[string]string, len(src))

	for key, value := range src {
		dst[key] = value
	}

	return dst
}

//////////////////////////////////////////////////////////////
// Add / Generate Kundali
//////////////////////////////////////////////////////////////

// applyKundaliDefaults fills the fields the apps routinely leave out.
func applyKundaliDefaults(item userdto.KundaliInput) userdto.KundaliInput {

	if item.Lang == "" {
		item.Lang = "en"
	}

	if item.PdfType == "" {
		item.PdfType = "small"
	}

	if item.MatchType == "" {
		item.MatchType = "north"
	}

	if item.Timezone == 0 {
		item.Timezone = 5.5
	}

	return item
}

// AddKundali generates a complete kundali for every person in the request.
//
// Three things differ from the original behaviour, all deliberate:
//
//   - It is free. No wallet is read and none is charged. req.Amount is accepted
//     and ignored: a price taken from the request body let a modified client
//     set its own, and this service now has no price at all.
//   - It casts a chart for anybody. A person is described entirely by the birth
//     details in the request, so there is nothing tying a kundali to the
//     account holder.
//   - userID 0 means an unauthenticated guest. The kundali is generated and
//     returned in full, but nothing is written to the database — no account
//     would own the row, and no authenticated endpoint could read it back. A
//     guest's kundali lives in the response only.
//
// Generation runs before the transaction opens. A complete kundali is twenty-odd
// upstream HTTP calls, and the original code held a MySQL transaction open
// across all of them, pinning a connection for the better part of a minute.
func (s *kundaliService) AddKundali(
	userID uint,
	req *userdto.AddKundaliRequest,
) (*userdto.AddKundaliResponse, error) {

	if req == nil || len(req.Kundali) == 0 {
		return nil, errors.New("kundali data is required")
	}

	isGuest := userID == 0

	type pendingKundali struct {
		input userdto.KundaliInput

		birthDate time.Time

		generated *CompleteKundaliResult
	}

	pending := make([]pendingKundali, 0, len(req.Kundali))

	// STEP 1: GENERATE — no database transaction held open across the network.

	for _, item := range req.Kundali {

		item = applyKundaliDefaults(item)

		birthDate, err := time.Parse("2006-01-02", item.BirthDate)

		if err != nil {

			return nil, fmt.Errorf(
				"invalid birth date %q, expected YYYY-MM-DD",
				item.BirthDate,
			)
		}

		generated, err := s.GenerateCompleteKundli(KundaliOptions{
			Lang:       item.Lang,
			Name:       item.Name,
			Gender:     item.Gender,
			Latitude:   item.Latitude,
			Longitude:  item.Longitude,
			BirthDate:  item.BirthDate,
			BirthTime:  item.BirthTime,
			Timezone:   item.Timezone,
			BirthPlace: item.BirthPlace,
			PDFType:    item.PdfType,
			ChartStyle: item.MatchType,
			Sections:   req.Sections,
			SkipPDF:    req.SkipPDF,
			VendorPDF:  req.VendorPDF,
		})

		if err != nil {
			return nil, err
		}

		pending = append(pending, pendingKundali{
			input:     item,
			birthDate: birthDate,
			generated: generated,
		})
	}

	// STEP 2: PERSIST — authenticated callers only, in one short transaction.

	recordList := make([]usermodel.Kundali, 0, len(pending))

	saved := make([]bool, len(pending))

	if isGuest {

		for _, entry := range pending {

			record := usermodel.Kundali{}

			applyKundaliValues(&record, entry.input, entry.birthDate, entry.generated, 0)

			recordList = append(recordList, record)
		}

	} else {

		tx := s.db.Begin()

		if tx.Error != nil {
			return nil, tx.Error
		}

		defer func() {
			if r := recover(); r != nil {
				tx.Rollback()
				panic(r)
			}
		}()

		for index, entry := range pending {

			// An id is only honoured when the row belongs to this user, so a
			// stale id, or somebody else's id, creates a new kundali instead of
			// overwriting a record that is not ours.
			var existing *usermodel.Kundali

			if entry.input.ID > 0 {

				found, err := s.kundaliRepo.FindUserKundali(userID, entry.input.ID)

				if err != nil {

					if !errors.Is(err, gorm.ErrRecordNotFound) {

						tx.Rollback()

						return nil, err
					}

				} else {
					existing = found
				}
			}

			if existing != nil {

				applyKundaliValues(existing, entry.input, entry.birthDate, entry.generated, userID)

				if err := s.kundaliRepo.UpdateKundali(tx, existing); err != nil {

					tx.Rollback()

					return nil, err
				}

				recordList = append(recordList, *existing)

				saved[index] = true

				continue
			}

			record := usermodel.Kundali{}

			applyKundaliValues(&record, entry.input, entry.birthDate, entry.generated, userID)

			record.CreatedBy = int(userID)

			if err := s.kundaliRepo.CreateKundali(tx, &record); err != nil {

				tx.Rollback()

				return nil, err
			}

			recordList = append(recordList, record)

			saved[index] = true
		}

		if err := tx.Commit().Error; err != nil {

			tx.Rollback()

			return nil, err
		}

		// The reports are saved and openable, so tell the customer. After
		// the commit, and only for a signed-in user — a guest kundali has
		// no account to notify.
		if !isGuest {

			for index := range recordList {

				notifications.Notify().KundaliGenerated(
					userID,
					recordList[index].ID,
					recordList[index].Name,
				)
			}
		}
	}

	// STEP 3: RESPONSE — the saved rows plus the complete third-party details.

	completeDetails := make([]userdto.CompleteKundaliResponse, 0, len(pending))

	for index, entry := range pending {

		completeDetails = append(completeDetails, userdto.CompleteKundaliResponse{
			ID:   recordList[index].ID,
			Name: entry.input.Name,
			BirthDetails: &userdto.KundaliBirthDetails{
				Name:       entry.input.Name,
				Gender:     entry.input.Gender,
				BirthDate:  entry.input.BirthDate,
				BirthTime:  entry.input.BirthTime,
				BirthPlace: entry.input.BirthPlace,
				Latitude:   entry.input.Latitude,
				Longitude:  entry.input.Longitude,
				Timezone:   entry.input.Timezone,
				Lang:       entry.input.Lang,
				ChartStyle: entry.input.MatchType,
			},
			PDFURL:      entry.generated.LocalPDFURL,
			VedicPDFURL: entry.generated.VedicPDFURL,
			IsGuest:     isGuest,
			Saved:       saved[index],
			Cached:      entry.generated.FromCache,
			Details:     entry.generated.Details,
			Errors:      entry.generated.Errors,
		})
	}

	return &userdto.AddKundaliResponse{
		Message:         "Kundli generated successfully",
		RecordList:      recordList,
		Status:          200,
		CompleteDetails: completeDetails,
	}, nil
}

// applyKundaliValues copies one request item and its generated output onto a
// record, shared by the create and update paths so the two cannot drift.
func applyKundaliValues(
	record *usermodel.Kundali,
	item userdto.KundaliInput,
	birthDate time.Time,
	generated *CompleteKundaliResult,
	userID uint,
) {

	latitude := item.Latitude

	longitude := item.Longitude

	timezone := fmt.Sprintf("%.1f", item.Timezone)

	forMatch := "0"

	if item.ForMatch {
		forMatch = "yes"
	}

	record.Name = item.Name
	record.Gender = item.Gender
	record.BirthDate = birthDate
	record.BirthTime = item.BirthTime
	record.BirthPlace = item.BirthPlace

	record.Latitude = &latitude
	record.Longitude = &longitude
	record.Timezone = &timezone

	record.PDFType = item.PdfType
	record.MatchType = item.MatchType
	record.ForMatch = forMatch

	// Keep whatever PDF the record already had when this run produced none, so
	// a regeneration that could not reach the PDF endpoint does not erase a
	// working download link.
	if generated.LocalPDFURL != "" {

		localPDF := generated.LocalPDFURL

		record.PDFLink = &localPDF
	}

	if generated.VedicPDFURL != "" {

		vedicPDF := generated.VedicPDFURL

		record.VedicPDFLink = &vedicPDF
	}

	if generated.DetailsJSON != "" {

		detailsJSON := generated.DetailsJSON

		record.DetailsJSON = &detailsJSON
	}

	record.ModifiedBy = int(userID)
}

func (s *kundaliService) GenerateKundliViaVedic(lang string, name string, lat float64, long float64, dob string, tob string, timezone float64, pob string, pdfType string, matchType string) (*GeneratedKundaliResult, error) {

	// GET VEDIC API KEY

	apiKey, err := s.kundaliRepo.GetVedicAPIKey()

	if err != nil {
		return nil, fmt.Errorf("failed to get Vedic API key: %w", err)
	}

	if lang == "" {
		lang = "en"
	}

	if pdfType == "" {
		pdfType = "small"
	}

	if matchType == "" {
		matchType = "north"
	}

	if timezone == 0 {
		timezone = 5.5
	}

	// PARSE DOB
	parsedDate, err := time.Parse("2006-01-02", dob)
	if err != nil {
		return nil, fmt.Errorf(
			"invalid birth date %q, expected YYYY-MM-DD: %w",
			dob,
			err,
		)
	}

	formattedDOB := parsedDate.Format("02/01/2006")

	if _, err := time.Parse("15:04", tob); err != nil {
		// Try HH:MM:SS also
		if _, err2 := time.Parse("15:04:05", tob); err2 != nil {
			return nil, fmt.Errorf(
				"invalid birth time %q, expected HH:MM",
				tob,
			)
		}
	}

	// Vedic API requires DD/MM/YYYY

	// VEDIC PDF API

	baseURL := "https://api.vedicastroapi.com/v3-json/pdf/horoscope"
	params := url.Values{}
	params.Add("name", name)
	params.Add("dob", formattedDOB)
	params.Add("tob", tob)
	params.Add("lat", fmt.Sprintf("%f", lat))
	params.Add("lon", fmt.Sprintf("%f", long))
	params.Add("tz", fmt.Sprintf("%.1f", timezone))
	params.Add("pob", pob)
	params.Add("api_key", apiKey)
	params.Add("lang", lang)
	params.Add("style", matchType)
	params.Add("color", "140")
	params.Add("pdf_type", pdfType)
	fullURL := baseURL + "?" + params.Encode()

	client := &http.Client{
		Timeout: 60 * time.Second,
	}

	// CALL VEDIC API
	resp, err := client.Get(fullURL)
	if err != nil {
		return nil, fmt.Errorf(
			"failed to connect to Vedic API: %w",
			err,
		)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)

	if err != nil {
		return nil, fmt.Errorf(
			"failed to read Vedic API response: %w",
			err,
		)
	}

	// HTTP STATUS CHECK

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {

		var apiError struct {
			Message string      `json:"message"`
			Status  interface{} `json:"status"`
		}

		if err := json.Unmarshal(body, &apiError); err == nil {

			if strings.TrimSpace(apiError.Message) != "" {

				return nil, errors.New(
					apiError.Message,
				)
			}
		}

		message := strings.TrimSpace(string(body))

		if message == "" {

			message = fmt.Sprintf(
				"Vedic API returned HTTP %d",
				resp.StatusCode,
			)
		}

		return nil, errors.New(message)
		// body, _ := io.ReadAll(resp.Body)

		// return nil, fmt.Errorf(
		// 	"vedic api returned status %d: %s",
		// 	resp.StatusCode,
		// 	string(body),
		// )
	}
	// KEEP COMPLETE THIRD-PARTY RESPONSE

	detailsJSON := string(body)

	// PARSE VEDIC RESPONSE

	var vedicResponse userdto.VedicPDFResponse

	err = json.Unmarshal(
		body,
		&vedicResponse,
	)

	if err != nil {
		return nil, err
	}

	// CHECK PDF URL

	if vedicResponse.Response == "" {

		return nil, fmt.Errorf(
			"vedic api returned empty pdf url",
		)
	}

	if strings.TrimSpace(vedicResponse.Response) == "" {

		// Try to extract API message if available.
		var apiResponse struct {
			Message string `json:"message"`
		}

		if err := json.Unmarshal(body, &apiResponse); err == nil {

			if strings.TrimSpace(apiResponse.Message) != "" {

				return nil, errors.New(
					apiResponse.Message,
				)
			}
		}

		return nil, errors.New(
			"vedic API returned empty pdf url",
		)
	}

	// DOWNLOAD + SAVE LOCAL PDF

	localPDFPath, err := s.DownloadAndSavePDF(
		vedicResponse.Response,
		name,
	)

	if err != nil {
		return nil, fmt.Errorf(
			"failed to save kundali PDF: %w",
			err,
		)
	}

	// RETURN ALL DATA

	return &GeneratedKundaliResult{

		LocalPDFURL: localPDFPath,

		VedicPDFURL: vedicResponse.Response,

		DetailsJSON: detailsJSON,
	}, nil
}

func (s *kundaliService) DownloadAndSavePDF(pdfURL string, name string) (string, error) {

	resp, err := http.Get(pdfURL)

	if err != nil {
		return "", err
	}

	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf(
			"unable to download pdf, status: %d",
			resp.StatusCode,
		)
	}

	kundliDir := kundaliPDFDir

	err = os.MkdirAll(kundliDir, os.ModePerm)

	if err != nil {
		return "", err
	}

	safeName := strings.ReplaceAll(name, " ", "_")

	fileName := fmt.Sprintf(
		"%s_kundali_%d.pdf",
		safeName,
		time.Now().Unix(),
	)

	fullPath := filepath.Join(
		kundliDir,
		fileName,
	)

	file, err := os.Create(fullPath)

	if err != nil {
		return "", err
	}

	defer file.Close()

	_, err = io.Copy(file, resp.Body)

	if err != nil {
		return "", err
	}

	return filepath.ToSlash(fullPath), nil
}

func (s *kundaliService) GetKundalis(
	userID uint,
	req *userdto.GetKundaliRequest,
) (*userdto.GetKundaliResponse, error) {

	kundalis, err := s.kundaliRepo.GetUserKundalis(
		userID,
		req.S,
	)

	if err != nil {
		return nil, err
	}

	totalRecords, err := s.kundaliRepo.CountUserKundalis(
		userID,
	)

	if err != nil {
		return nil, err
	}

	response := &userdto.GetKundaliResponse{
		RecordList:  kundalis,
		Status:      200,
		TotalRecord: totalRecords,
	}

	return response, nil
}

//////////////////////////////////////////////////////////////
// Reading A Saved Kundali
//////////////////////////////////////////////////////////////

// ShowKundali returns a saved kundali in the same envelope the generate call
// used — same recordList, same complete_details, same grouped sections — so the
// app parses one shape either way.
//
// Nothing is fetched. The whole payload was written to kundalis.details_json
// when the kundali was generated, so reading one back costs a single row read
// and no metered upstream calls, however many sections it holds.
func (s *kundaliService) ShowKundali(
	userID uint,
	kundaliID uint,
) (*userdto.ShowKundaliResponse, error) {

	kundali, err := s.kundaliRepo.GetUserKundaliByID(
		userID,
		kundaliID,
	)

	if err != nil {

		return &userdto.ShowKundaliResponse{
			Message: "Kundali not found",
			Status:  404,
		}, nil
	}

	return &userdto.ShowKundaliResponse{
		Message:    "Kundali fetched successfully",
		RecordList: []usermodel.Kundali{*kundali},
		Status:     200,
		CompleteDetails: []userdto.CompleteKundaliResponse{
			*storedKundaliResponse(kundali),
		},
	}, nil
}

// storedKundaliResponse rebuilds one complete_details entry from a saved row.
func storedKundaliResponse(kundali *usermodel.Kundali) *userdto.CompleteKundaliResponse {

	response := &userdto.CompleteKundaliResponse{
		ID:      kundali.ID,
		Name:    kundali.Name,
		IsGuest: false,
		Saved:   true,

		// Served from storage rather than fetched, which is what Cached means to
		// a caller: no upstream call was made for this response.
		Cached: true,
	}

	if kundali.PDFLink != nil {
		response.PDFURL = *kundali.PDFLink
	}

	if kundali.VedicPDFLink != nil {
		response.VedicPDFURL = *kundali.VedicPDFLink
	}

	failures := map[string]string{}

	//------------------------------------------------
	// The stored sections
	//------------------------------------------------

	stored := strings.TrimSpace(pointerString(kundali.DetailsJSON))

	if stored == "" || stored == "null" {

		// A row saved before the details were stored, or one whose generation
		// produced nothing. The record still describes a person, so it is worth
		// returning; the caller just cannot render the tabs.
		failures["details"] = "no sections stored for this kundali - generate it again to fill them in"

	} else {

		details := &userdto.KundaliDetails{}

		if err := json.Unmarshal([]byte(stored), details); err != nil {

			failures["details"] = "stored sections could not be read: " + err.Error()

		} else if isEmptyKundaliDetails(details) {

			// Non-empty JSON that maps to nothing is a payload written by an
			// older build, when the sections were a flat object rather than
			// grouped by screen.
			failures["details"] = "stored sections are in an older format - generate this kundali again to convert it"

		} else {

			response.Details = details
		}
	}

	//------------------------------------------------
	// Birth details
	//------------------------------------------------

	// The stored payload echoes the exact request the chart was cast from,
	// including the language, which the row does not record. Prefer it, and fall
	// back to the row for kundalis saved before it was kept.
	if response.Details != nil &&
		response.Details.BasicDetails != nil &&
		response.Details.BasicDetails.Request != nil {

		response.BirthDetails = response.Details.BasicDetails.Request

	} else {

		response.BirthDetails = birthDetailsFromRecord(kundali)
	}

	if len(failures) > 0 {
		response.Errors = failures
	}

	return response
}

// birthDetailsFromRecord reconstructs the birth details from the columns.
func birthDetailsFromRecord(kundali *usermodel.Kundali) *userdto.KundaliBirthDetails {

	birth := &userdto.KundaliBirthDetails{
		Name:       kundali.Name,
		Gender:     kundali.Gender,
		BirthDate:  kundali.BirthDate.Format("2006-01-02"),
		BirthTime:  kundali.BirthTime,
		BirthPlace: kundali.BirthPlace,
		ChartStyle: kundali.MatchType,
	}

	if kundali.Latitude != nil {
		birth.Latitude = *kundali.Latitude
	}

	if kundali.Longitude != nil {
		birth.Longitude = *kundali.Longitude
	}

	if kundali.Timezone != nil {

		if timezone, err := strconv.ParseFloat(strings.TrimSpace(*kundali.Timezone), 64); err == nil {
			birth.Timezone = timezone
		}
	}

	return birth
}

// isEmptyKundaliDetails reports whether a decoded payload carries no groups at
// all, which is how a payload from the older flat format presents itself.
func isEmptyKundaliDetails(details *userdto.KundaliDetails) bool {

	return details.BasicDetails == nil &&
		details.Chart == nil &&
		details.KP == nil &&
		details.Ashtakvarga == nil &&
		details.Dasha == nil &&
		details.Reports == nil &&
		details.Others == nil
}

func pointerString(value *string) string {

	if value == nil {
		return ""
	}

	return *value
}

// UpdateKundali edits a kundali the user already saved, in place.
//
// The id never changes: the same row is loaded, merged and written back, so
// editing a kundali cannot leave a second copy in the list. That is the point of
// this endpoint — the generate call also updates in place, but only when the
// client remembers to send the id, and an edit posted without one adds a row
// instead of changing the one on screen.
//
// When an edit moves something the chart is cast from — date, time, coordinates,
// timezone, gender, chart style, language — the kundali is recast against the
// new details and the stored sections and PDF are replaced. A cosmetic edit (a
// corrected name, a re-typed birth place) writes the columns only and makes no
// metered upstream call. "regenerate": true forces a fresh cast either way.
func (s *kundaliService) UpdateKundali(
	userID uint,
	kundaliID uint,
	req *userdto.UpdateKundaliRequest,
) (*userdto.UpdateKundaliResponse, error) {

	if req == nil {
		return nil, errors.New("kundali data is required")
	}

	// Scoped to createdBy, so somebody else's id is a not-found rather than an
	// edit of a row we do not own.
	kundali, err := s.kundaliRepo.GetUserKundaliByID(
		userID,
		kundaliID,
	)

	if err != nil {
		return nil, errors.New("kundali not found")
	}

	stored := storedKundaliInput(kundali)

	input := mergeKundaliUpdate(stored, req)

	birthDate, err := time.Parse("2006-01-02", input.BirthDate)

	if err != nil {
		return nil, fmt.Errorf(
			"invalid birth date %q, expected YYYY-MM-DD",
			input.BirthDate,
		)
	}

	recast := req.Regenerate || chartInputChanged(stored, input)

	// An empty result is what applyKundaliValues reads as "this run produced
	// nothing", which leaves the stored sections and PDF links as they were.
	generated := &CompleteKundaliResult{}

	if recast {

		// Outside the transaction: a complete kundali is around forty upstream
		// calls, and none of them should hold a MySQL connection open.
		generated, err = s.GenerateCompleteKundli(KundaliOptions{
			Lang:       input.Lang,
			Name:       input.Name,
			Gender:     input.Gender,
			Latitude:   input.Latitude,
			Longitude:  input.Longitude,
			BirthDate:  input.BirthDate,
			BirthTime:  input.BirthTime,
			Timezone:   input.Timezone,
			BirthPlace: input.BirthPlace,
			PDFType:    input.PdfType,
			ChartStyle: input.MatchType,
			Sections:   req.Sections,
			SkipPDF:    req.SkipPDF,
			VendorPDF:  req.VendorPDF,
		})

		if err != nil {
			return nil, err
		}
	}

	tx := s.db.Begin()

	if tx.Error != nil {
		return nil, tx.Error
	}

	defer func() {
		if r := recover(); r != nil {
			tx.Rollback()
			panic(r)
		}
	}()

	// Same struct that was loaded, so its primary key is set and Save writes an
	// UPDATE. createdBy is never touched.
	applyKundaliValues(kundali, input, birthDate, generated, userID)

	if err := s.kundaliRepo.UpdateKundaliDetails(tx, kundali); err != nil {

		tx.Rollback()

		return nil, err
	}

	if err := tx.Commit().Error; err != nil {

		tx.Rollback()

		return nil, err
	}

	// Rebuilt from the row that was just written, so the caller sees the saved
	// state rather than what the request hoped for.
	details := storedKundaliResponse(kundali)

	if recast {

		details.Cached = generated.FromCache

		for section, reason := range generated.Errors {

			if details.Errors == nil {
				details.Errors = map[string]string{}
			}

			details.Errors[section] = reason
		}
	}

	return &userdto.UpdateKundaliResponse{
		Message:         "Kundli update successfully",
		RecordList:      kundali,
		Status:          200,
		CompleteDetails: []userdto.CompleteKundaliResponse{*details},
	}, nil
}

// storedKundaliInput describes a saved row as the generate call's input, so an
// edit can be expressed as "this row, with the request's fields applied".
func storedKundaliInput(kundali *usermodel.Kundali) userdto.KundaliInput {

	birth := birthDetailsFromRecord(kundali)

	return applyKundaliDefaults(userdto.KundaliInput{
		ID:         kundali.ID,
		Lang:       storedKundaliLang(kundali),
		Name:       birth.Name,
		Gender:     birth.Gender,
		BirthDate:  birth.BirthDate,
		BirthTime:  birth.BirthTime,
		BirthPlace: birth.BirthPlace,
		Latitude:   birth.Latitude,
		Longitude:  birth.Longitude,
		Timezone:   birth.Timezone,
		PdfType:    kundali.PDFType,
		MatchType:  kundali.MatchType,
		ForMatch:   isForMatch(kundali.ForMatch),
	})
}

// storedKundaliLang reads the language a kundali was cast in. No column records
// it; the stored payload echoes the request that produced it.
func storedKundaliLang(kundali *usermodel.Kundali) string {

	stored := strings.TrimSpace(pointerString(kundali.DetailsJSON))

	if stored == "" || stored == "null" {
		return ""
	}

	var echo struct {
		BasicDetails *struct {
			Request *struct {
				Lang string `json:"lang"`
			} `json:"request"`
		} `json:"basic_details"`
	}

	if err := json.Unmarshal([]byte(stored), &echo); err != nil {
		return ""
	}

	if echo.BasicDetails == nil || echo.BasicDetails.Request == nil {
		return ""
	}

	return echo.BasicDetails.Request.Lang
}

// isForMatch reads the forMatch column, which holds "yes" or "0".
func isForMatch(value string) bool {

	switch strings.ToLower(strings.TrimSpace(value)) {

	case "yes", "1", "true":
		return true
	}

	return false
}

// mergeKundaliUpdate applies the populated fields of an update over the stored
// input. A field the client omitted is not an instruction to blank the column.
func mergeKundaliUpdate(
	stored userdto.KundaliInput,
	req *userdto.UpdateKundaliRequest,
) userdto.KundaliInput {

	merged := stored

	if strings.TrimSpace(req.Name) != "" {
		merged.Name = req.Name
	}

	if strings.TrimSpace(req.Gender) != "" {
		merged.Gender = req.Gender
	}

	if strings.TrimSpace(req.BirthDate) != "" {
		merged.BirthDate = req.BirthDate
	}

	if strings.TrimSpace(req.BirthTime) != "" {
		merged.BirthTime = req.BirthTime
	}

	if strings.TrimSpace(req.BirthPlace) != "" {
		merged.BirthPlace = req.BirthPlace
	}

	if req.Latitude != nil {
		merged.Latitude = *req.Latitude
	}

	if req.Longitude != nil {
		merged.Longitude = *req.Longitude
	}

	if req.Timezone != nil {

		if timezone, err := strconv.ParseFloat(strings.TrimSpace(*req.Timezone), 64); err == nil {
			merged.Timezone = timezone
		}
	}

	if strings.TrimSpace(req.Lang) != "" {
		merged.Lang = req.Lang
	}

	if strings.TrimSpace(req.PdfType) != "" {
		merged.PdfType = req.PdfType
	}

	if strings.TrimSpace(req.MatchType) != "" {
		merged.MatchType = req.MatchType
	}

	if req.ForMatch != nil {
		merged.ForMatch = *req.ForMatch
	}

	return merged
}

// chartInputChanged reports whether an edit moved something the chart is cast
// from. The name and the birth place are labels — correcting either leaves every
// section identical, so it is not worth forty metered calls.
func chartInputChanged(stored userdto.KundaliInput, merged userdto.KundaliInput) bool {

	return merged.Gender != stored.Gender ||
		merged.BirthDate != stored.BirthDate ||
		merged.BirthTime != stored.BirthTime ||
		merged.Latitude != stored.Latitude ||
		merged.Longitude != stored.Longitude ||
		merged.Timezone != stored.Timezone ||
		merged.Lang != stored.Lang ||
		merged.MatchType != stored.MatchType ||
		merged.PdfType != stored.PdfType
}

func (s *kundaliService) DeleteKundali(
	userID uint,
	req *userdto.DeleteKundaliRequest,
) (*userdto.DeleteKundaliResponse, error) {

	tx := s.db.Begin()

	if tx.Error != nil {
		return nil, tx.Error
	}

	kundali, err := s.kundaliRepo.GetUserKundaliByID(
		userID,
		req.ID,
	)

	if err != nil {
		tx.Rollback()
		return nil, errors.New("kundli not found")
	}

	err = s.kundaliRepo.DeleteKundali(
		tx,
		kundali,
	)

	if err != nil {
		tx.Rollback()
		return nil, err
	}

	err = tx.Commit().Error

	if err != nil {
		tx.Rollback()
		return nil, err
	}

	response := &userdto.DeleteKundaliResponse{
		Message: "Kundli delete successfully",
		Status:  200,
	}

	return response, nil
}

//////////////////////////////////////////////////////////////
// Download Kundali
//////////////////////////////////////////////////////////////

// DownloadKundali returns the PDF already stored on the record. Generation
// happens during AddKundali, so this never calls the Vedic API and never
// charges the user a second time.
func (s *kundaliService) DownloadKundali(
	userID uint,
	kundaliID uint,
) (*userdto.DownloadKundaliResponse, error) {

	kundali, err := s.kundaliRepo.GetUserKundaliByID(
		userID,
		kundaliID,
	)

	if err != nil {
		return nil, errors.New("kundli not found")
	}

	if kundali.PDFLink == nil || *kundali.PDFLink == "" {
		return nil, errors.New("pdf is not available for this kundli")
	}

	return &userdto.DownloadKundaliResponse{
		ID:       kundali.ID,
		Name:     kundali.Name,
		PDFLink:  *kundali.PDFLink,
		FileName: filepath.Base(*kundali.PDFLink),
	}, nil
}

//////////////////////////////////////////////////////////////
// Vedic Section Fetching
//////////////////////////////////////////////////////////////

// How many section fetches run at once. A complete kundali is forty-odd
// upstream calls; sequentially that is minutes of wall clock, and the apps wait
// on it. Eight keeps the response quick without hammering the vendor.
const vedicSectionConcurrency = 8

func cloneValues(src url.Values) url.Values {

	dst := url.Values{}

	for key, values := range src {
		for _, value := range values {
			dst.Add(key, value)
		}
	}

	return dst
}

// fetchVedicJSON calls one v3-json endpoint through VedicClient and returns the
// unwrapped "response" payload.
//
// Routing through VedicClient is the point: VedicAstroAPI answers HTTP 200 with
// {"status":402,"response":"out of api calls"}, and an earlier version of this
// function inspected only the HTTP status. A quota failure was therefore stored
// as a perfectly valid section and reached the app as an empty screen with no
// error anywhere. VedicClient decides on the status *inside* the envelope.
func (s *kundaliService) fetchVedicJSON(
	apiKey string,
	path string,
	params url.Values,
) (json.RawMessage, error) {

	// VedicClient sets api_key and lang on the values it is given, so it must
	// never see a caller's shared params.
	return NewVedicClient(apiKey).Get(path, cloneValues(params))
}

//////////////////////////////////////////////////////////////
// The Fetch Plan
//////////////////////////////////////////////////////////////

// kundaliFetch is one upstream call: the data key it fills, the groups that
// need that key, the endpoint, and any parameters beyond the birth details.
//
// A key is fetched once no matter how many groups want it. Several sections
// appear in two places in the response — personal characteristics under both
// Chart and Reports, mahadasha under both Dasha and Others — and paying for
// them twice would be pure waste.
type kundaliFetch struct {
	key string

	groups []string

	path string

	extra map[string]string

	// dateStyle sends the birth moment as date/time instead of dob/tob, which
	// is what the panchang endpoints expect.
	dateStyle bool
}

// Planets that get a narrative report.
var reportPlanets = []string{
	"Sun",
	"Moon",
	"Mercury",
	"Venus",
	"Mars",
	"Jupiter",
	"Saturn",
	"Rahu",
	"Ketu",
}

// Binnashtakvarga is defined for the seven classical planets only.
var binnashtakvargaPlanets = []string{
	"Sun",
	"Moon",
	"Mercury",
	"Venus",
	"Mars",
	"Jupiter",
	"Saturn",
}

// Divisional charts fetched for the Charts tab: lagna, navamsa, dasamsa.
var chartDivisions = []string{
	"D1",
	"D9",
	"D10",
}

// kundaliFetchPlan is the whole upstream surface of a kundali, in one place.
// Adding a section is an entry here plus a line in buildKundaliDetails.
func kundaliFetchPlan(name string) []kundaliFetch {

	plan := []kundaliFetch{

		// Basic details.
		{key: "panchang", groups: []string{SectionBasic}, path: "panchang/panchang", dateStyle: true},

		{key: "ashtakvarga", groups: []string{SectionBasic, SectionAshtakvarga}, path: "horoscope/ashtakvarga", extra: map[string]string{"planet": "total"}},

		{key: "manglik_dosh", groups: []string{SectionBasic}, path: "dosha/manglik-dosh"},

		// Chart.
		{key: "planet_details", groups: []string{SectionChart, SectionOthers}, path: "horoscope/planet-details"},

		{key: "personal_characteristics", groups: []string{SectionChart, SectionReports}, path: "horoscope/personal-characteristics"},

		// KP.
		{key: "kp_houses", groups: []string{SectionKP}, path: "extended-horoscope/kp-houses"},

		{key: "kp_planets", groups: []string{SectionKP}, path: "extended-horoscope/kp-planets"},

		// Dasha.
		{key: "mahadasha", groups: []string{SectionDasha, SectionOthers}, path: "dashas/maha-dasha"},

		{key: "mahadasha_predictions", groups: []string{SectionDasha, SectionReports}, path: "dashas/maha-dasha-predictions"},

		{key: "yogini_dasha_main", groups: []string{SectionDasha}, path: "dashas/yogini-dasha-main"},

		{key: "yogini_dasha_sub", groups: []string{SectionDasha}, path: "dashas/yogini-dasha-sub"},

		// Reports — general.
		{key: "list_of_yogas", groups: []string{SectionReports}, path: "extended-horoscope/yoga-list"},

		// Reports — remedies.
		{key: "rudraksh_suggestion", groups: []string{SectionReports}, path: "extended-horoscope/rudraksh-suggestion"},

		{key: "gem_suggestion", groups: []string{SectionReports}, path: "extended-horoscope/gem-suggestion"},

		// Reports — dosha.
		{key: "mangal_dosh", groups: []string{SectionReports}, path: "dosha/mangal-dosh"},

		{key: "kaalsarp_dosh", groups: []string{SectionReports}, path: "dosha/kaalsarp-dosh"},

		{key: "sade_sati_table", groups: []string{SectionReports}, path: "extended-horoscope/sade-sati-table"},

		{key: "current_sade_sati", groups: []string{SectionReports}, path: "extended-horoscope/current-sade-sati"},

		// Others.
		{key: "extended_kundli_details", groups: []string{SectionOthers}, path: "extended-horoscope/extended-kundli-details"},

		{key: "ascendant_report", groups: []string{SectionOthers}, path: "horoscope/ascendant-report"},

		{key: "planetary_aspects", groups: []string{SectionOthers}, path: "horoscope/planetary-aspects", extra: map[string]string{"type": "houses"}},

		{key: "planets_in_houses", groups: []string{SectionOthers}, path: "horoscope/planets-in-houses"},

		{key: "antardasha", groups: []string{SectionOthers}, path: "dashas/antar-dasha"},
	}

	// Numerology needs the name as well as the date; the endpoint rejects the
	// request without it. Kept out of the response groups the client selects
	// because it belongs to the remedies report.
	plan = append(plan, kundaliFetch{
		key:    "numero_table",
		groups: []string{SectionReports},
		path:   "extended-horoscope/numero-table",
		extra:  map[string]string{"name": name},
	})

	// Per-planet narrative reports.
	for _, planet := range reportPlanets {

		plan = append(plan, kundaliFetch{
			key:    "planet_report." + planet,
			groups: []string{SectionReports},
			path:   "horoscope/planet-report",
			extra:  map[string]string{"planet": planet},
		})
	}

	// Divisional charts.
	for _, division := range chartDivisions {

		plan = append(plan, kundaliFetch{
			key:    "divisional_charts." + division,
			groups: []string{SectionOthers},
			path:   "horoscope/divisional-charts",
			extra:  map[string]string{"div": division},
		})
	}

	// Per-planet binnashtakvarga.
	for _, planet := range binnashtakvargaPlanets {

		plan = append(plan, kundaliFetch{
			key:    "binnashtakvarga." + planet,
			groups: []string{SectionOthers},
			path:   "horoscope/binnashtakvarga",
			extra:  map[string]string{"planet": planet},
		})
	}

	return plan
}

func kundaliBaseParams(opts KundaliOptions, formattedDOB string) url.Values {

	params := url.Values{}

	params.Set("dob", formattedDOB)
	params.Set("tob", opts.BirthTime)
	params.Set("lat", fmt.Sprintf("%f", opts.Latitude))
	params.Set("lon", fmt.Sprintf("%f", opts.Longitude))
	params.Set("tz", fmt.Sprintf("%.1f", opts.Timezone))
	params.Set("lang", opts.Lang)

	return params
}

// fetchParams turns one plan entry into its query string.
func (fetch kundaliFetch) fetchParams(base url.Values, formattedDOB string, tob string) url.Values {

	params := cloneValues(base)

	if fetch.dateStyle {

		params.Del("dob")
		params.Del("tob")

		params.Set("date", formattedDOB)
		params.Set("time", tob)
	}

	for key, value := range fetch.extra {
		params.Set(key, value)
	}

	return params
}

// selectPlan keeps the entries at least one selected group needs.
func selectPlan(plan []kundaliFetch, sections map[string]bool) []kundaliFetch {

	selected := make([]kundaliFetch, 0, len(plan))

	for _, fetch := range plan {

		for _, group := range fetch.groups {

			if sections[group] {

				selected = append(selected, fetch)

				break
			}
		}
	}

	return selected
}

// fetchSections runs the plan concurrently. Every failure is collected rather
// than returned: partial failure is the contract of this endpoint.
func (s *kundaliService) fetchSections(
	apiKey string,
	plan []kundaliFetch,
	base url.Values,
	formattedDOB string,
	tob string,
) (map[string]json.RawMessage, map[string]string) {

	results := make(map[string]json.RawMessage, len(plan))

	failures := make(map[string]string)

	if len(plan) == 0 {
		return results, failures
	}

	workers := vedicSectionConcurrency

	if len(plan) < workers {
		workers = len(plan)
	}

	work := make(chan kundaliFetch)

	var mutex sync.Mutex

	var group sync.WaitGroup

	for worker := 0; worker < workers; worker++ {

		group.Add(1)

		go func() {

			defer group.Done()

			for fetch := range work {

				raw, err := s.fetchVedicJSON(
					apiKey,
					fetch.path,
					fetch.fetchParams(base, formattedDOB, tob),
				)

				mutex.Lock()

				if err != nil {
					failures[fetch.key] = err.Error()
				} else {
					results[fetch.key] = raw
				}

				mutex.Unlock()
			}
		}()
	}

	for _, fetch := range plan {
		work <- fetch
	}

	close(work)

	group.Wait()

	return results, failures
}

//////////////////////////////////////////////////////////////
// AI 12 Month Prediction
//////////////////////////////////////////////////////////////

// moonSignNumber digs the moon's rasi out of a planet-details body.
//
// The vendor returns the planets as an object keyed "0".."9" and mixes in
// unrelated keys of other shapes — birth_dasa is a string, lucky_gem an object —
// so each value has to be decoded on its own. Decoding the map as one typed
// struct fails on the first key that is not a planet, which is what silently
// cost this section its data.
func moonSignNumber(planetDetails json.RawMessage) int {

	if len(planetDetails) == 0 {
		return 0
	}

	var entries map[string]json.RawMessage

	if err := json.Unmarshal(planetDetails, &entries); err != nil {
		return 0
	}

	for _, entry := range entries {

		var planet struct {
			FullName string `json:"full_name"`

			RasiNo int `json:"rasi_no"`
		}

		if err := json.Unmarshal(entry, &planet); err != nil {
			continue
		}

		if strings.EqualFold(planet.FullName, "Moon") && planet.RasiNo > 0 {
			return planet.RasiNo
		}
	}

	return 0
}

// fetchAI12MonthPrediction gets the twelve-month outlook, which the vendor keys
// on the moon sign rather than on the birth details. That makes it a second
// round trip after planet-details resolves, so it cannot join the main fan-out.
func (s *kundaliService) fetchAI12MonthPrediction(
	apiKey string,
	opts KundaliOptions,
	planetDetails json.RawMessage,
) (json.RawMessage, error) {

	zodiac := moonSignNumber(planetDetails)

	if zodiac <= 0 {
		return nil, errors.New("moon sign unavailable, so the 12 month prediction could not be requested")
	}

	params := url.Values{}

	params.Set("zodiac", fmt.Sprintf("%d", zodiac))
	params.Set("year", fmt.Sprintf("%d", time.Now().Year()))
	params.Set("lang", opts.Lang)
	params.Set("split", "true")
	params.Set("api_key", apiKey)

	return s.fetchVedicJSON(apiKey, "prediction/yearly", params)
}

//////////////////////////////////////////////////////////////
// Assembling The Grouped Response
//////////////////////////////////////////////////////////////

// buildKundaliDetails lays the fetched bodies out in the shape the app screens
// read: one key per tab, sub-sections nested underneath.
func buildKundaliDetails(
	opts KundaliOptions,
	sections map[string]bool,
	results map[string]json.RawMessage,
) *userdto.KundaliDetails {

	// collect gathers the "prefix.Name" keys into one map, and returns nil when
	// none of them came back so the field stays out of the JSON.
	collect := func(prefix string) map[string]json.RawMessage {

		gathered := map[string]json.RawMessage{}

		for key, raw := range results {

			if strings.HasPrefix(key, prefix+".") {
				gathered[strings.TrimPrefix(key, prefix+".")] = raw
			}
		}

		if len(gathered) == 0 {
			return nil
		}

		return gathered
	}

	details := &userdto.KundaliDetails{}

	birth := &userdto.KundaliBirthDetails{
		Name:       opts.Name,
		Gender:     opts.Gender,
		BirthDate:  opts.BirthDate,
		BirthTime:  opts.BirthTime,
		BirthPlace: opts.BirthPlace,
		Latitude:   opts.Latitude,
		Longitude:  opts.Longitude,
		Timezone:   opts.Timezone,
		Lang:       opts.Lang,
		ChartStyle: opts.ChartStyle,
	}

	if sections[SectionBasic] {

		details.BasicDetails = &userdto.KundaliBasicSection{
			Request:     birth,
			Panchang:    results["panchang"],
			Ashtakvarga: results["ashtakvarga"],
			ManglikDosh: results["manglik_dosh"],
		}
	}

	if sections[SectionChart] {

		chartImages := map[string]string{}

		for _, division := range chartDivisions {
			chartImages[division] = kundaliChartImageURL(opts, division, "", "")
		}

		details.Chart = &userdto.KundaliChartSection{
			ChartImage:              kundaliChartImageURL(opts, "D1", "", ""),
			ChartImages:             chartImages,
			PlanetDetails:           results["planet_details"],
			PersonalCharacteristics: results["personal_characteristics"],
		}
	}

	if sections[SectionKP] {

		details.KP = &userdto.KundaliKPSection{
			KPHouses:  results["kp_houses"],
			KPPlanets: results["kp_planets"],
		}
	}

	if sections[SectionAshtakvarga] {

		details.Ashtakvarga = &userdto.KundaliAshtakvargaSection{
			AshtakvargaChartImage: kundaliChartImageURL(opts, "", "ashtakvarga", "total"),
			Ashtakvarga:           results["ashtakvarga"],
		}
	}

	if sections[SectionDasha] {

		details.Dasha = &userdto.KundaliDashaSection{
			Vimshottari: &userdto.KundaliVimshottariDasha{
				Mahadasha:            results["mahadasha"],
				MahadashaPredictions: results["mahadasha_predictions"],
			},
			Yogini: &userdto.KundaliYoginiDasha{
				YoginiDashaMain: results["yogini_dasha_main"],
				YoginiDashaSub:  results["yogini_dasha_sub"],
			},
		}
	}

	if sections[SectionReports] {

		details.Reports = &userdto.KundaliReportsSection{
			General: &userdto.KundaliGeneralReport{
				PersonalCharacteristics: results["personal_characteristics"],
				PlanetReport:            collect("planet_report"),
				MahadashaPredictions:    results["mahadasha_predictions"],
				ListOfYogas:             results["list_of_yogas"],
			},
			Remedies: &userdto.KundaliRemedyReport{
				RudrakshSuggestion: results["rudraksh_suggestion"],
				GemSuggestion:      results["gem_suggestion"],
				NumeroTable:        results["numero_table"],
			},
			Dosha: &userdto.KundaliDoshaReport{
				MangalDosh:      results["mangal_dosh"],
				KaalsarpDosh:    results["kaalsarp_dosh"],
				SadeSatiTable:   results["sade_sati_table"],
				CurrentSadeSati: results["current_sade_sati"],
			},
		}
	}

	if sections[SectionOthers] {

		details.Others = &userdto.KundaliOthersSection{
			ExtendedKundliDetails: results["extended_kundli_details"],
			AscendantReport:       results["ascendant_report"],
			DivisionalCharts:      collect("divisional_charts"),
			Binnashtakvarga:       collect("binnashtakvarga"),
			AI12MonthPrediction:   results["ai_12_month_prediction"],
			PlanetaryAspects:      results["planetary_aspects"],
			PlanetsInHouses:       results["planets_in_houses"],
			Mahadasha:             results["mahadasha"],
			Antardasha:            results["antardasha"],
		}
	}

	return details
}

//////////////////////////////////////////////////////////////
// Chart Image URLs
//////////////////////////////////////////////////////////////

// appBaseURL is the public origin this API is served from, used to build links
// the apps can follow.
func appBaseURL() string {

	appURL := strings.TrimRight(os.Getenv("APP_URL"), "/")

	if appURL == "" {
		appURL = "https://api.astrology.togwe.com"
	}

	return appURL
}

// kundaliChartImageURL points at this API's own chart-image proxy, never at
// VedicAstroAPI. The upstream URL has to carry our API key in the query string,
// and these responses are handed to unauthenticated clients — an earlier
// version of this code published the key to every caller.
func kundaliChartImageURL(opts KundaliOptions, division string, kind string, planet string) string {

	params := url.Values{}

	params.Set("dob", opts.BirthDate)
	params.Set("tob", opts.BirthTime)
	params.Set("lat", fmt.Sprintf("%f", opts.Latitude))
	params.Set("lon", fmt.Sprintf("%f", opts.Longitude))
	params.Set("tz", fmt.Sprintf("%.1f", opts.Timezone))
	params.Set("style", opts.ChartStyle)
	params.Set("lang", opts.Lang)

	if division != "" {
		params.Set("div", division)
	}

	if kind != "" {
		params.Set("kind", kind)
	}

	if planet != "" {
		params.Set("planet", planet)
	}

	return appBaseURL() + "/api/user/kundali/chart-image?" + params.Encode()
}

//////////////////////////////////////////////////////////////
// Chart Image Proxy
//////////////////////////////////////////////////////////////

// ChartImage fetches a chart from VedicAstroAPI with the server-side key and
// returns the bytes for the handler to stream. The chart endpoints answer with
// an SVG document rather than the usual JSON envelope, so this cannot go
// through VedicClient — but a failure still arrives as an envelope, which is
// why the body is sniffed before being handed back as an image.
func (s *kundaliService) ChartImage(req userdto.ChartImageRequest) (*ChartImage, error) {

	apiKey, err := s.kundaliRepo.GetVedicAPIKey()

	if err != nil {
		return nil, fmt.Errorf("failed to get Vedic API key: %w", err)
	}

	formattedDOB, err := ToVedicDate(req.BirthDate)

	if err != nil {
		return nil, err
	}

	if strings.TrimSpace(req.BirthTime) == "" {
		return nil, errors.New("tob is required")
	}

	style := req.Style

	if style == "" {
		style = "north"
	}

	lang := req.Lang

	if lang == "" {
		lang = "en"
	}

	params := url.Values{}

	params.Set("dob", formattedDOB)
	params.Set("tob", req.BirthTime)
	params.Set("lat", fmt.Sprintf("%f", req.Latitude))
	params.Set("lon", fmt.Sprintf("%f", req.Longitude))
	params.Set("tz", fmt.Sprintf("%.1f", DefaultTimezone(req.Timezone)))
	params.Set("style", style)
	params.Set("lang", lang)
	params.Set("api_key", apiKey)

	path := "horoscope/chart-image"

	if strings.EqualFold(req.Kind, "ashtakvarga") {

		path = "horoscope/ashtakvarga-chart-image"

		planet := req.Planet

		if planet == "" {
			planet = "total"
		}

		params.Set("planet", planet)
		params.Set("color", "23ff3366")
		params.Set("size", "300")
		params.Set("font_size", "30")

	} else {

		division := req.Division

		if division == "" {
			division = "D1"
		}

		params.Set("div", division)
		params.Set("stroke", "2")
	}

	client := &http.Client{Timeout: 30 * time.Second}

	resp, err := client.Get(VedicBaseURL + "/" + path + "?" + params.Encode())

	if err != nil {
		return nil, fmt.Errorf("astrology service unreachable: %w", err)
	}

	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)

	if err != nil {
		return nil, err
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {

		return nil, fmt.Errorf(
			"astrology service returned HTTP %d for the chart image",
			resp.StatusCode,
		)
	}

	contentType := resp.Header.Get("Content-Type")

	// A JSON body here is the failure envelope, not a chart.
	if strings.HasPrefix(strings.TrimSpace(string(body)), "{") {

		var envelope vedicEnvelope

		if err := json.Unmarshal(body, &envelope); err == nil {

			if envelope.Status != http.StatusOK {
				return nil, vedicError(envelope)
			}

			// Some deployments wrap the SVG in the envelope as a string.
			var svg string

			if err := json.Unmarshal(envelope.Response, &svg); err == nil && svg != "" {

				return &ChartImage{
					ContentType: "image/svg+xml",
					Body:        []byte(svg),
				}, nil
			}
		}
	}

	if contentType == "" {
		contentType = "image/svg+xml"
	}

	return &ChartImage{
		ContentType: contentType,
		Body:        body,
	}, nil
}

//////////////////////////////////////////////////////////////
// Complete Kundali
//////////////////////////////////////////////////////////////

// GenerateCompleteKundli assembles one complete kundali: every JSON section the
// app screens render, the chart image links, and the full report PDF.
//
// The PDF is built here from the sections, not fetched from the vendor. Their
// pdf/horoscope endpoint is not enabled on every plan and currently answers
// with an internal error, which used to fail the whole request — no chart, no
// planets, no dasha, just an error, even though all of that data was in hand.
// The vendor's PDF is now opt-in via VendorPDF, and a failure of either PDF
// lands in Errors and leaves the data untouched. Only a request where *nothing*
// could be fetched is an error.
func (s *kundaliService) GenerateCompleteKundli(opts KundaliOptions) (*CompleteKundaliResult, error) {

	if opts.Lang == "" {
		opts.Lang = "en"
	}

	if opts.ChartStyle == "" {
		opts.ChartStyle = "north"
	}

	if opts.PDFType == "" {
		opts.PDFType = "small"
	}

	opts.Timezone = DefaultTimezone(opts.Timezone)

	formattedDOB, err := ToVedicDate(opts.BirthDate)

	if err != nil {
		return nil, err
	}

	if err := validateBirthTime(opts.BirthTime); err != nil {
		return nil, err
	}

	sections := resolveSections(opts.Sections)

	cacheKey := detailsCacheKey(opts, sections)

	if cached := cachedDetails(cacheKey); cached != nil {
		return cached, nil
	}

	apiKey, err := s.kundaliRepo.GetVedicAPIKey()

	if err != nil {
		return nil, fmt.Errorf("failed to get Vedic API key: %w", err)
	}

	//------------------------------------------------
	// JSON sections
	//------------------------------------------------

	plan := selectPlan(kundaliFetchPlan(opts.Name), sections)

	base := kundaliBaseParams(opts, formattedDOB)

	results, failures := s.fetchSections(apiKey, plan, base, formattedDOB, opts.BirthTime)

	// The twelve-month prediction is keyed on the moon sign, so it can only be
	// asked for once the planet details are in.
	if sections[SectionOthers] {

		prediction, err := s.fetchAI12MonthPrediction(apiKey, opts, results["planet_details"])

		if err != nil {
			failures["ai_12_month_prediction"] = err.Error()
		} else {
			results["ai_12_month_prediction"] = prediction
		}
	}

	// Nothing came back at all: report the upstream reason instead of handing
	// the app an empty kundali it cannot explain.
	if len(results) == 0 && len(failures) > 0 {

		return nil, fmt.Errorf(
			"could not generate kundali: %s",
			firstFailure(failures),
		)
	}

	details := buildKundaliDetails(opts, sections, results)

	//------------------------------------------------
	// PDF
	//------------------------------------------------

	pdf := kundaliPDF{}

	if !opts.SkipPDF {

		localPath, localURL, err := s.saveKundaliReportPDF(opts, details)

		if err != nil {
			failures["pdf"] = err.Error()
		} else {
			pdf.LocalPath = localPath
			pdf.LocalURL = localURL
		}
	}

	if opts.VendorPDF {

		vendorURL, err := s.fetchVendorPDF(apiKey, opts, formattedDOB)

		if err != nil {
			failures["vendor_pdf"] = err.Error()
		} else {
			pdf.VedicURL = vendorURL
		}
	}

	detailsJSON, err := json.Marshal(details)

	if err != nil {
		return nil, err
	}

	if len(failures) == 0 {
		failures = nil
	}

	result := &CompleteKundaliResult{
		LocalPDFPath: pdf.LocalPath,
		LocalPDFURL:  pdf.LocalURL,
		VedicPDFURL:  pdf.VedicURL,
		Details:      details,
		DetailsJSON:  string(detailsJSON),
		Errors:       failures,
	}

	storeCachedDetails(cacheKey, result)

	return result, nil
}

// firstFailure picks a stable message out of the failure map, so the same
// broken input always produces the same error text.
func firstFailure(failures map[string]string) string {

	keys := make([]string, 0, len(failures))

	for key := range failures {
		keys = append(keys, key)
	}

	sort.Strings(keys)

	if len(keys) == 0 {
		return "no astrology data available"
	}

	return failures[keys[0]]
}

// validateBirthTime accepts HH:MM and HH:MM:SS, which is what the two apps send.
func validateBirthTime(tob string) error {

	if strings.TrimSpace(tob) == "" {
		return errors.New("birthTime is required")
	}

	if _, err := time.Parse("15:04", tob); err == nil {
		return nil
	}

	if _, err := time.Parse("15:04:05", tob); err == nil {
		return nil
	}

	return fmt.Errorf("invalid birth time %q, expected HH:MM", tob)
}

//////////////////////////////////////////////////////////////
// PDF
//////////////////////////////////////////////////////////////

type kundaliPDF struct {
	LocalPath string

	LocalURL string

	VedicURL string
}

// saveKundaliReportPDF renders the full report and writes it under uploads/,
// which main.go serves at /uploads.
func (s *kundaliService) saveKundaliReportPDF(
	opts KundaliOptions,
	details *userdto.KundaliDetails,
) (string, string, error) {

	document, err := RenderKundaliReportPDF(opts, details)

	if err != nil {
		return "", "", err
	}

	if err := os.MkdirAll(kundaliPDFDir, os.ModePerm); err != nil {
		return "", "", err
	}

	safeName := strings.ReplaceAll(strings.TrimSpace(opts.Name), " ", "_")

	if safeName == "" {
		safeName = "kundali"
	}

	fileName := fmt.Sprintf("%s_kundali_%d.pdf", safeName, time.Now().UnixNano())

	fullPath := filepath.ToSlash(filepath.Join(kundaliPDFDir, fileName))

	if err := os.WriteFile(fullPath, document, 0o644); err != nil {
		return "", "", err
	}

	return fullPath, appBaseURL() + "/" + kundaliPDFRoute + "/" + fileName, nil
}

// fetchVendorPDF asks VedicAstroAPI for its own report PDF and mirrors it
// locally. Opt-in: the endpoint is not on every plan, and on this one it
// currently answers with an internal error.
func (s *kundaliService) fetchVendorPDF(
	apiKey string,
	opts KundaliOptions,
	formattedDOB string,
) (string, error) {

	params := url.Values{}

	params.Set("name", opts.Name)
	params.Set("dob", formattedDOB)
	params.Set("tob", opts.BirthTime)
	params.Set("lat", fmt.Sprintf("%f", opts.Latitude))
	params.Set("lon", fmt.Sprintf("%f", opts.Longitude))
	params.Set("tz", fmt.Sprintf("%.1f", opts.Timezone))
	params.Set("pob", opts.BirthPlace)
	params.Set("lang", opts.Lang)
	params.Set("style", opts.ChartStyle)
	params.Set("color", "140")
	params.Set("pdf_type", opts.PDFType)

	raw, err := NewVedicClient(apiKey).Get("pdf/horoscope", params)

	if err != nil {
		return "", err
	}

	var pdfURL string

	if err := json.Unmarshal(raw, &pdfURL); err != nil {
		return "", errors.New("astrology service returned no pdf link")
	}

	pdfURL = strings.TrimSpace(pdfURL)

	if pdfURL == "" {
		return "", errors.New("astrology service returned an empty pdf link")
	}

	return pdfURL, nil
}

// syncStoredBirthEcho rewrites the labels in a stored request echo so a
// cosmetic edit is not reported back with the old values. The echo is what
// storedKundaliResponse prefers over the columns, so without this an edited name
// would show up in recordList and nowhere else.
//
// Only labels are touched. Anything the chart is cast from recasts the kundali,
// which rewrites the whole echo anyway. The payload is patched as a map rather
// than through KundaliDetails so that sections this build does not know about
// survive the round trip.
func syncStoredBirthEcho(kundali *usermodel.Kundali, input userdto.KundaliInput) {

	stored := strings.TrimSpace(pointerString(kundali.DetailsJSON))

	if stored == "" || stored == "null" {
		return
	}

	root := map[string]json.RawMessage{}

	if err := json.Unmarshal([]byte(stored), &root); err != nil {
		return
	}

	basic := map[string]json.RawMessage{}

	if err := json.Unmarshal(root["basic_details"], &basic); err != nil {
		return
	}

	echo := map[string]json.RawMessage{}

	if err := json.Unmarshal(basic["request"], &echo); err != nil {
		return
	}

	for field, value := range map[string]string{
		"name":       input.Name,
		"birthPlace": input.BirthPlace,
	} {

		encoded, err := json.Marshal(value)

		if err != nil {
			return
		}

		echo[field] = encoded
	}

	encodedEcho, err := json.Marshal(echo)

	if err != nil {
		return
	}

	basic["request"] = encodedEcho

	encodedBasic, err := json.Marshal(basic)

	if err != nil {
		return
	}

	root["basic_details"] = encodedBasic

	encodedRoot, err := json.Marshal(root)

	if err != nil {
		return
	}

	patched := string(encodedRoot)

	kundali.DetailsJSON = &patched
}
