package services

import (
	"errors"
	"fmt"
	"log"
	"net/url"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"

	dto "astrology-api/dto/astrology"
	usermodel "astrology-api/models/usermodel"
)

//////////////////////////////////////////////////////////////
// Horoscope Service
//////////////////////////////////////////////////////////////
//
// Shared by the customer and the astrologer routes. Every lookup that gets
// past validation writes one horoscope_requests row - success or failure - for
// the admin panel's Horoscope screens. The contract for that table lives in
// astrology-admin/api_requirement.txt; the admin filters match the categorical
// values below exactly, so write no others.

// horoscope_requests.horoscope_type
const (
	HoroscopeTypeDaily          = "daily"
	HoroscopeTypeDailyMoon      = "daily_moon"
	HoroscopeTypeDailyNakshatra = "daily_nakshatra"
	HoroscopeTypeWeekly         = "weekly"
	HoroscopeTypeYearly         = "yearly"
)

// horoscope_requests.actor_type
const (
	HoroscopeActorCustomer   = "customer"
	HoroscopeActorAstrologer = "astrologer"
)

// horoscope_requests.platform
const (
	HoroscopePlatformCustomerApp   = "customer_app"
	HoroscopePlatformAstrologerApp = "astrologer_app"
	HoroscopePlatformWeb           = "web"
	HoroscopePlatformAstrologerWeb = "astrologer_web"
)

type HoroscopeService interface {
	Daily(caller dto.HoroscopeCaller, req dto.DailyHoroscopeRequest) (*dto.VedicDataResponse, error)
	Weekly(caller dto.HoroscopeCaller, req dto.WeeklyHoroscopeRequest) (*dto.VedicDataResponse, error)
	Yearly(caller dto.HoroscopeCaller, req dto.YearlyHoroscopeRequest) (*dto.VedicDataResponse, error)
	DailyMoon(caller dto.HoroscopeCaller, req dto.DailyHoroscopeRequest) (*dto.VedicDataResponse, error)
	DailyNakshatra(caller dto.HoroscopeCaller, req dto.DailyNakshatraRequest) (*dto.VedicDataResponse, error)
	Feedback(caller dto.HoroscopeCaller, req dto.HoroscopeFeedbackRequest) error
	Signs() ([]dto.HoroscopeSign, error)

	// AstrologerID maps an astrologer token's users.id to astrologers.id.
	AstrologerID(userID uint) (uint, error)
}

type horoscopeService struct {
	astrologyBase
}

func NewHoroscopeService() HoroscopeService {
	return &horoscopeService{astrologyBase: newAstrologyBase()}
}

// itoa keeps the query building readable.
func itoa(value int) string {
	return strconv.Itoa(value)
}

// todayIfEmpty defaults a date to the current day, which is what the home
// screen wants when it sends no date at all. It returns the Vedic DD/MM/YYYY
// form and the day itself, for the log row.
func todayIfEmpty(value string) (string, time.Time, error) {

	if strings.TrimSpace(value) == "" {
		now := time.Now()
		return now.Format("02/01/2006"), now, nil
	}

	vedic, err := ToVedicDate(value)

	if err != nil {
		return "", time.Time{}, err
	}

	day, _ := time.Parse("02/01/2006", vedic)

	return vedic, day, nil
}

// langOrDefault is the lang sent upstream and logged; Vedic's own default is
// English, so sending "en" explicitly changes nothing for the caller.
func langOrDefault(value string) string {

	value = strings.TrimSpace(value)

	if value == "" {
		return "en"
	}

	return value
}

// dayLabel is period_label for the daily variants: today, yesterday or
// tomorrow relative to the server date, else the date itself (YYYY-MM-DD),
// mirroring the thisweek / nextweek labels of the weekly rows.
func dayLabel(day time.Time) string {

	today := time.Now().Format("2006-01-02")
	asked := day.Format("2006-01-02")

	switch asked {
	case today:
		return "today"
	case time.Now().AddDate(0, 0, -1).Format("2006-01-02"):
		return "yesterday"
	case time.Now().AddDate(0, 0, 1).Format("2006-01-02"):
		return "tomorrow"
	}

	return asked
}

// mondayOf returns the Monday of the week containing day.
func mondayOf(day time.Time) time.Time {

	offset := (int(day.Weekday()) + 6) % 7

	return day.AddDate(0, 0, -offset)
}

func intPtr(value int) *int {
	return &value
}

func strPtr(value string) *string {
	return &value
}

// optionalStr turns an empty header into a NULL column.
func optionalStr(value string) *string {

	if value == "" {
		return nil
	}

	return &value
}

// fetch runs a horoscope lookup, applies the horoscope price when the caller
// is chargeable, and logs the outcome. entry carries the per-endpoint columns;
// fetch fills in the caller, the charge and the result.
func (s *horoscopeService) fetch(
	caller dto.HoroscopeCaller,
	path string,
	params url.Values,
	entry usermodel.HoroscopeRequest,
) (*dto.VedicDataResponse, error) {

	response, charged, elapsed, err := s.lookup(caller, path, params)

	s.record(caller, entry, charged, elapsed, err)

	return response, err
}

func (s *horoscopeService) lookup(
	caller dto.HoroscopeCaller,
	path string,
	params url.Values,
) (*dto.VedicDataResponse, float64, *time.Duration, error) {

	client, err := s.vedic()

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

	started := time.Now()

	data, err := client.Get(path, params)

	elapsed := time.Since(started)

	if err != nil {
		return nil, 0, &elapsed, err
	}

	// Charged only after the upstream call succeeds, so a failed lookup is
	// never billed. Astrologers are never charged.
	charged := float64(0)

	if caller.Chargeable && caller.CataloguePrice != nil {

		// Priced by the admin catalogue; the usage middleware debits it once
		// this response is ready.
		charged = *caller.CataloguePrice

	} else if caller.Chargeable {

		charged, err = s.chargeForRead(caller.UserID, FlagPriceHoroscope, TxnHoroscope)

		if err != nil {
			return nil, 0, &elapsed, err
		}
	}

	return &dto.VedicDataResponse{Charged: charged, Data: data}, charged, &elapsed, nil
}

// record writes the horoscope_requests row on a goroutine. A failed insert is
// only logged: the admin log must never fail or slow the user's request.
func (s *horoscopeService) record(
	caller dto.HoroscopeCaller,
	entry usermodel.HoroscopeRequest,
	charged float64,
	elapsed *time.Duration,
	lookupErr error,
) {

	entry.ActorType = caller.ActorType
	entry.Platform = caller.Platform
	entry.DeviceType = optionalStr(caller.DeviceType)
	entry.AppVersion = optionalStr(caller.AppVersion)
	entry.IPAddress = optionalStr(caller.IPAddress)
	entry.WasCharged = charged > 0
	entry.ChargedAmount = charged
	entry.Succeeded = lookupErr == nil
	entry.IsTest = false
	entry.CreatedAt = time.Now()

	if caller.ActorType == HoroscopeActorAstrologer {
		entry.AstrologerID = caller.AstrologerID
	} else {
		userID := caller.UserID
		entry.UserID = &userID
	}

	if elapsed != nil {
		ms := elapsed.Milliseconds()
		entry.ResponseMs = &ms
	}

	if lookupErr != nil {

		reason := lookupErr.Error()

		if len(reason) > 255 {
			reason = reason[:255]
		}

		entry.FailureReason = &reason
	}

	go func() {

		defer func() {
			if r := recover(); r != nil {
				log.Printf("horoscope: log insert panicked: %v", r)
			}
		}()

		if err := s.repo.CreateHoroscopeRequest(&entry); err != nil {
			log.Printf("horoscope: log insert failed: %v", err)
		}
	}()
}

//////////////////////////////////////////////////////////////
// Daily
//////////////////////////////////////////////////////////////

func (s *horoscopeService) Daily(
	caller dto.HoroscopeCaller,
	req dto.DailyHoroscopeRequest,
) (*dto.VedicDataResponse, error) {

	date, day, err := todayIfEmpty(req.Date)

	if err != nil {
		return nil, err
	}

	reportType := req.Type

	if reportType == "" {
		reportType = "big"
	}

	lang := langOrDefault(req.Lang)

	params := url.Values{}
	params.Set("zodiac", itoa(req.Zodiac))
	params.Set("date", date)
	params.Set("type", reportType)
	params.Set("lang", lang)

	if req.Split {
		params.Set("split", "true")
	}

	return s.fetch(caller, "prediction/daily-sun", params, usermodel.HoroscopeRequest{
		HoroscopeType: HoroscopeTypeDaily,
		Zodiac:        intPtr(req.Zodiac),
		PeriodDate:    &day,
		PeriodLabel:   strPtr(dayLabel(day)),
		PeriodYear:    intPtr(day.Year()),
		Langcode:      lang,
	})
}

func (s *horoscopeService) DailyMoon(
	caller dto.HoroscopeCaller,
	req dto.DailyHoroscopeRequest,
) (*dto.VedicDataResponse, error) {

	date, day, err := todayIfEmpty(req.Date)

	if err != nil {
		return nil, err
	}

	lang := langOrDefault(req.Lang)

	params := url.Values{}
	params.Set("zodiac", itoa(req.Zodiac))
	params.Set("date", date)
	params.Set("show_same", "true")
	params.Set("lang", lang)

	return s.fetch(caller, "prediction/daily-moon", params, usermodel.HoroscopeRequest{
		HoroscopeType: HoroscopeTypeDailyMoon,
		Zodiac:        intPtr(req.Zodiac),
		PeriodDate:    &day,
		PeriodLabel:   strPtr(dayLabel(day)),
		PeriodYear:    intPtr(day.Year()),
		Langcode:      lang,
	})
}

//////////////////////////////////////////////////////////////
// Weekly
//////////////////////////////////////////////////////////////

func (s *horoscopeService) Weekly(
	caller dto.HoroscopeCaller,
	req dto.WeeklyHoroscopeRequest,
) (*dto.VedicDataResponse, error) {

	// Upstream accepts only these two literals for week.
	week := req.Week

	if week != "nextweek" {
		week = "thisweek"
	}

	monday := mondayOf(time.Now())

	if week == "nextweek" {
		monday = monday.AddDate(0, 0, 7)
	}

	lang := langOrDefault(req.Lang)

	params := url.Values{}
	params.Set("zodiac", itoa(req.Zodiac))
	params.Set("week", week)
	params.Set("show_same", strconv.FormatBool(req.ShowSame))
	params.Set("lang", lang)

	return s.fetch(caller, "prediction/weekly-sun", params, usermodel.HoroscopeRequest{
		HoroscopeType: HoroscopeTypeWeekly,
		Zodiac:        intPtr(req.Zodiac),
		PeriodDate:    &monday,
		PeriodLabel:   strPtr(week),
		PeriodYear:    intPtr(monday.Year()),
		Langcode:      lang,
	})
}

//////////////////////////////////////////////////////////////
// Yearly
//////////////////////////////////////////////////////////////

func (s *horoscopeService) Yearly(
	caller dto.HoroscopeCaller,
	req dto.YearlyHoroscopeRequest,
) (*dto.VedicDataResponse, error) {

	year := req.Year

	if year == 0 {
		year = time.Now().Year()
	}

	lang := langOrDefault(req.Lang)

	params := url.Values{}
	params.Set("zodiac", itoa(req.Zodiac))
	params.Set("year", itoa(year))
	params.Set("lang", lang)

	return s.fetch(caller, "prediction/yearly", params, usermodel.HoroscopeRequest{
		HoroscopeType: HoroscopeTypeYearly,
		Zodiac:        intPtr(req.Zodiac),
		PeriodLabel:   strPtr(itoa(year)),
		PeriodYear:    intPtr(year),
		Langcode:      lang,
	})
}

//////////////////////////////////////////////////////////////
// Nakshatra
//////////////////////////////////////////////////////////////

func (s *horoscopeService) DailyNakshatra(
	caller dto.HoroscopeCaller,
	req dto.DailyNakshatraRequest,
) (*dto.VedicDataResponse, error) {

	date, day, err := todayIfEmpty(req.Date)

	if err != nil {
		return nil, err
	}

	lang := langOrDefault(req.Lang)

	params := url.Values{}
	params.Set("nakshatra", itoa(req.Nakshatra))
	params.Set("date", date)
	params.Set("lang", lang)

	return s.fetch(caller, "prediction/daily-nakshatra", params, usermodel.HoroscopeRequest{
		HoroscopeType: HoroscopeTypeDailyNakshatra,
		Nakshatra:     intPtr(req.Nakshatra),
		PeriodDate:    &day,
		PeriodLabel:   strPtr(dayLabel(day)),
		PeriodYear:    intPtr(day.Year()),
		Langcode:      lang,
	})
}

func (s *horoscopeService) Feedback(
	caller dto.HoroscopeCaller,
	req dto.HoroscopeFeedbackRequest,
) error {

	if caller.UserID == 0 {
		return errors.New("unauthorized")
	}

	now := time.Now()

	row := usermodel.HoroscopeFeedback{
		UserID:        caller.UserID,
		Feedback:      optionalStr(strings.TrimSpace(req.Feedback)),
		FeedbackType:  req.FeedbackType,
		HoroscopeType: optionalStr(req.HoroscopeType),
		Platform:      optionalStr(caller.Platform),
		IsTest:        false,
		CreatedAt:     now,
		UpdatedAt:     now,
	}

	if req.Zodiac > 0 {
		row.Zodiac = intPtr(req.Zodiac)
	}

	if err := s.repo.CreateHoroscopeFeedback(&row); err != nil {
		return fmt.Errorf("could not save feedback: %w", err)
	}

	return nil
}

func (s *horoscopeService) AstrologerID(userID uint) (uint, error) {
	return s.repo.GetAstrologerIDByUserID(userID)
}

//////////////////////////////////////////////////////////////
// Signs
//////////////////////////////////////////////////////////////

// zodiacByName is the VedicAstroAPI numbering, 1 Aries .. 12 Pisces.
var zodiacByName = map[string]int{
	"aries": 1, "taurus": 2, "gemini": 3, "cancer": 4,
	"leo": 5, "virgo": 6, "libra": 7, "scorpio": 8,
	"sagittarius": 9, "capricorn": 10, "aquarius": 11, "pisces": 12,
}

func (s *horoscopeService) Signs() ([]dto.HoroscopeSign, error) {

	rows, err := s.repo.GetHoroscopeSigns()

	if err != nil {
		return nil, err
	}

	signs := make([]dto.HoroscopeSign, 0, len(rows))

	for _, row := range rows {

		zodiac, ok := zodiacByName[strings.ToLower(strings.TrimSpace(row.Name))]

		if !ok {
			continue
		}

		signs = append(signs, dto.HoroscopeSign{
			Zodiac:    zodiac,
			SignID:    row.ID,
			Name:      row.Name,
			Image:     adminAssetURL(row.Image),
			ImagePath: row.Image,
			SvgImage:  svgOrNil(row.SvgImage),
		})
	}

	sort.Slice(signs, func(i, j int) bool { return signs[i].Zodiac < signs[j].Zodiac })

	return signs, nil
}

// defaultAdminURL is used when ADMIN_URL is unset. Set ADMIN_URL on live, or
// sign images will point at the dev admin.
const defaultAdminURL = "https://dev-admin.astrology.togwe.com"

// adminAssetURL turns a path stored by the admin panel (storage/images/...)
// into a full URL on the admin host. Absolute URLs and empty paths pass
// through unchanged.
func adminAssetURL(path string) string {

	path = strings.TrimSpace(path)

	if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
		return path
	}

	base := strings.TrimRight(strings.TrimSpace(os.Getenv("ADMIN_URL")), "/")

	if base == "" {
		base = defaultAdminURL
	}

	return base + "/" + strings.TrimLeft(path, "/")
}

// svgOrNil treats a blank svgImage as absent, so the apps only check for null.
func svgOrNil(svg *string) *string {

	if svg == nil || strings.TrimSpace(*svg) == "" {
		return nil
	}

	return svg
}
