// Package implements "galaxy/connector.Connector" interface with HTTP REST API protocol
package http

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"galaxy/connector"
	gerr "galaxy/error"
	"galaxy/model/client"
	"galaxy/model/report"
	"io"
	"math/rand/v2"
	"net"
	"net/http"
	"net/url"
	"path"
	"strconv"
	"strings"
	"time"
)

const (
	// checkConnectionPath is backend endpoint path used to test server reachability.
	checkConnectionPath = "api/v1/status"
	// checkVersionPath is backend endpoint path used to load available app versions.
	checkVersionPath = "api/v1/versions"
	// fetchReportPath is backend endpoint path used to load game report for a specific turn number.
	fetchReportPath = "api/v1/report"
	// fetchReportPlayer is a temporary player identifier until UI passes actor identity explicitly.
	fetchReportPlayer = "Race_01"

	// connectTimeout is max time for establishing TCP connection.
	connectTimeout = 3 * time.Second
	// responseTimeout is max time for waiting response headers from backend.
	responseTimeout = 3 * time.Second
)

// defaultRetryCaps defines connect-timeout retry caps for full-jitter backoff.
var defaultRetryCaps = []time.Duration{
	5 * time.Second,
	15 * time.Second,
	30 * time.Second,
	60 * time.Second,
}

// errMovedPermanentlyWithoutLocation reports an invalid redirect response.
var errMovedPermanentlyWithoutLocation = errors.New("server returned 301 response without Location header")

type httpConnector struct {
	ctx        context.Context
	backendURL *url.URL // HTTP REST API Server URL
	httpClient *http.Client
	retryCaps  []time.Duration
	jitterFn   func(time.Duration) time.Duration
	sleepFn    func(context.Context, time.Duration) error
}

func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) {
	u, err := url.Parse(backendURL)
	if err != nil {
		return nil, gerr.WrapService(fmt.Errorf("parse backend URL %q: %w", backendURL, err))
	}
	h := &httpConnector{
		ctx:        ctx,
		backendURL: u,
		httpClient: newHTTPClient(connectTimeout, responseTimeout),
		retryCaps:  append([]time.Duration(nil), defaultRetryCaps...),
		jitterFn:   fullJitter,
		sleepFn:    sleepWithContext,
	}
	return h, nil
}

// newHTTPClient builds dedicated HTTP client with separate timeouts
// for connect and response phases.
func newHTTPClient(connectTimeout, responseTimeout time.Duration) *http.Client {
	transport := http.DefaultTransport.(*http.Transport).Clone()
	transport.DialContext = (&net.Dialer{
		Timeout:   connectTimeout,
		KeepAlive: 30 * time.Second,
	}).DialContext
	transport.TLSHandshakeTimeout = connectTimeout
	transport.ResponseHeaderTimeout = responseTimeout

	return &http.Client{
		Transport: transport,
	}
}

// doNotFollowRedirect keeps redirect handling inside doRequest so retry budget
// and jitter stay under connector control.
func doNotFollowRedirect(_ *http.Request, _ []*http.Request) error {
	return http.ErrUseLastResponse
}

func (h *httpConnector) requestContext() context.Context {
	if h.ctx != nil {
		return h.ctx
	}

	return context.Background()
}

// fullJitter calculates random wait duration in [0, cap].
func fullJitter(cap time.Duration) time.Duration {
	if cap <= 0 {
		return 0
	}

	return time.Duration(rand.Int64N(cap.Nanoseconds() + 1))
}

// sleepWithContext blocks for the given duration or until context cancellation.
func sleepWithContext(ctx context.Context, d time.Duration) error {
	if d <= 0 {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
			return nil
		}
	}

	timer := time.NewTimer(d)
	defer timer.Stop()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}

// isConnectTimeout returns true for dial and TLS-handshake timeout errors.
func isConnectTimeout(err error) bool {
	if err == nil {
		return false
	}
	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false
	}

	var urlErr *url.Error
	if errors.As(err, &urlErr) {
		err = urlErr.Err
	}

	if strings.Contains(err.Error(), "timeout awaiting response headers") {
		return false
	}
	if strings.Contains(err.Error(), "TLS handshake timeout") {
		return true
	}

	var opErr *net.OpError
	if errors.As(err, &opErr) {
		return opErr.Op == "dial" && opErr.Timeout()
	}

	return false
}

// isConnectionError reports transport-level connectivity failures that should
// be surfaced as connection errors instead of service contract errors.
func isConnectionError(err error) bool {
	if err == nil {
		return false
	}
	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false
	}
	if isConnectTimeout(err) {
		return true
	}

	var urlErr *url.Error
	if errors.As(err, &urlErr) {
		err = urlErr.Err
	}

	var dnsErr *net.DNSError
	if errors.As(err, &dnsErr) {
		return true
	}

	var opErr *net.OpError
	if errors.As(err, &opErr) {
		return true
	}

	var netErr net.Error
	if errors.As(err, &netErr) && netErr.Timeout() {
		return true
	}

	return false
}

func classifyConnectorError(err error) error {
	if err == nil {
		return nil
	}
	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return err
	}
	if gerr.IsConnection(err) || gerr.IsService(err) {
		return err
	}
	if isConnectionError(err) {
		return gerr.WrapConnection(err)
	}
	return gerr.WrapService(err)
}

// CheckConnection probes backend status endpoint and reports whether server is reachable.
func (h *httpConnector) CheckConnection() bool {
	resp, err := h.doRequest(h.requestContext(), checkConnectionPath)
	if err != nil {
		return false
	}
	defer resp.Body.Close()

	return true
}

// CheckVersion loads available app versions from backend and returns parsed version metadata.
func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) {
	resp, err := h.doRequest(h.requestContext(), checkVersionPath)
	if err != nil {
		return nil, classifyConnectorError(fmt.Errorf("request versions from backend: %w", err))
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, classifyConnectorError(fmt.Errorf("request versions from backend: unexpected status code %d", resp.StatusCode))
	}

	var versions []connector.VersionInfo
	if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
		return nil, classifyConnectorError(fmt.Errorf("decode versions response: %w", err))
	}

	return versions, nil
}

// DownloadVersion retrieves a version artifact from backend storage.
// urlOrPath may be either a backend-relative path or a fully qualified URL.
func (h *httpConnector) DownloadVersion(urlOrPath string) ([]byte, error) {
	resp, err := h.doRequest(h.requestContext(), urlOrPath)
	if err != nil {
		return nil, classifyConnectorError(fmt.Errorf("download version artifact: %w", err))
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, classifyConnectorError(fmt.Errorf("download version artifact: unexpected status code %d", resp.StatusCode))
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, classifyConnectorError(fmt.Errorf("read version artifact body: %w", err))
	}

	return body, nil
}

// FetchReport asynchronously loads a report for turn from backend and invokes callback once with the result.
func (h *httpConnector) FetchReport(_ client.GameID, turn uint, callback func(report.Report, error)) {
	go func() {
		rep, err := h.fetchReport(turn)
		if callback != nil {
			callback(rep, err)
		}
	}()
}

// fetchReport loads a report for turn from backend using the temporary player identifier.
func (h *httpConnector) fetchReport(turn uint) (report.Report, error) {
	resp, err := h.doRequest(h.requestContext(), fetchReportRequestPath(turn))
	if err != nil {
		return report.Report{}, classifyConnectorError(fmt.Errorf("request report from backend: %w", err))
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return report.Report{}, classifyConnectorError(fmt.Errorf("request report from backend: unexpected status code %d", resp.StatusCode))
	}

	var rep report.Report
	if err := json.NewDecoder(resp.Body).Decode(&rep); err != nil {
		return report.Report{}, classifyConnectorError(fmt.Errorf("decode report response: %w", err))
	}

	return rep, nil
}

// fetchReportRequestPath builds the report endpoint with required query parameters.
func fetchReportRequestPath(turn uint) string {
	values := url.Values{}
	values.Set("player", fetchReportPlayer)
	values.Set("turn", strconv.FormatUint(uint64(turn), 10))

	return fetchReportPath + "?" + values.Encode()
}

// resolveRequestURL returns either the fully qualified request URL as-is or
// composes a backend-relative path with connector backendURL.
func (h *httpConnector) resolveRequestURL(urlOrPath string) (*url.URL, error) {
	requestURL, err := url.Parse(urlOrPath)
	if err != nil {
		return nil, fmt.Errorf("parse request URL %q: %w", urlOrPath, err)
	}

	if requestURL.IsAbs() {
		return requestURL, nil
	}

	resolvedURL := *h.backendURL
	resolvedURL.Path = path.Join(resolvedURL.Path, requestURL.Path)
	if requestURL.RawQuery != "" {
		resolvedURL.RawQuery = requestURL.RawQuery
	}
	if requestURL.Fragment != "" {
		resolvedURL.Fragment = requestURL.Fragment
	}

	return &resolvedURL, nil
}

// doHTTP executes a single HTTP exchange without the standard client redirect handling.
func (h *httpConnector) doHTTP(req *http.Request) (*http.Response, error) {
	client := h.httpClient
	if client == nil {
		client = newHTTPClient(connectTimeout, responseTimeout)
	}

	noRedirectClient := *client
	noRedirectClient.CheckRedirect = doNotFollowRedirect

	return noRedirectClient.Do(req)
}

// doRequest performs a GET request for either a backend-relative endpoint or a
// fully qualified URL with the passed context.
func (h *httpConnector) doRequest(ctx context.Context, urlOrPath string) (*http.Response, error) {
	requestURL, err := h.resolveRequestURL(urlOrPath)
	if err != nil {
		return nil, err
	}

	retryCaps := h.retryCaps
	if retryCaps == nil {
		retryCaps = defaultRetryCaps
	}
	jitterFn := h.jitterFn
	if jitterFn == nil {
		jitterFn = fullJitter
	}
	sleepFn := h.sleepFn
	if sleepFn == nil {
		sleepFn = sleepWithContext
	}

	var lastErr error
	for attempt := 0; attempt <= len(retryCaps); attempt++ {
		if attempt > 0 {
			delay := jitterFn(retryCaps[attempt-1])
			if delay < 0 {
				delay = 0
			}
			if err := sleepFn(ctx, delay); err != nil {
				return nil, err
			}
		}

		req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
		if err != nil {
			return nil, fmt.Errorf("create request: %w", err)
		}

		resp, err := h.doHTTP(req)
		if err == nil {
			if resp.StatusCode != http.StatusMovedPermanently {
				return resp, nil
			}

			location := resp.Header.Get("Location")
			resp.Body.Close()
			if location == "" {
				return nil, fmt.Errorf("request %q: %w", requestURL.Redacted(), errMovedPermanentlyWithoutLocation)
			}
			if attempt == len(retryCaps) {
				return nil, fmt.Errorf("request %q: exhausted attempts following redirect to %q", requestURL.Redacted(), location)
			}

			redirectURL, err := requestURL.Parse(location)
			if err != nil {
				return nil, fmt.Errorf("resolve redirect location %q for request %q: %w", location, requestURL.Redacted(), err)
			}
			requestURL = redirectURL
			continue
		}
		if !isConnectTimeout(err) {
			return nil, err
		}
		lastErr = err
	}

	return nil, lastErr
}
