package publish

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

	"apps/backend/internal/module/studio/domain"

	"github.com/zeromicro/go-zero/core/logx"
)

// MetaTransport — 真 Threads 發文／回覆（container → publish）。
// 支援 TEXT / 單圖 IMAGE / 多圖 CAROUSEL（image_url 須為 Meta 可抓的公開 https）。
type MetaTransport struct {
	GraphBase  string
	HTTPClient *http.Client
}

func NewMeta(graphBase string) *MetaTransport {
	if graphBase == "" {
		graphBase = "https://graph.threads.net"
	}
	transport := http.DefaultTransport.(*http.Transport).Clone()
	transport.DialContext = dialPublicAddress
	client := &http.Client{Timeout: 90 * time.Second, Transport: transport}
	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		if len(via) >= 5 {
			return fmt.Errorf("too many redirects")
		}
		return validatePublicHTTPURL(req.Context(), req.URL)
	}
	return &MetaTransport{
		GraphBase:  strings.TrimRight(graphBase, "/"),
		HTTPClient: client,
	}
}

func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest) (*domain.PublishResult, error) {
	token := strings.TrimSpace(req.AccessToken)
	if token == "" || strings.HasPrefix(token, "fake-") {
		return nil, fmt.Errorf("Threads token 無效，請重新連帳")
	}
	text := strings.TrimSpace(req.Text)
	images := publicImageURLs(req.ImageURLs)
	if text == "" && len(images) == 0 {
		return nil, fmt.Errorf("內容不可為空")
	}
	// Meta 會從公網 cURL image_url；先 HEAD 自檢，避免只得到 code 1 unknown
	for i, img := range images {
		if err := t.preflightImageURL(ctx, img); err != nil {
			return nil, fmt.Errorf("第 %d 張圖 Meta 無法抓取：%w", i+1, err)
		}
	}

	createURL := t.GraphBase + "/v1.0/me/threads"
	replyTo := strings.TrimSpace(req.ReplyTo)
	topicTag := ""
	if replyTo == "" {
		topicTag = normalizeTopicTag(req.TopicTag)
	}

	var containerID string
	var wait time.Duration
	var err error

	switch {
	case len(images) == 0:
		// 純文字
		form := url.Values{}
		form.Set("media_type", "TEXT")
		form.Set("text", text)
		form.Set("access_token", token)
		if replyTo != "" {
			form.Set("reply_to_id", replyTo)
		}
		if topicTag != "" {
			form.Set("topic_tag", topicTag)
		}
		setReplyControl(form, req)
		containerID, err = t.createContainer(ctx, createURL, form)
		wait = 8 * time.Second
	case len(images) == 1:
		// 單圖
		form := url.Values{}
		form.Set("media_type", "IMAGE")
		form.Set("image_url", images[0])
		form.Set("access_token", token)
		if text != "" {
			form.Set("text", text)
		}
		if replyTo != "" {
			form.Set("reply_to_id", replyTo)
		}
		if topicTag != "" {
			form.Set("topic_tag", topicTag)
		}
		setReplyControl(form, req)
		containerID, err = t.createContainer(ctx, createURL, form)
		wait = 30 * time.Second
	default:
		// 多圖 carousel：先建 is_carousel_item 子容器，再建 CAROUSEL
		childIDs := make([]string, 0, len(images))
		for i, img := range images {
			// Threads carousel 最多 20，這裡保守 10
			if i >= 10 {
				break
			}
			cf := url.Values{}
			cf.Set("media_type", "IMAGE")
			cf.Set("image_url", img)
			cf.Set("is_carousel_item", "true")
			cf.Set("access_token", token)
			cid, cerr := t.createContainer(ctx, createURL, cf)
			if cerr != nil {
				return nil, fmt.Errorf("Threads 建立 carousel 子圖失敗：%w", cerr)
			}
			// 子項需 FINISHED 才可組 carousel
			if werr := t.waitContainerReady(ctx, token, cid, 30*time.Second); werr != nil {
				return nil, fmt.Errorf("Threads 子圖未就緒：%w", werr)
			}
			childIDs = append(childIDs, cid)
		}
		form := url.Values{}
		form.Set("media_type", "CAROUSEL")
		form.Set("children", strings.Join(childIDs, ","))
		form.Set("access_token", token)
		if text != "" {
			form.Set("text", text)
		}
		if replyTo != "" {
			form.Set("reply_to_id", replyTo)
		}
		if topicTag != "" {
			form.Set("topic_tag", topicTag)
		}
		setReplyControl(form, req)
		containerID, err = t.createContainer(ctx, createURL, form)
		wait = 30 * time.Second
	}
	if err != nil {
		return nil, err
	}

	if werr := t.waitContainerReady(ctx, token, containerID, wait); werr != nil {
		// 超時仍嘗試 publish；硬錯誤則直接失敗
		if wait > 0 && strings.Contains(werr.Error(), "container") {
			// ERROR/EXPIRED 會回 error message
			if !strings.Contains(werr.Error(), "timeout") {
				return nil, fmt.Errorf("Threads 媒體處理失敗：%w", werr)
			}
		}
	}

	// publish
	pForm := url.Values{}
	pForm.Set("creation_id", containerID)
	pForm.Set("access_token", token)
	pubURL := t.GraphBase + "/v1.0/me/threads_publish"
	pBody, pStatus, err := t.postForm(ctx, pubURL, pForm)
	if err != nil {
		return nil, fmt.Errorf("Threads 發佈失敗：%w", err)
	}
	if pStatus >= 300 {
		return nil, fmt.Errorf("Threads 發佈失敗：%s", parseGraphError(pBody, pStatus))
	}
	var published struct {
		ID string `json:"id"`
	}
	if err := json.Unmarshal(pBody, &published); err != nil || published.ID == "" {
		return nil, fmt.Errorf("Threads 發佈失敗：無法解析 media id（%s）", truncate(string(pBody), 180))
	}
	logx.Infof("threads publish ok media_id=%s images=%d reply_to=%s", published.ID, len(images), req.ReplyTo)
	return &domain.PublishResult{MediaID: published.ID}, nil
}

func publicImageURLs(raw []string) []string {
	out := make([]string, 0, len(raw))
	seen := make(map[string]struct{}, len(raw))
	for _, u := range raw {
		u = strings.TrimSpace(u)
		if u == "" {
			continue
		}
		// data URL 無法給 Meta 抓，跳過（前端應先上傳）
		if strings.HasPrefix(u, "data:") {
			continue
		}
		if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
			if _, ok := seen[u]; ok {
				continue
			}
			seen[u] = struct{}{}
			out = append(out, u)
		}
	}
	return out
}

// preflightImageURL — 確認公開 URL 對匿名 GET 可讀（Meta crawler 同樣匿名）
func (t *MetaTransport) preflightImageURL(ctx context.Context, imageURL string) error {
	parsed, err := url.Parse(imageURL)
	if err != nil {
		return err
	}
	if err := validatePublicHTTPURL(ctx, parsed); err != nil {
		return err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodHead, parsed.String(), nil)
	if err != nil {
		return err
	}
	// 部分 CDN 對 HEAD 不友善，失敗再 GET 前幾個 byte
	res, err := t.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("無法連線 %s：%w", truncate(imageURL, 80), err)
	}
	_ = res.Body.Close()
	if res.StatusCode == http.StatusMethodNotAllowed || res.StatusCode == http.StatusForbidden || res.StatusCode >= 400 {
		// fallback GET
		gReq, gerr := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
		if gerr != nil {
			return gerr
		}
		gReq.Header.Set("Range", "bytes=0-1023")
		gRes, gerr := t.HTTPClient.Do(gReq)
		if gerr != nil {
			return fmt.Errorf("無法連線 %s：%w", truncate(imageURL, 80), gerr)
		}
		_, _ = io.Copy(io.Discard, io.LimitReader(gRes.Body, 2048))
		_ = gRes.Body.Close()
		if gRes.StatusCode >= 300 {
			return fmt.Errorf("HTTP %d（請確認 MinIO/nginx 允許公開讀取 image_url）", gRes.StatusCode)
		}
		return nil
	}
	if res.StatusCode >= 300 {
		return fmt.Errorf("HTTP %d（請確認物件儲存允許公開讀取）", res.StatusCode)
	}
	return nil
}

func validatePublicHTTPURL(ctx context.Context, u *url.URL) error {
	if u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
		return fmt.Errorf("invalid public HTTP URL")
	}
	if u.User != nil {
		return fmt.Errorf("URL credentials are not allowed")
	}
	addrs, err := net.DefaultResolver.LookupIPAddr(ctx, u.Hostname())
	if err != nil {
		return fmt.Errorf("cannot resolve image host: %w", err)
	}
	if len(addrs) == 0 {
		return fmt.Errorf("image host has no address")
	}
	for _, addr := range addrs {
		if !isPublicIP(addr.IP) {
			return fmt.Errorf("image URL resolves to a non-public address")
		}
	}
	return nil
}

func dialPublicAddress(ctx context.Context, network, address string) (net.Conn, error) {
	host, port, err := net.SplitHostPort(address)
	if err != nil {
		return nil, err
	}
	addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
	if err != nil {
		return nil, err
	}
	for _, addr := range addrs {
		if !isPublicIP(addr.IP) {
			return nil, fmt.Errorf("refusing to connect to a non-public address")
		}
	}
	if len(addrs) == 0 {
		return nil, fmt.Errorf("host has no address")
	}
	dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
	return dialer.DialContext(ctx, network, net.JoinHostPort(addrs[0].IP.String(), port))
}

func isPublicIP(ip net.IP) bool {
	return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
}

func (t *MetaTransport) createContainer(ctx context.Context, createURL string, form url.Values) (string, error) {
	cBody, cStatus, err := t.postForm(ctx, createURL, form)
	if err != nil {
		return "", fmt.Errorf("Threads 建立容器失敗：%w", err)
	}
	if cStatus >= 300 {
		return "", fmt.Errorf("Threads 建立容器失敗：%s", parseGraphError(cBody, cStatus))
	}
	var created struct {
		ID string `json:"id"`
	}
	if err := json.Unmarshal(cBody, &created); err != nil || created.ID == "" {
		return "", fmt.Errorf("Threads 建立容器失敗：無法解析 container id（%s）", truncate(string(cBody), 180))
	}
	return created.ID, nil
}

// normalizeTopicTag — Threads API：1～50 字，不可含 . &
func normalizeTopicTag(raw string) string {
	s := strings.TrimSpace(raw)
	s = strings.TrimPrefix(s, "#")
	s = strings.TrimSpace(s)
	if s == "" {
		return ""
	}
	s = strings.ReplaceAll(s, ".", "")
	s = strings.ReplaceAll(s, "&", "")
	s = strings.TrimSpace(s)
	r := []rune(s)
	if len(r) > 50 {
		s = string(r[:50])
	}
	if s == "" {
		return ""
	}
	return s
}

func setReplyControl(form url.Values, req domain.PublishRequest) {
	if strings.TrimSpace(req.ReplyTo) != "" {
		return
	}
	c := strings.ToLower(strings.TrimSpace(req.ReplyControl))
	switch c {
	case "everyone", "accounts_you_follow", "mentioned_only", "parent_post_author_only", "followers_only":
		form.Set("reply_control", c)
	}
}

func (t *MetaTransport) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, int, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode()))
	if err != nil {
		return nil, 0, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	res, err := t.HTTPClient.Do(req)
	if err != nil {
		return nil, 0, err
	}
	defer res.Body.Close()
	body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
	return body, res.StatusCode, nil
}

func (t *MetaTransport) waitContainerReady(ctx context.Context, token, containerID string, maxWait time.Duration) error {
	deadline := time.Now().Add(maxWait)
	for time.Now().Before(deadline) {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
		q := url.Values{}
		q.Set("fields", "status,error_message")
		q.Set("access_token", token)
		u := t.GraphBase + "/v1.0/" + url.PathEscape(containerID) + "?" + q.Encode()
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
		if err != nil {
			return err
		}
		res, err := t.HTTPClient.Do(req)
		if err != nil {
			time.Sleep(800 * time.Millisecond)
			continue
		}
		body, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
		_ = res.Body.Close()
		var st struct {
			Status       string `json:"status"`
			ErrorMessage string `json:"error_message"`
		}
		_ = json.Unmarshal(body, &st)
		switch strings.ToUpper(st.Status) {
		case "FINISHED", "PUBLISHED":
			return nil
		case "ERROR":
			if st.ErrorMessage != "" {
				return fmt.Errorf("%s", st.ErrorMessage)
			}
			return fmt.Errorf("container error")
		case "EXPIRED":
			return fmt.Errorf("container expired")
		}
		time.Sleep(800 * time.Millisecond)
	}
	return fmt.Errorf("container wait timeout")
}

func parseGraphError(body []byte, httpStatus int) string {
	var ge struct {
		Error *struct {
			Message string `json:"message"`
			Type    string `json:"type"`
			Code    int    `json:"code"`
		} `json:"error"`
	}
	if err := json.Unmarshal(body, &ge); err == nil && ge.Error != nil && ge.Error.Message != "" {
		return fmt.Sprintf("%s (code %d)", ge.Error.Message, ge.Error.Code)
	}
	return fmt.Sprintf("HTTP %d %s", httpStatus, truncate(string(body), 160))
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "…"
}

var _ Transport = (*MetaTransport)(nil)
