package lookup

import (
	"context"
	"encoding/xml"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"
)

// HamQTH is a lookup.Provider for hamqth.com (free with registration).
type HamQTH struct {
	User     string
	Password string

	HTTP *http.Client

	mu       sync.Mutex
	session  string
	loggedAt time.Time
}

func NewHamQTH(user, password string) *HamQTH {
	return &HamQTH{
		User:     user,
		Password: password,
		HTTP:     &http.Client{Timeout: 12 * time.Second},
	}
}

func (h *HamQTH) Name() string { return "hamqth" }

func (h *HamQTH) Lookup(ctx context.Context, callsign string) (Result, error) {
	if h.User == "" || h.Password == "" {
		return Result{}, fmt.Errorf("hamqth: credentials not set")
	}
	id, err := h.sessionID(ctx)
	if err != nil {
		return Result{}, err
	}
	r, err := h.fetch(ctx, id, callsign)
	if err == errHamQTHSessionExpired {
		h.mu.Lock()
		h.session = ""
		h.mu.Unlock()
		id, err = h.sessionID(ctx)
		if err != nil {
			return Result{}, err
		}
		r, err = h.fetch(ctx, id, callsign)
	}
	return r, err
}

func (h *HamQTH) sessionID(ctx context.Context) (string, error) {
	h.mu.Lock()
	defer h.mu.Unlock()
	// HamQTH sessions stay valid ~1h; re-login after 30min defensively.
	if h.session != "" && time.Since(h.loggedAt) < 30*time.Minute {
		return h.session, nil
	}
	u := fmt.Sprintf("https://www.hamqth.com/xml.php?u=%s&p=%s",
		url.QueryEscape(h.User), url.QueryEscape(h.Password))
	body, err := h.get(ctx, u)
	if err != nil {
		return "", err
	}
	var resp hamqthRoot
	if err := xml.Unmarshal(body, &resp); err != nil {
		return "", fmt.Errorf("hamqth: parse session: %w", err)
	}
	if resp.Session.Error != "" {
		return "", fmt.Errorf("hamqth login: %s", resp.Session.Error)
	}
	if resp.Session.ID == "" {
		return "", fmt.Errorf("hamqth: empty session id")
	}
	h.session = resp.Session.ID
	h.loggedAt = time.Now()
	return h.session, nil
}

var errHamQTHSessionExpired = fmt.Errorf("hamqth: session expired")

func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result, error) {
	u := fmt.Sprintf("https://www.hamqth.com/xml.php?id=%s&callsign=%s&prg=HamLog",
		url.QueryEscape(sessionID), url.QueryEscape(callsign))
	body, err := h.get(ctx, u)
	if err != nil {
		return Result{}, err
	}
	return parseHamQTHSearch(body)
}

// parseHamQTHSearch turns one answer into a Result. Split out from the request
// so the field mapping can be checked against a captured payload — which is how
// the missing full name and picture were found.
func parseHamQTHSearch(body []byte) (Result, error) {
	var resp hamqthRoot
	if err := xml.Unmarshal(body, &resp); err != nil {
		return Result{}, fmt.Errorf("hamqth: parse callsign: %w", err)
	}
	if resp.Session.Error != "" {
		msg := strings.ToLower(resp.Session.Error)
		if strings.Contains(msg, "session") || strings.Contains(msg, "expired") {
			return Result{}, errHamQTHSessionExpired
		}
		if strings.Contains(msg, "not found") || strings.Contains(msg, "callsign") {
			return Result{}, ErrNotFound
		}
		return Result{}, fmt.Errorf("hamqth: %s", resp.Session.Error)
	}
	s := resp.Search
	if s.Callsign == "" {
		return Result{}, ErrNotFound
	}
	// <nick> is the name the operator goes BY on the air, which is what belongs
	// in a log — "Igor", not "Igor Vladimirovich Getman". adr_name is the postal
	// name and is used only when there is nothing else, so a record carrying
	// neither nick nor name no longer resolves to blank.
	name := strings.TrimSpace(s.Nick + " " + s.LastName)
	if name == "" {
		name = strings.TrimSpace(s.AdrName)
	}
	r := Result{
		Callsign:  strings.ToUpper(s.Callsign),
		Name:      name,
		QTH:       firstNonEmpty(s.QTH, s.AdrCity),
		Address:   s.AdrStreet1,
		Zip:       s.AdrZip,
		State:     strings.ToUpper(s.USState),
		County:    s.USCounty,
		Country:   firstNonEmpty(s.AdrCountry, s.Country),
		Grid:      strings.ToUpper(s.Grid),
		Continent: strings.ToUpper(s.Continent),
		Email:     s.Email,
		QSLVia:    s.QSLVia,
		Web:       s.Web,
		ImageURL:  s.Picture,
		RDA:       rdaRef(s.Oblast, s.District),
	}
	r.Lat, _ = strconv.ParseFloat(s.Latitude, 64)
	r.Lon, _ = strconv.ParseFloat(s.Longitude, 64)
	r.DXCC, _ = strconv.Atoi(s.DXCC)
	r.CQZ, _ = strconv.Atoi(s.CQ)
	r.ITUZ, _ = strconv.Atoi(s.ITU)
	return r, nil
}

// rdaRefRe matches a Russian district reference: two letters, a hyphen, two
// digits. Searched for rather than anchored, because the field is filled by
// hand and arrives as "KE-29", "KE-29 Kemerovskaya obl." or with stray spaces
// depending on which editor the operator used.
var rdaRefRe = regexp.MustCompile(`\b([A-Z]{2}-[0-9]{2})\b`)

// rdaRef extracts a district reference from HamQTH's oblast/district fields.
//
// Strict about the SHAPE and silent otherwise: a value that is not a district
// reference — an oblast name, a postal code, free text — must yield nothing.
// Writing it into the award anyway would create a reference no list contains,
// which counts for zero and has to be found and removed by hand later.
func rdaRef(fields ...string) string {
	for _, f := range fields {
		if m := rdaRefRe.FindStringSubmatch(strings.ToUpper(strings.TrimSpace(f))); m != nil {
			return m[1]
		}
	}
	return ""
}

func (h *HamQTH) get(ctx context.Context, u string) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
	if err != nil {
		return nil, err
	}
	resp, err := h.HTTP.Do(req)
	if err != nil {
		return nil, fmt.Errorf("hamqth http: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("hamqth http %d", resp.StatusCode)
	}
	return io.ReadAll(resp.Body)
}

// ----- XML shapes -----

type hamqthRoot struct {
	XMLName xml.Name      `xml:"HamQTH"`
	Session hamqthSession `xml:"session"`
	Search  hamqthSearch  `xml:"search"`
}

type hamqthSession struct {
	ID    string `xml:"session_id"`
	Error string `xml:"error"`
}

type hamqthSearch struct {
	Callsign   string `xml:"callsign"`
	Nick       string `xml:"nick"`
	LastName   string `xml:"name"`
	QTH        string `xml:"qth"`
	AdrStreet1 string `xml:"adr_street1"`
	AdrCity    string `xml:"adr_city"`
	Country    string `xml:"country"`
	AdrCountry string `xml:"adr_country"`
	USState    string `xml:"us_state"`
	USCounty   string `xml:"us_county"`
	// Oblast is HamQTH's Russian district field; District is its generic one.
	// Both are read because operators fill whichever their editor showed them.
	Oblast    string `xml:"oblast"`
	District  string `xml:"district"`
	Grid      string `xml:"grid"`
	Latitude  string `xml:"latitude"`
	Longitude string `xml:"longitude"`
	DXCC      string `xml:"adif"` // HamQTH exposes the ADIF/DXCC number under <adif>
	CQ        string `xml:"cq"`
	ITU       string `xml:"itu"`
	Continent string `xml:"continent"`
	Email     string `xml:"email"`
	QSLVia    string `xml:"qsl_via"`
	// AdrName is the full postal name. Many records carry it and no <name> at
	// all, so building the name from <nick> + <name> silently kept the first
	// name and dropped the rest.
	AdrName string `xml:"adr_name"`
	AdrZip  string `xml:"adr_zip"`
	// Picture is HamQTH's profile photo. Result.ImageURL was documented as "QRZ
	// only" because this element was never read — QRZ calls the same thing
	// <image>.
	Picture string `xml:"picture"`
	Web     string `xml:"web"`
}
