package extsvc

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"
)

// Cloudlog (and its fork Wavelog) are self-hosted logbooks, so there is no
// fixed endpoint: the user gives the base URL of their own instance and we
// append the API path. Both expose the SAME contract — an ADIF record wrapped
// in JSON — which is why one uploader serves both.
//
//	POST <base>/index.php/api/qso
//	{"key":"…","station_profile_id":"1","type":"adif","string":"<call:4>… <eor>"}
//
// The station profile id is NOT optional: Cloudlog files the QSO under one of
// the account's station locations, and a wrong id silently lands the contact in
// someone else's log slot.
const cloudlogAPIPath = "index.php/api/qso"

// cloudlogEndpoint builds the API URL from whatever the user pasted. People
// paste the dashboard URL, the API URL, with or without a trailing slash or
// index.php, so normalise all of it rather than make them guess the exact form.
func cloudlogEndpoint(base string) (string, error) {
	u := strings.TrimSpace(base)
	if u == "" {
		return "", fmt.Errorf("cloudlog: URL not set")
	}
	// A bare host or IP is almost always meant as http:// on a LAN instance;
	// requiring the scheme just produces a confusing "unsupported protocol".
	if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
		u = "http://" + u
	}
	u = strings.TrimRight(u, "/")
	// Trim anything the user copied past the site root, so both
	// "https://log.f4bpo.fr" and "https://log.f4bpo.fr/index.php/api/qso" work.
	for _, suffix := range []string{"/index.php/api/qso", "/api/qso", "/index.php"} {
		if strings.HasSuffix(strings.ToLower(u), suffix) {
			u = u[:len(u)-len(suffix)]
			u = strings.TrimRight(u, "/")
		}
	}
	return u + "/" + cloudlogAPIPath, nil
}

// cloudlogRequest is the JSON body both Cloudlog and Wavelog expect.
type cloudlogRequest struct {
	Key       string `json:"key"`
	StationID string `json:"station_profile_id"`
	Type      string `json:"type"`
	String    string `json:"string"`
}

// cloudlogReply covers the documented failure shape
// ({"status":"failed","reason":"missing api key"}); success replies vary
// between versions, so success is judged on the HTTP status plus the ABSENCE
// of a failure marker rather than on a field that may not be there.
type cloudlogReply struct {
	Status string `json:"status"`
	Reason string `json:"reason"`
	Type   string `json:"type"`
	String string `json:"string"`
}

// cloudlogPost sends one JSON body and returns the trimmed response.
func cloudlogPost(ctx context.Context, client *http.Client, endpoint string, body cloudlogRequest) (string, int, error) {
	buf, err := json.Marshal(body)
	if err != nil {
		return "", 0, fmt.Errorf("cloudlog: encode request: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
	if err != nil {
		return "", 0, fmt.Errorf("cloudlog: build request: %w", err)
	}
	// Content-Type is what most "wrong JSON" reports come down to: without it
	// Cloudlog falls back to form-decoding and never sees the fields.
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	if client == nil {
		client = &http.Client{Timeout: 20 * time.Second}
	}
	resp, err := client.Do(req)
	if err != nil {
		return "", 0, fmt.Errorf("cloudlog: request failed: %w", err)
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
	return strings.TrimSpace(string(raw)), resp.StatusCode, nil
}

// cloudlogReason turns a reply into a human-readable failure reason, or ""
// when the reply looks like a success.
func cloudlogReason(body string, status int) string {
	var r cloudlogReply
	if json.Unmarshal([]byte(body), &r) == nil && strings.EqualFold(r.Status, "failed") {
		if r.Reason != "" {
			return r.Reason
		}
		return "rejected"
	}
	switch {
	case status == http.StatusUnauthorized || status == http.StatusForbidden:
		// The documented 401 body is {"status":"failed","reason":"missing api key"},
		// but a reverse proxy in front of the instance can swallow it.
		return "API key refused"
	case status == http.StatusNotFound:
		return "API not found at this URL — check the address of your Cloudlog/Wavelog instance"
	case status >= 400:
		msg := body
		if len(msg) > 200 {
			msg = msg[:200]
		}
		if msg == "" {
			msg = fmt.Sprintf("HTTP %d", status)
		}
		return msg
	}
	return ""
}

// UploadCloudlog pushes one ADIF record to a Cloudlog or Wavelog instance.
//
// Duplicates are handled by the server (Cloudlog dedupes on the fly), so a
// retry of an already-accepted QSO is harmless — the upload stays idempotent
// without OpsLog having to track it.
func UploadCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
	endpoint, err := cloudlogEndpoint(cfg.URL)
	if err != nil {
		return UploadResult{}, err
	}
	key := strings.TrimSpace(cfg.APIKey)
	station := strings.TrimSpace(cfg.StationID)
	if key == "" {
		return UploadResult{}, fmt.Errorf("cloudlog: API key not set")
	}
	if station == "" {
		return UploadResult{}, fmt.Errorf("cloudlog: station ID not set")
	}
	if strings.TrimSpace(adifRecord) == "" {
		return UploadResult{}, fmt.Errorf("cloudlog: empty adif record")
	}

	body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
		Key: key, StationID: station, Type: "adif", String: adifRecord,
	})
	if err != nil {
		return UploadResult{OK: false, Message: body}, err
	}
	// The endpoint is echoed in both outcomes: a self-hosted instance means the
	// URL itself is a prime suspect, and a log line naming what was actually
	// called settles it without the operator having to guess how we normalised
	// what they typed.
	if reason := cloudlogReason(body, status); reason != "" {
		return UploadResult{OK: false, Message: reason},
			fmt.Errorf("cloudlog: POST %s → HTTP %d: %s", endpoint, status, reason)
	}
	return UploadResult{OK: true, Message: fmt.Sprintf("uploaded to %s (HTTP %d)", endpoint, status)}, nil
}

// TestCloudlog validates URL, API key and station ID with a REAL request.
//
// It posts a well-formed body with an EMPTY ADIF string: the credentials are
// checked by the server before the record is parsed, so a bad key or id fails
// exactly as it would for a real QSO, while nothing is inserted.
func TestCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
	endpoint, err := cloudlogEndpoint(cfg.URL)
	if err != nil {
		return "", err
	}
	key := strings.TrimSpace(cfg.APIKey)
	station := strings.TrimSpace(cfg.StationID)
	if key == "" {
		return "", fmt.Errorf("cloudlog: API key not set")
	}
	if station == "" {
		return "", fmt.Errorf("cloudlog: station ID not set")
	}
	body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
		Key: key, StationID: station, Type: "adif", String: "",
	})
	if err != nil {
		return "", err
	}
	if reason := cloudlogReason(body, status); reason != "" {
		return "", fmt.Errorf("cloudlog: %s", reason)
	}
	return fmt.Sprintf("Connected — station profile %s", station), nil
}
