package weather

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"math"
	"net/http"
	"net/url"
	"strings"
	"time"
	"unicode"

	"github.com/kami/maven/internal/morph"
)

// clientTimeout — the whole request, geocode or forecast. Both are one call to
// a public API over the internet rather than a LAN service, hence longer than
// a bare "it's slow" budget; there is no retry behind it, so a slow reply
// still costs the caller the whole wait.
const clientTimeout = 10 * time.Second

// maxResponseBytes bounds both Open-Meteo JSON endpoints. count=1 geocoding
// and one current-weather object are normally a few kilobytes; 1 MiB leaves
// ample schema headroom without trusting an internet response to allocate
// without bound.
const maxResponseBytes int64 = 1 << 20

type OpenMeteoProvider struct {
	httpClient *http.Client
}

func NewOpenMeteoProvider() *OpenMeteoProvider {
	return &OpenMeteoProvider{
		httpClient: &http.Client{Timeout: clientTimeout},
	}
}

func (p *OpenMeteoProvider) SetClient(c *http.Client) {
	p.httpClient = c
}

type geoResult struct {
	Name      *string  `json:"name"`
	Latitude  *float64 `json:"latitude"`
	Longitude *float64 `json:"longitude"`
	Country   string   `json:"country"`
}

type geoResponse struct {
	Results []geoResult `json:"results"`
}

type currentWeather struct {
	Temperature *float64 `json:"temperature"`
	WMO         *int     `json:"weathercode"`
	WindSpeed   *float64 `json:"windspeed"`
}

type forecastResponse struct {
	CurrentWeather *currentWeather `json:"current_weather"`
}

var wmoCodes = map[int]string{
	0:  "ясно",
	1:  "преимущественно ясно",
	2:  "облачно",
	3:  "пасмурно",
	45: "туман",
	51: "морось",
	61: "дождь",
	71: "снег",
	95: "гроза",
}

func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string) (Weather, error) {
	lat, lon, name, err := p.geocode(ctx, location)
	if err != nil {
		return Weather{}, fmt.Errorf("open-meteo: geocode: %w", err)
	}

	u := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f&current_weather=true", lat, lon)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return Weather{}, fmt.Errorf("open-meteo: request: %w", err)
	}

	resp, err := p.httpClient.Do(req)
	if err != nil {
		return Weather{}, fmt.Errorf("open-meteo: http: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return Weather{}, fmt.Errorf("open-meteo forecast: http %d", resp.StatusCode)
	}

	var forecast forecastResponse
	if err := decodeBounded(resp.Body, &forecast); err != nil {
		return Weather{}, fmt.Errorf("open-meteo: decode: %w", err)
	}
	if err := validateCurrentWeather(forecast.CurrentWeather); err != nil {
		return Weather{}, fmt.Errorf("open-meteo: invalid forecast: %w", err)
	}

	condition := wmoCodes[*forecast.CurrentWeather.WMO]
	if condition == "" {
		condition = "неизвестно"
	}

	return Weather{
		Location:    name,
		Temperature: *forecast.CurrentWeather.Temperature,
		Condition:   condition,
	}, nil
}

// locationCandidates — the spellings to try for a place taken out of a spoken
// sentence, in order. He says "какая погода в Казани", so the word arrives in
// the prepositional case and the geocoder wants the nominative (Vikunja #421).
//
// The dictionary answers first (Vikunja #530). internal/morph lemmatises
// "Уфе" to "Уфа" and "Москве" to "Москва", which is the same question this
// used to guess at by reversing endings, asked of something that knows.
//
// The reversals stay behind it, because the dictionary does not know every
// place: "Твери" and "Перми" come back unchanged, and a final "и" is usually a
// soft sign. A final "е" is usually a nominative "а" (Москве → Москва) or
// nothing at all (Лондоне → Лондон). Indeclinable names — Тбилиси, Сочи, Осло —
// are already nominative and the first candidate answers, which is why the word
// as spoken is always tried before anything derived from it.
//
// There used to be a four-rune floor here, so "Уфе" was asked as spoken and
// "Уфа" was never tried. The floor was there to stop a two-letter stem, and the
// stem length is what it now tests.
//
// Nothing here is a guess about the weather: a wrong candidate finds no city
// and the caller says so. It only decides which strings are worth asking about.
func locationCandidates(location string) []string {
	out := []string{location}
	add := func(s string) {
		if s == "" || s == location {
			return
		}
		for _, seen := range out {
			if seen == s {
				return
			}
		}
		out = append(out, s)
	}
	add(titleFirst(morph.Lemma(location)))
	r := []rune(location)
	if len(r) < 3 {
		return out
	}
	stem := string(r[:len(r)-1])
	switch r[len(r)-1] {
	case 'е', 'Е':
		add(stem + "а")
		add(stem)
	case 'и', 'И':
		add(stem + "ь")
		add(stem)
	case 'у', 'У', 'ю', 'Ю':
		add(stem + "а")
	}
	return out
}

// titleFirst restores the leading capital a place name carries. morph.Lemma
// answers lowercased, because a lemma is a dictionary entry and the dictionary
// has no opinion about proper nouns.
func titleFirst(s string) string {
	r := []rune(s)
	if len(r) == 0 {
		return s
	}
	return string(unicode.ToUpper(r[0])) + string(r[1:])
}

func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
	for _, cand := range locationCandidates(location) {
		lat, lon, name, err = p.geocodeOne(ctx, cand)
		if err == nil {
			return lat, lon, name, nil
		}
	}
	return 0, 0, "", err
}

func (p *OpenMeteoProvider) geocodeOne(ctx context.Context, location string) (lat, lon float64, name string, err error) {
	u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=ru&format=json", url.QueryEscape(location))
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return 0, 0, "", err
	}

	resp, err := p.httpClient.Do(req)
	if err != nil {
		return 0, 0, "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return 0, 0, "", fmt.Errorf("open-meteo geocode: http %d", resp.StatusCode)
	}

	var geo geoResponse
	if err := decodeBounded(resp.Body, &geo); err != nil {
		return 0, 0, "", err
	}

	if len(geo.Results) == 0 {
		return 0, 0, "", fmt.Errorf("%w: %q", ErrLocationUnknown, location)
	}

	r := geo.Results[0]
	if r.Name == nil || r.Latitude == nil || r.Longitude == nil {
		return 0, 0, "", fmt.Errorf("open-meteo geocode: result is missing name or coordinates")
	}
	name = strings.TrimSpace(*r.Name)
	if name == "" {
		return 0, 0, "", fmt.Errorf("open-meteo geocode: result name is blank")
	}
	if !finite(*r.Latitude) || *r.Latitude < -90 || *r.Latitude > 90 ||
		!finite(*r.Longitude) || *r.Longitude < -180 || *r.Longitude > 180 {
		return 0, 0, "", fmt.Errorf("open-meteo geocode: coordinates are out of range")
	}
	return *r.Latitude, *r.Longitude, name, nil
}

func decodeBounded(r io.Reader, dst any) error {
	body, err := io.ReadAll(io.LimitReader(r, maxResponseBytes+1))
	if err != nil {
		return err
	}
	if int64(len(body)) > maxResponseBytes {
		return fmt.Errorf("response exceeds %d bytes", maxResponseBytes)
	}
	return json.Unmarshal(body, dst)
}

func validateCurrentWeather(w *currentWeather) error {
	if w == nil || w.Temperature == nil || w.WMO == nil || w.WindSpeed == nil {
		return fmt.Errorf("current_weather, temperature, weathercode and windspeed are required")
	}
	if !finite(*w.Temperature) || *w.Temperature < -100 || *w.Temperature > 70 {
		return fmt.Errorf("temperature %v is out of range", *w.Temperature)
	}
	if *w.WMO < 0 || *w.WMO > 99 {
		return fmt.Errorf("weathercode %d is out of range", *w.WMO)
	}
	if !finite(*w.WindSpeed) || *w.WindSpeed < 0 || *w.WindSpeed > 500 {
		return fmt.Errorf("windspeed %v is out of range", *w.WindSpeed)
	}
	return nil
}

func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
