package extsvc

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

// eqslImportURL is eQSL.cc's ADIF import endpoint. It accepts a form-encoded
// POST (or URL params) with the account credentials and the ADIF content.
const eqslImportURL = "https://www.eQSL.cc/qslcard/ImportADIF.cfm"

// eqslResultRe extracts "Result: X out of Y records added" from the reply.
var eqslResultRe = regexp.MustCompile(`(?i)result:\s*(\d+)\s+out of\s+(\d+)\s+records added`)

// eqslPost performs the import POST and returns the raw response body. eQSL
// replies HTTP 200 with a plain-text/HTML body for both success and errors;
// callers classify it via the markers below.
func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) (string, error) {
	form := url.Values{}
	form.Set("EQSL_USER", user)
	form.Set("EQSL_PSWD", pswd)
	form.Set("ADIFData", adif)

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, eqslImportURL, strings.NewReader(form.Encode()))
	if err != nil {
		return "", fmt.Errorf("eqsl: build request: %w", err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	if client == nil {
		client = &http.Client{Timeout: 30 * time.Second}
	}
	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("eqsl: request failed: %w", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
	msg := strings.TrimSpace(string(body))
	if resp.StatusCode != http.StatusOK {
		if msg == "" {
			msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
		}
		return msg, fmt.Errorf("eqsl: http %d: %s", resp.StatusCode, msg)
	}
	return msg, nil
}

// eQSL Inbox download endpoint + base host. DownloadInBox.cfm builds an ADIF of
// the account's *received* eQSLs (confirmations) and returns an HTML page that
// links to the generated .adi file; we then fetch that file (two-step API).
const (
	eqslDownloadInboxURL = "https://www.eQSL.cc/qslcard/DownloadInBox.cfm"
	eqslBaseURL          = "https://www.eQSL.cc"
)

// eqslAdiHrefRe pulls the generated .adi link out of the DownloadInBox reply.
// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes,
// e.g. `<A HREF=/downloadedFiles/xxxx.adi>` or `<a href="…/xxxx.adi">`, and the
// path may be root-relative or a bare filename. Match case-insensitively with
// optional quoting; resolve to an absolute URL afterwards.
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.adi)`)

// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs =
// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since
// then, incremental "last download"); qthNick comes from the config when the
// account has several QTH profiles.
func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
	user := strings.ToUpper(strings.TrimSpace(cfg.Username))
	if user == "" || strings.TrimSpace(cfg.Password) == "" {
		return "", fmt.Errorf("eqsl: username/password not set")
	}
	if client == nil {
		client = &http.Client{Timeout: 120 * time.Second}
	}
	q := url.Values{}
	q.Set("UserName", user)
	q.Set("Password", cfg.Password)
	if nick := strings.TrimSpace(cfg.QTHNickname); nick != "" {
		q.Set("QTHNickname", nick)
	}
	if s := eqslRcvdSince(since); s != "" {
		q.Set("RcvdSince", s) // eQSL wants YYYYMMDDHHMM
	}

	// Step 1: ask eQSL to build the inbox ADIF; the reply is HTML linking to it.
	page, err := eqslGet(ctx, client, eqslDownloadInboxURL+"?"+q.Encode())
	if err != nil {
		return "", err
	}
	if reason := authErrEQSL(page); reason != "" {
		return "", fmt.Errorf("eqsl: %s", reason)
	}
	m := eqslAdiHrefRe.FindStringSubmatch(page)
	if m == nil {
		// eQSL reports problems inline ("Error: …", "You have no … confirmations").
		return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page))
	}

	// Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so
	// the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\
	// xxx.adi`); normalise those. It may also be absolute or a bare filename.
	link := strings.TrimSpace(m[1])
	link = strings.ReplaceAll(link, `\`, "/")
	link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…"
	var fileURL string
	switch {
	case strings.HasPrefix(strings.ToLower(link), "http"):
		fileURL = link
	case strings.HasPrefix(link, "/"):
		fileURL = eqslBaseURL + link
	default:
		fileURL = eqslBaseURL + "/" + link
	}

	// Step 2: fetch the generated .adi file.
	adif, err := eqslGet(ctx, client, fileURL)
	if err != nil {
		return "", fmt.Errorf("eqsl: fetch adif: %w", err)
	}
	return adif, nil
}

// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's
// own error/notice text if present, else a short tag-stripped snippet of the page
// so the real wording (e.g. "You have no Inbox items") is visible in the log.
func eqslNoLinkReason(page string) string {
	low := strings.ToLower(page)
	for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} {
		if i := strings.Index(low, marker); i >= 0 {
			seg := page[i:]
			if len(seg) > 160 {
				seg = seg[:160]
			}
			return strings.Join(strings.Fields(stripTags(seg)), " ")
		}
	}
	txt := strings.Join(strings.Fields(stripTags(page)), " ")
	if len(txt) > 200 {
		txt = txt[:200]
	}
	if txt == "" {
		txt = "empty response"
	}
	return txt
}

// stripTags removes HTML tags for a readable one-line diagnostic.
func stripTags(s string) string {
	var b strings.Builder
	depth := 0
	for _, r := range s {
		switch r {
		case '<':
			depth++
		case '>':
			if depth > 0 {
				depth--
			}
		default:
			if depth == 0 {
				b.WriteRune(r)
			}
		}
	}
	return b.String()
}

// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on
// a non-200 status.
func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return "", fmt.Errorf("eqsl: build request: %w", err)
	}
	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("eqsl: request failed: %w", err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("eqsl: http %d", resp.StatusCode)
	}
	return string(b), nil
}

// eqslRcvdSince converts the app's "YYYY-MM-DD" into eQSL's RcvdSince format
// "YYYYMMDDHHMM" (midnight). Empty in → empty out (full pull).
func eqslRcvdSince(since string) string {
	s := strings.ReplaceAll(strings.TrimSpace(since), "-", "")
	if len(s) == 8 { // YYYYMMDD → append 0000 (00:00)
		return s + "0000"
	}
	return s
}

// authErrEQSL returns a reason when the response signals bad credentials, else
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
func authErrEQSL(body string) string {
	if strings.Contains(strings.ToLower(body), "no match on eqsl") {
		return "invalid username or password"
	}
	return ""
}

// eqslRecordWithNickname prepends the APP_EQSL_QTH_NICKNAME tag to an ADIF
// record when nick is set, so eQSL files the QSO under the right QTH profile
// (required when the account has more than one). ADIF field order is free, so
// prepending before the rest of the record is valid.
func eqslRecordWithNickname(record, nick string) string {
	nick = strings.TrimSpace(nick)
	if nick == "" {
		return record
	}
	return fmt.Sprintf("<APP_EQSL_QTH_NICKNAME:%d>%s%s", len(nick), nick, record)
}

// UploadEQSL pushes one ADIF record to eQSL.cc for the given account. qthNick
// is the optional eQSL QTH nickname.
//
// eQSL replies with text: "Result: 1 out of 1 records added" on success,
// "Bad record: Duplicate" for an already-present QSO (treated as success so
// retries are idempotent), or "Error: No match on eQSL_User/eQSL_Pswd" for bad
// credentials.
func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, adifRecord string) (UploadResult, error) {
	user = strings.ToUpper(strings.TrimSpace(user))
	if user == "" {
		return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
	}
	if strings.TrimSpace(pswd) == "" {
		return UploadResult{}, fmt.Errorf("eqsl: password not set")
	}
	if strings.TrimSpace(adifRecord) == "" {
		return UploadResult{}, fmt.Errorf("eqsl: empty adif record")
	}

	body, err := eqslPost(ctx, client, user, pswd, eqslRecordWithNickname(adifRecord, qthNick))
	if err != nil {
		return UploadResult{OK: false, Message: body}, err
	}

	b := strings.ToLower(body)
	if reason := authErrEQSL(body); reason != "" {
		return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
	}
	if strings.Contains(b, "duplicate") {
		return UploadResult{OK: true, Message: "already in logbook"}, nil
	}
	if m := eqslResultRe.FindStringSubmatch(body); m != nil {
		added, _ := strconv.Atoi(m[1])
		if added >= 1 {
			return UploadResult{OK: true, Message: strings.TrimSpace(m[0])}, nil
		}
		// "0 out of N" — eQSL accepted nothing; surface why if it said so.
		reason := eqslReason(body)
		return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
	}
	reason := eqslReason(body)
	return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
}

// eqslBatchMax is the largest number of records eQSL asks a single upload to
// carry ("upload only files smaller than about 1000 records at a time", eQSL's
// own ImportADIF interface notes). Callers chunk to this.
const eqslBatchMax = 1000

// UploadEQSLBatch pushes MANY ADIF records to eQSL.cc in ONE request.
//
// ImportADIF.cfm is a file importer, not a per-QSO endpoint: it takes one *or
// more* QSOs and answers "Result: X out of Y records added" — the plural in its
// own reply. So an on-close sweep or a bulk upload is one request, not one per
// contact. No ADIF header is prepended: the single-record path has always posted
// bare <EOR> records and eQSL accepts them (per ADIF, a file starting with '<'
// has no header), and there is no reason to change what is known to work.
//
// A PARTIAL result ("97 out of 100") sets Ignored so the caller can say so.
// eQSL does not identify which records it left out, and in practice they are
// QSOs it already holds — the same duplicate that UploadEQSL reports as success.
func UploadEQSLBatch(ctx context.Context, client *http.Client, user, pswd, qthNick string, records []string) (UploadResult, error) {
	user = strings.ToUpper(strings.TrimSpace(user))
	if user == "" {
		return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
	}
	if strings.TrimSpace(pswd) == "" {
		return UploadResult{}, fmt.Errorf("eqsl: password not set")
	}
	docs := make([]string, 0, len(records))
	for _, r := range records {
		if strings.TrimSpace(r) == "" {
			continue
		}
		docs = append(docs, eqslRecordWithNickname(strings.TrimRight(r, "\r\n"), qthNick))
	}
	if len(docs) == 0 {
		return UploadResult{}, fmt.Errorf("eqsl: empty adif batch")
	}
	if len(docs) > eqslBatchMax {
		return UploadResult{}, fmt.Errorf("eqsl: batch of %d exceeds the %d-record limit", len(docs), eqslBatchMax)
	}

	body, err := eqslPost(ctx, client, user, pswd, strings.Join(docs, "\n"))
	if err != nil {
		return UploadResult{OK: false, Message: body}, err
	}
	if reason := authErrEQSL(body); reason != "" {
		return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
	}

	// The counted result is read FIRST here, unlike the single-record path: a
	// batch reply routinely carries both "Result: 97 out of 100 records added"
	// and a "Bad record: Duplicate" line for the other three, and matching the
	// duplicate first would throw away the count that says the rest went in.
	if m := eqslResultRe.FindStringSubmatch(body); m != nil {
		added, _ := strconv.Atoi(m[1])
		total, _ := strconv.Atoi(m[2])
		if added >= 1 {
			return UploadResult{OK: true, Message: strings.TrimSpace(m[0]), Ignored: added < total}, nil
		}
		// "0 out of N" — nothing added. A re-upload of QSOs eQSL already holds
		// lands here, and that is not a failure.
		if strings.Contains(strings.ToLower(body), "duplicate") {
			return UploadResult{OK: true, Message: "already in logbook"}, nil
		}
		reason := eqslReason(body)
		return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: batch upload failed: %s", reason)
	}
	if strings.Contains(strings.ToLower(body), "duplicate") {
		return UploadResult{OK: true, Message: "already in logbook"}, nil
	}
	reason := eqslReason(body)
	return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: batch upload failed: %s", reason)
}

// eqslReason trims an eQSL reply to a short human-readable reason: the first
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
// (capped), else a generic phrase.
func eqslReason(body string) string {
	for _, line := range strings.Split(body, "\n") {
		l := strings.TrimSpace(line)
		ll := strings.ToLower(l)
		if strings.HasPrefix(ll, "error:") || strings.HasPrefix(ll, "warning:") || strings.Contains(ll, "bad record") {
			return l
		}
	}
	b := strings.TrimSpace(body)
	if b == "" {
		return "upload rejected"
	}
	if len(b) > 200 {
		b = b[:200]
	}
	return b
}

// TestEQSL validates the configured eQSL credentials with a REAL request: it
// posts an empty ADIF so nothing is inserted, then checks for the bad-login
// marker. Anything else means the credentials were accepted.
func TestEQSL(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
	user := strings.ToUpper(strings.TrimSpace(cfg.Username))
	if user == "" {
		return "", fmt.Errorf("eqsl: username (callsign) not set")
	}
	if strings.TrimSpace(cfg.Password) == "" {
		return "", fmt.Errorf("eqsl: password not set")
	}
	body, err := eqslPost(ctx, client, user, cfg.Password, "")
	if err != nil {
		return "", err
	}
	if reason := authErrEQSL(body); reason != "" {
		return "", fmt.Errorf("eqsl: %s", reason)
	}
	return fmt.Sprintf("Credentials accepted — %s", user), nil
}
