package activityhub_client

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/trace"
)

const (
	activityHubSourceHeader = "x-activityhub-source"
	activityHubKeyHeader    = "x-activityhub-key"
	activityHubCursorHeader = "x-activityhub-cursor"
	maxResponseBodyBytes    = 32 << 20
	maxDiagnosticIDRunes    = 256
	defaultSearchTimeout    = 10 * time.Second
)

// ErrUnavailable indicates that the ActivityHub client cannot issue requests
// because its local configuration or HTTP transport is unavailable.
var ErrUnavailable = errors.New("activityhub client unavailable")

// SearchParams describes one ActivityHub time-range search.
type SearchParams struct {
	BeginTS  int64
	EndTS    int64
	Catalog  string
	Type     string
	Name     string
	Severity int
	Limit    int
	Cursor   string
}

// ActivitiesResponse is the stable response used by Clinic's internal Data
// Proxy activity API.
type ActivitiesResponse struct {
	Activities []*Activity `json:"activities"`
	Partial    bool        `json:"partial"`
	Cursor     string      `json:"cursor"`
}

// UpstreamError reports safe diagnostic metadata from an ActivityHub failure
// without retaining its body or any configured service credential. Error()
// deliberately excludes all upstream-provided text so incidental error logging
// cannot expose it.
type UpstreamError struct {
	StatusCode    int
	ProtocolError bool
	RequestID     string
	AWSRequestID  string
	APIGatewayID  string
}

func (e *UpstreamError) Error() string {
	if e.ProtocolError {
		return "activityhub upstream returned an error envelope"
	}
	return fmt.Sprintf("activityhub upstream returned HTTP status %d", e.StatusCode)
}

// SearchActivities is the canonical ActivityHub search implementation. HTTP
// callers own parameter validation; this method owns the upstream protocol.
func (c *ActivityHubClient) SearchActivities(ctx context.Context, targetID string, params SearchParams) (ActivitiesResponse, error) {
	if c == nil || c.client == nil {
		return ActivitiesResponse{}, fmt.Errorf("%w: HTTP client is required", ErrUnavailable)
	}
	if targetID == "" {
		return ActivitiesResponse{}, fmt.Errorf("target ID is required")
	}
	if targetID == "." || targetID == ".." {
		return ActivitiesResponse{}, fmt.Errorf("target ID must not be a dot path segment")
	}
	if strings.TrimSpace(c.source) == "" || strings.TrimSpace(c.key) == "" {
		return ActivitiesResponse{}, fmt.Errorf("%w: service credentials are required", ErrUnavailable)
	}
	if containsHeaderControl(c.source) || containsHeaderControl(c.key) {
		return ActivitiesResponse{}, fmt.Errorf("%w: service credentials contain invalid characters", ErrUnavailable)
	}

	requestURL, err := c.buildSearchURL(targetID, params)
	if err != nil {
		return ActivitiesResponse{}, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
	if err != nil {
		return ActivitiesResponse{}, fmt.Errorf("create ActivityHub request: %w", err)
	}
	req.Header.Set(activityHubSourceHeader, c.source)
	req.Header.Set(activityHubKeyHeader, c.key)
	injectTraceHeaders(ctx, req.Header)
	if params.Cursor != "" {
		req.Header.Set(activityHubCursorHeader, params.Cursor)
	}

	timeout := c.client.Timeout
	if timeout == 0 {
		timeout = defaultSearchTimeout
	}
	httpClient := &http.Client{
		Transport: c.client.Transport,
		Timeout:   timeout,
		CheckRedirect: func(*http.Request, []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	resp, err := httpClient.Do(req)
	if err != nil {
		return ActivitiesResponse{}, fmt.Errorf("query ActivityHub: %w", err)
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		if resp.StatusCode == http.StatusNotFound {
			body, readErr := readSearchResponseBody(resp.Body)
			if readErr != nil {
				return ActivitiesResponse{}, readErr
			}
			upstream, decodeErr := decodeSearchResponse(body)
			if decodeErr == nil && upstream.Status == errorStatus && upstream.Message == notFoundError {
				return emptyActivitiesResponse(), nil
			}
		}
		return ActivitiesResponse{}, newUpstreamError(resp, false)
	}

	body, err := readSearchResponseBody(resp.Body)
	if err != nil {
		return ActivitiesResponse{}, err
	}
	upstream, err := decodeSearchResponse(body)
	if err != nil {
		return ActivitiesResponse{}, err
	}
	if upstream.Status == errorStatus {
		if upstream.Message == notFoundError {
			return emptyActivitiesResponse(), nil
		}
		return ActivitiesResponse{}, newUpstreamError(resp, true)
	}

	return ActivitiesResponse{
		Activities: toSearchActivities(upstream.Result),
		Partial:    upstream.Partial,
		Cursor:     upstream.Cursor,
	}, nil
}

func injectTraceHeaders(ctx context.Context, header http.Header) {
	otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(header))
	if spanContext := trace.SpanContextFromContext(ctx); spanContext.HasTraceID() {
		header.Set("X-Trace-ID", spanContext.TraceID().String())
	}
}

func newUpstreamError(resp *http.Response, protocolError bool) *UpstreamError {
	return &UpstreamError{
		StatusCode:    resp.StatusCode,
		ProtocolError: protocolError,
		RequestID:     normalizeDiagnosticID(resp.Header.Get("x-request-id")),
		AWSRequestID:  normalizeDiagnosticID(resp.Header.Get("x-amzn-requestid")),
		APIGatewayID:  normalizeDiagnosticID(resp.Header.Get("x-amz-apigw-id")),
	}
}

func normalizeDiagnosticID(value string) string {
	value = strings.Map(func(r rune) rune {
		if r < 0x20 || r == 0x7f {
			return ' '
		}
		return r
	}, value)
	value = strings.Join(strings.Fields(value), " ")
	runes := []rune(value)
	if len(runes) > maxDiagnosticIDRunes {
		value = string(runes[:maxDiagnosticIDRunes])
	}
	return value
}

func emptyActivitiesResponse() ActivitiesResponse {
	return ActivitiesResponse{Activities: make([]*Activity, 0)}
}

// toSearchActivities normalizes the internal/Data Proxy contract without
// changing the nil-slice behavior of Clinic's legacy Activity APIs.
func toSearchActivities(source []*activity) []*Activity {
	result := make([]*Activity, 0, len(source))
	for _, item := range source {
		if item == nil {
			continue
		}
		result = append(result, item.toActivity())
	}
	return result
}

func containsHeaderControl(value string) bool {
	for _, r := range value {
		if r < 0x20 || r == 0x7f {
			return true
		}
	}
	return false
}

func (c *ActivityHubClient) buildSearchURL(targetID string, params SearchParams) (*url.URL, error) {
	baseURL, err := url.Parse(c.addr)
	if err != nil {
		return nil, fmt.Errorf("%w: invalid base URL", ErrUnavailable)
	}
	if baseURL.Scheme != "http" && baseURL.Scheme != "https" {
		return nil, fmt.Errorf("%w: base URL must use http or https", ErrUnavailable)
	}
	if baseURL.Host == "" {
		return nil, fmt.Errorf("%w: base URL must be absolute", ErrUnavailable)
	}
	if baseURL.User != nil || baseURL.RawQuery != "" || baseURL.Fragment != "" {
		return nil, fmt.Errorf("%w: base URL contains unsupported components", ErrUnavailable)
	}

	segments := []string{
		"search",
		targetID,
		"from",
		strconv.FormatInt(params.BeginTS, 10),
		"to",
		strconv.FormatInt(params.EndTS, 10),
	}
	rawPath := strings.TrimRight(baseURL.EscapedPath(), "/")
	for _, segment := range segments {
		rawPath += "/" + url.PathEscape(segment)
	}
	path, err := url.PathUnescape(rawPath)
	if err != nil {
		return nil, fmt.Errorf("build ActivityHub request path: %w", err)
	}
	baseURL.Path = path
	baseURL.RawPath = rawPath

	query := make(url.Values, 6)
	query.Set("catalog", params.Catalog)
	query.Set("type", params.Type)
	query.Set("name", params.Name)
	query.Set("severity", strconv.Itoa(params.Severity))
	query.Set("limit", strconv.Itoa(params.Limit))
	query.Set("desc", "true")
	baseURL.RawQuery = query.Encode()
	return baseURL, nil
}

func readSearchResponseBody(body io.Reader) ([]byte, error) {
	content, err := io.ReadAll(io.LimitReader(body, maxResponseBodyBytes+1))
	if err != nil {
		return nil, fmt.Errorf("read ActivityHub response: %w", err)
	}
	if len(content) > maxResponseBodyBytes {
		return nil, fmt.Errorf("activityhub response exceeds size limit")
	}
	return content, nil
}

func decodeSearchResponse(body []byte) (getActivitiesResp, error) {
	var upstream getActivitiesResp
	decoder := json.NewDecoder(bytes.NewReader(body))
	decoder.UseNumber()
	if err := decoder.Decode(&upstream); err != nil {
		return getActivitiesResp{}, fmt.Errorf("decode ActivityHub response: %w", err)
	}
	var trailing any
	if err := decoder.Decode(&trailing); err != io.EOF {
		return getActivitiesResp{}, fmt.Errorf("decode ActivityHub response: trailing JSON data")
	}
	return upstream, nil
}
