package clients

import (
	"bytes"
	"context"
	"crypto/md5"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

const (
	topazDefaultPollInterval = 15 * time.Second
	topazDefaultPollTimeout  = 60 * time.Minute
	topazDefaultMaxInput     = int64(2 << 30)
)

type TopazClient struct {
	HTTPClient *http.Client
	Probe      func(context.Context, string) (TopazVideoMetadata, error)
}

type TopazVideoMetadata struct {
	Width      int
	Height     int
	Duration   float64
	FrameRate  float64
	FrameCount int
	HasAudio   bool
}

type topazSource struct {
	Path       string
	Container  string
	Size       int64
	MD5        string
	Resolution map[string]any
	Duration   float64
	FrameRate  float64
	FrameCount int
}

func (c TopazClient) Run(ctx context.Context, request Request) (Response, error) {
	startedAt := time.Now()
	apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
	if apiKey == "" {
		return Response{}, &ClientError{Code: "missing_credentials", Message: "Topaz API key is required", Retryable: false}
	}
	requestID := strings.TrimSpace(request.RemoteTaskID)
	payload := cloneMapAny(request.RemoteTaskPayload)
	var source *topazSource
	var target map[string]int
	if requestID == "" || strings.TrimSpace(firstNonEmptyString(payload["phase"])) != "uploaded" {
		videoURL := strings.TrimSpace(firstNonEmptyString(request.Body["video_url"], request.Body["videoUrl"]))
		if videoURL == "" {
			return Response{}, &ClientError{Code: "invalid_parameter", Message: "video_url is required", Param: "video_url", StatusCode: http.StatusBadRequest}
		}
		prepared, err := c.prepareSource(ctx, request, videoURL)
		if err != nil {
			return Response{}, err
		}
		source = &prepared
		defer os.Remove(prepared.Path)
		target = topazTargetResolution(request.Body, prepared.Resolution)
		if requestID == "" {
			created, err := c.createRequest(ctx, request, apiKey, prepared, target)
			if err != nil {
				return Response{}, annotateResponseError(err, "", startedAt, time.Now())
			}
			requestID = strings.TrimSpace(firstNonEmptyString(created["requestId"], created["request_id"], created["id"]))
			if requestID == "" {
				return Response{}, &ClientError{Code: "invalid_response", Message: "Topaz create response is missing requestId", Retryable: false}
			}
			payload = map[string]any{"phase": "created", "targetResolution": target}
			if request.OnRemoteTaskSubmitted != nil {
				if err := request.OnRemoteTaskSubmitted(requestID, payload); err != nil {
					return Response{}, err
				}
			}
		}
		if err := c.uploadSource(ctx, request, apiKey, requestID, prepared); err != nil {
			return Response{}, annotateResponseError(err, requestID, startedAt, time.Now())
		}
		payload = map[string]any{"phase": "uploaded", "targetResolution": target}
		if request.OnRemoteTaskPolled != nil {
			if err := request.OnRemoteTaskPolled(requestID, payload); err != nil {
				return Response{}, err
			}
		}
	}
	if target == nil {
		target = topazTargetFromPayload(payload, request.Body)
	}
	completed, err := c.poll(ctx, request, apiKey, requestID, target)
	if err != nil {
		return Response{}, annotateResponseError(err, requestID, startedAt, time.Now())
	}
	outputURL := topazDownloadURL(completed)
	if outputURL == "" {
		return Response{}, &ClientError{Code: "invalid_response", Message: "Topaz completed without a download URL", RequestID: requestID, Retryable: false}
	}
	metadata, err := c.probe(ctx, outputURL)
	if err != nil {
		return Response{}, &ClientError{Code: "invalid_response", Message: "cannot validate Topaz output metadata: " + err.Error(), RequestID: requestID, Retryable: true}
	}
	if target["width"] > 0 && target["height"] > 0 && (metadata.Width+4 < target["width"] || metadata.Height+4 < target["height"]) {
		return Response{}, &ClientError{Code: "invalid_response", Message: fmt.Sprintf("Topaz output resolution %dx%d does not reach target %dx%d", metadata.Width, metadata.Height, target["width"], target["height"]), RequestID: requestID, Retryable: false}
	}
	finishedAt := time.Now()
	resultItem := map[string]any{
		"type":              "video",
		"url":               outputURL,
		"video_url":         outputURL,
		"width":             metadata.Width,
		"height":            metadata.Height,
		"target_resolution": fmt.Sprintf("%dx%d", target["width"], target["height"]),
		"duration":          metadata.Duration,
		"target_frame_rate": metadata.FrameRate,
		"slow_motion_rate":  numericValue(request.Body["slow_motion_rate"], 1),
	}
	if source != nil {
		resultItem["source_resolution"] = fmt.Sprintf("%vx%v", source.Resolution["width"], source.Resolution["height"])
		resultItem["source_frame_rate"] = source.FrameRate
		resultItem["duration"] = source.Duration
	}
	return Response{
		Result: map[string]any{
			"status":           "success",
			"model":            request.Model,
			"task_id":          requestID,
			"upstream_task_id": requestID,
			"data":             []any{resultItem},
		},
		RequestID:          requestID,
		Progress:           append(providerProgress(request), Progress{Phase: "polling", Progress: 0.9, Message: "Topaz upscale completed", Payload: map[string]any{"upstreamTaskId": requestID}}),
		ResponseStartedAt:  startedAt,
		ResponseFinishedAt: finishedAt,
		ResponseDurationMS: responseDurationMS(startedAt, finishedAt),
	}, nil
}

func (c TopazClient) prepareSource(ctx context.Context, request Request, rawURL string) (topazSource, error) {
	parsed, err := url.Parse(rawURL)
	if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
		return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video_url must be an http(s) URL", Param: "video_url", StatusCode: http.StatusBadRequest}
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
	if err != nil {
		return topazSource{}, err
	}
	downloadClient := topazSourceHTTPClient(httpClient(request.HTTPClient, c.HTTPClient), boolishDefault(request.Candidate.PlatformConfig["allowPrivateSourceDownloads"], false))
	resp, err := downloadClient.Do(req)
	if err != nil {
		return topazSource{}, transportClientError(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video_url download failed: " + resp.Status, Param: "video_url", StatusCode: http.StatusBadRequest}
	}
	limit := int64(numericValue(firstPresent(request.Candidate.PlatformConfig["maxInputBytes"], request.Candidate.PlatformConfig["max_input_bytes"]), float64(topazDefaultMaxInput)))
	if limit <= 0 {
		limit = topazDefaultMaxInput
	}
	tmp, err := os.CreateTemp("", "easyai-topaz-*"+topazContainerExtension(parsed.Path))
	if err != nil {
		return topazSource{}, err
	}
	path := tmp.Name()
	cleanup := func() {
		_ = tmp.Close()
		_ = os.Remove(path)
	}
	hash := md5.New()
	written, err := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(resp.Body, limit+1))
	closeErr := tmp.Close()
	if err != nil || closeErr != nil || written <= 0 || written > limit {
		cleanup()
		if written > limit {
			return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video source exceeds configured size limit", Param: "video_url", StatusCode: http.StatusBadRequest}
		}
		return topazSource{}, firstError(err, closeErr)
	}
	metadata, err := c.probe(ctx, path)
	if err != nil || metadata.Width <= 0 || metadata.Height <= 0 {
		cleanup()
		return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "cannot probe source video metadata", Param: "video_url", StatusCode: http.StatusBadRequest}
	}
	frameRate := metadata.FrameRate
	if frameRate <= 0 {
		frameRate = numericValue(request.Candidate.PlatformConfig["frameRate"], 30)
	}
	duration := math.Max(1, metadata.Duration)
	frameCount := metadata.FrameCount
	if frameCount <= 0 {
		frameCount = int(math.Round(duration * frameRate))
	}
	return topazSource{
		Path: path, Container: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."), Size: written,
		MD5: hex.EncodeToString(hash.Sum(nil)), Resolution: map[string]any{"width": metadata.Width, "height": metadata.Height},
		Duration: duration, FrameRate: frameRate, FrameCount: frameCount,
	}, nil
}

func (c TopazClient) createRequest(ctx context.Context, request Request, apiKey string, source topazSource, target map[string]int) (map[string]any, error) {
	model := topazModelName(firstNonEmptyString(request.Candidate.ProviderModelName, request.Model))
	scale := math.Max(float64(target["width"])/numericValue(source.Resolution["width"], 1), float64(target["height"])/numericValue(source.Resolution["height"], 1))
	filter := map[string]any{"model": model}
	if scale > 1 {
		key := "scale"
		if model == "slf-2" || model == "slhq-1" || model == "slm-1" || model == "slp-2.5" {
			key = "upscaling_factor"
		}
		filter[key] = math.Round(scale*1000) / 1000
	}
	preserveAudio := boolishDefault(request.Body["preserve_audio"], true)
	filters := []any{filter}
	targetFrameRate := numericValue(firstPresent(request.Body["target_frame_rate"], request.Body["output_frame_rate"]), source.FrameRate)
	slowMotionRate := numericValue(request.Body["slow_motion_rate"], 1)
	if targetFrameRate <= 0 {
		targetFrameRate = source.FrameRate
	}
	if slowMotionRate <= 0 {
		slowMotionRate = 1
	}
	if targetFrameRate != source.FrameRate || slowMotionRate > 1 {
		interpolationModel := strings.TrimSpace(firstNonEmptyString(request.Body["frame_interpolation_model"]))
		if interpolationModel == "" {
			return nil, &ClientError{Code: "invalid_parameter", Message: "frame_interpolation_model is required when output frame rate or slow motion changes", Param: "frame_interpolation_model", StatusCode: http.StatusBadRequest}
		}
		interpolation := map[string]any{"model": topazModelName(interpolationModel)}
		if targetFrameRate > 0 {
			interpolation["fps"] = targetFrameRate
		}
		if slowMotionRate > 1 {
			interpolation["slowmo"] = slowMotionRate
		}
		filters = append(filters, interpolation)
	}
	body := map[string]any{
		"source":  map[string]any{"container": source.Container, "size": source.Size, "duration": source.Duration, "frameCount": source.FrameCount, "frameRate": source.FrameRate, "resolution": source.Resolution},
		"filters": filters,
		"output":  map[string]any{"resolution": target, "frameRate": targetFrameRate, "audioCodec": "AAC", "audioTransfer": map[bool]string{true: "Copy", false: "None"}[preserveAudio], "dynamicCompressionLevel": "High", "videoEncoder": "H265", "videoProfile": "Main", "container": "mp4"},
	}
	return c.topazJSON(ctx, request, apiKey, http.MethodPost, "/video/", body)
}

func (c TopazClient) uploadSource(ctx context.Context, request Request, apiKey, requestID string, source topazSource) error {
	accepted, err := c.topazJSON(ctx, request, apiKey, http.MethodPatch, "/video/"+url.PathEscape(requestID)+"/accept", nil)
	if err != nil {
		return err
	}
	urls := stringList(accepted["urls"])
	if len(urls) == 0 {
		return &ClientError{Code: "invalid_response", Message: "Topaz accept response has no upload URLs", RequestID: requestID}
	}
	file, err := os.Open(source.Path)
	if err != nil {
		return err
	}
	defer file.Close()
	chunkSize := (source.Size + int64(len(urls)) - 1) / int64(len(urls))
	results := make([]any, 0, len(urls))
	for index, uploadURL := range urls {
		remaining := source.Size - int64(index)*chunkSize
		if remaining <= 0 {
			break
		}
		length := minInt64(chunkSize, remaining)
		section := io.NewSectionReader(file, int64(index)*chunkSize, length)
		req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, section)
		if err != nil {
			return err
		}
		req.ContentLength = length
		req.Header.Set("Content-Type", topazContainerContentType(source.Container))
		resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
		if err != nil {
			return transportClientError(err)
		}
		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
		resp.Body.Close()
		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
			return &ClientError{Code: "provider_failed", Message: "Topaz part upload failed: " + resp.Status, StatusCode: resp.StatusCode, Retryable: resp.StatusCode >= 500}
		}
		etag := strings.Trim(resp.Header.Get("ETag"), `"`)
		if etag == "" {
			return &ClientError{Code: "invalid_response", Message: "Topaz part upload is missing ETag", Retryable: false}
		}
		results = append(results, map[string]any{"partNum": index + 1, "eTag": etag})
	}
	_, err = c.topazJSON(ctx, request, apiKey, http.MethodPatch, "/video/"+url.PathEscape(requestID)+"/complete-upload/", map[string]any{"md5Hash": source.MD5, "uploadResults": results})
	return err
}

func (c TopazClient) poll(ctx context.Context, request Request, apiKey, requestID string, target map[string]int) (map[string]any, error) {
	interval := universalDurationConfig(request.Candidate.PlatformConfig, topazDefaultPollInterval, "pollIntervalMs", "poll_interval_ms")
	timeout := universalDurationConfig(request.Candidate.PlatformConfig, topazDefaultPollTimeout, "pollTimeoutMs", "poll_timeout_ms", "timeoutMs")
	deadline := time.NewTimer(timeout)
	defer deadline.Stop()
	for {
		status, err := c.topazJSON(ctx, request, apiKey, http.MethodGet, "/video/"+url.PathEscape(requestID)+"/status", nil)
		if err != nil {
			return nil, err
		}
		state := strings.ToLower(strings.TrimSpace(firstNonEmptyString(status["status"], status["state"])))
		if request.OnRemoteTaskPolled != nil {
			if err := request.OnRemoteTaskPolled(requestID, map[string]any{"phase": "uploaded", "status": state, "targetResolution": target}); err != nil {
				return nil, err
			}
		}
		switch state {
		case "complete", "completed", "success", "succeeded":
			return status, nil
		case "failed", "failure", "cancelled", "canceled", "error":
			return nil, &ClientError{Code: "provider_failed", Message: firstNonEmptyString(status["message"], "Topaz task failed"), RequestID: requestID, Retryable: false}
		}
		select {
		case <-ctx.Done():
			return nil, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
		case <-deadline.C:
			return nil, &ClientError{Code: "timeout", Message: "Topaz task polling timed out", RequestID: requestID, Retryable: true}
		case <-time.After(interval):
		}
	}
}

func (c TopazClient) topazJSON(ctx context.Context, request Request, apiKey, method, path string, body map[string]any) (map[string]any, error) {
	var reader io.Reader
	if body != nil {
		raw, _ := json.Marshal(body)
		reader = bytes.NewReader(raw)
	}
	req, err := http.NewRequestWithContext(ctx, method, providerURL(request.Candidate.BaseURL, path), reader)
	if err != nil {
		return nil, err
	}
	req.Header.Set("X-API-Key", apiKey)
	req.Header.Set("Accept", "application/json")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
	if err != nil {
		return nil, transportClientError(err)
	}
	result, decodeErr := decodeHTTPResponse(resp)
	if decodeErr != nil {
		return nil, decodeErr
	}
	return result, nil
}

func (c TopazClient) probe(ctx context.Context, target string) (TopazVideoMetadata, error) {
	if c.Probe != nil {
		return c.Probe(ctx, target)
	}
	return probeTopazVideo(ctx, target)
}

func probeTopazVideo(ctx context.Context, target string) (TopazVideoMetadata, error) {
	if _, err := exec.LookPath("ffprobe"); err != nil {
		return TopazVideoMetadata{}, err
	}
	probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	output, err := exec.CommandContext(probeCtx, "ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height,avg_frame_rate,nb_frames", "-of", "json", target).Output()
	if err != nil {
		return TopazVideoMetadata{}, err
	}
	var decoded struct {
		Format struct {
			Duration string `json:"duration"`
		} `json:"format"`
		Streams []struct {
			CodecType   string `json:"codec_type"`
			Width       int    `json:"width"`
			Height      int    `json:"height"`
			AverageRate string `json:"avg_frame_rate"`
			FrameCount  string `json:"nb_frames"`
		} `json:"streams"`
	}
	if err := json.Unmarshal(output, &decoded); err != nil {
		return TopazVideoMetadata{}, err
	}
	metadata := TopazVideoMetadata{}
	metadata.Duration, _ = strconv.ParseFloat(decoded.Format.Duration, 64)
	for _, stream := range decoded.Streams {
		if stream.CodecType == "audio" {
			metadata.HasAudio = true
		}
		if stream.CodecType != "video" || stream.Width <= 0 || stream.Height <= 0 {
			continue
		}
		metadata.Width, metadata.Height = stream.Width, stream.Height
		metadata.FrameRate = parseTopazRate(stream.AverageRate)
		metadata.FrameCount, _ = strconv.Atoi(stream.FrameCount)
	}
	if metadata.Width <= 0 || metadata.Height <= 0 {
		return TopazVideoMetadata{}, errors.New("video stream metadata is missing")
	}
	return metadata, nil
}

func topazTargetResolution(body map[string]any, source map[string]any) map[string]int {
	width := int(numericValue(body["output_width"], 0))
	height := int(numericValue(body["output_height"], 0))
	if width > 0 && height > 0 {
		return map[string]int{"width": width, "height": height}
	}
	value := strings.ToLower(strings.TrimSpace(firstNonEmptyString(body["target_resolution"], body["output_resolution"], "1080p")))
	if parsedWidth, parsedHeight, ok := parseTopazSize(value); ok {
		return map[string]int{"width": parsedWidth, "height": parsedHeight}
	}
	base := map[string][2]int{"480p": {854, 480}, "720p": {1280, 720}, "1080p": {1920, 1080}, "1440p": {2560, 1440}, "2k": {2560, 1440}, "2160p": {3840, 2160}, "4k": {3840, 2160}}
	value = strings.TrimSuffix(value, "_upscale")
	target, ok := base[value]
	if !ok {
		target = base["1080p"]
	}
	sourceWidth := numericValue(source["width"], 1)
	sourceHeight := numericValue(source["height"], 1)
	if sourceWidth == sourceHeight {
		side := minInt(target[0], target[1])
		return map[string]int{"width": side, "height": side}
	}
	if sourceWidth > sourceHeight {
		return map[string]int{"width": int(math.Round(sourceWidth / sourceHeight * float64(target[1]))), "height": target[1]}
	}
	return map[string]int{"width": target[1], "height": int(math.Round(sourceHeight / sourceWidth * float64(target[1])))}
}

func topazTargetFromPayload(payload map[string]any, body map[string]any) map[string]int {
	if raw, ok := payload["targetResolution"].(map[string]any); ok {
		return map[string]int{"width": int(numericValue(raw["width"], 0)), "height": int(numericValue(raw["height"], 0))}
	}
	return topazTargetResolution(body, map[string]any{"width": 16, "height": 9})
}

func topazDownloadURL(result map[string]any) string {
	if download, ok := result["download"].(map[string]any); ok {
		return firstNonEmptyString(download["url"], download["downloadUrl"])
	}
	return firstNonEmptyString(result["download_url"], result["downloadUrl"], result["url"])
}

func topazModelName(model string) string {
	switch strings.TrimSpace(model) {
	case "easy-proteus-standard-4":
		return "prob-4"
	case "easy-starlight-fast-2":
		return "slf-2"
	case "easy-starlight-hq-1":
		return "slhq-1"
	case "easy-starlight-mini-1":
		return "slm-1"
	default:
		return strings.TrimSpace(model)
	}
}

func topazContainerExtension(path string) string {
	ext := strings.ToLower(filepath.Ext(strings.Split(path, "?")[0]))
	switch ext {
	case ".mov", ".mkv", ".webm", ".mp4":
		return ext
	default:
		return ".mp4"
	}
}

func topazContainerContentType(container string) string {
	switch strings.ToLower(container) {
	case "mov":
		return "video/quicktime"
	case "mkv":
		return "video/x-matroska"
	case "webm":
		return "video/webm"
	default:
		return "video/mp4"
	}
}

func parseTopazRate(value string) float64 {
	parts := strings.Split(value, "/")
	if len(parts) == 2 {
		numerator, _ := strconv.ParseFloat(parts[0], 64)
		denominator, _ := strconv.ParseFloat(parts[1], 64)
		if denominator > 0 {
			return numerator / denominator
		}
	}
	parsed, _ := strconv.ParseFloat(value, 64)
	return parsed
}

func parseTopazSize(value string) (int, int, bool) {
	parts := strings.Split(strings.ReplaceAll(value, " ", ""), "x")
	if len(parts) != 2 {
		return 0, 0, false
	}
	width, errWidth := strconv.Atoi(parts[0])
	height, errHeight := strconv.Atoi(parts[1])
	return width, height, errWidth == nil && errHeight == nil && width > 0 && height > 0
}

func stringList(value any) []string {
	items, ok := value.([]any)
	if !ok {
		if values, ok := value.([]string); ok {
			return values
		}
		return nil
	}
	out := make([]string, 0, len(items))
	for _, item := range items {
		if text := strings.TrimSpace(fmt.Sprint(item)); text != "" {
			out = append(out, text)
		}
	}
	return out
}

func boolishDefault(value any, fallback bool) bool {
	if value == nil {
		return fallback
	}
	switch typed := value.(type) {
	case bool:
		return typed
	case string:
		parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
		if err == nil {
			return parsed
		}
	}
	return fallback
}

func firstError(values ...error) error {
	for _, value := range values {
		if value != nil {
			return value
		}
	}
	return nil
}

func minInt64(left, right int64) int64 {
	if left < right {
		return left
	}
	return right
}

func minInt(left, right int) int {
	if left < right {
		return left
	}
	return right
}

func topazSourceHTTPClient(base *http.Client, allowPrivate bool) *http.Client {
	client := *base
	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
	if client.Timeout <= 0 {
		client.Timeout = 10 * time.Minute
	}
	if allowPrivate {
		return &client
	}
	transport := http.DefaultTransport.(*http.Transport).Clone()
	transport.Proxy = nil
	transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
		host, port, err := net.SplitHostPort(address)
		if err != nil {
			return nil, err
		}
		addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
		if err != nil || len(addresses) == 0 {
			return nil, errors.New("video source DNS resolution failed")
		}
		for _, address := range addresses {
			if topazBlockedAddress(address.IP) {
				return nil, errors.New("video source resolved to a blocked network")
			}
		}
		dialer := &net.Dialer{Timeout: 10 * time.Second}
		var attempts []error
		for _, resolved := range addresses {
			connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
			if dialErr == nil {
				return connection, nil
			}
			attempts = append(attempts, dialErr)
		}
		return nil, errors.Join(attempts...)
	}
	client.Transport = transport
	return &client
}

func topazBlockedAddress(ip net.IP) bool {
	return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
