package handler

import (
	"context"
	"errors"
	"io"
	"net"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"sync"
	"time"

	"github.com/flowy-live/llink/internal/utils/flog"
)

// LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`.
// All clients (web and Electron) call this endpoint — the Electron main-process
// fetcher was retired so both clients share one parser and the server-side cache.
type LinkMetadata struct {
	URL         string  `json:"url"`
	Title       *string `json:"title"`
	Description *string `json:"description"`
	Image       *string `json:"image"`
	Favicon     *string `json:"favicon"`
	Domain      string  `json:"domain"`
}

const (
	metadataFetchTimeout = 5 * time.Second
	metadataMaxBytes     = 50 * 1024
	metadataUserAgent    = "Mozilla/5.0 (compatible; llink/1.0)"
)

// In-process cache, unbounded but only successful results are stored.
// URL space is bounded in practice.
var metadataCache sync.Map // map[string]LinkMetadata

func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) {
	raw := r.URL.Query().Get("url")
	if raw == "" {
		http.Error(w, "url is required", http.StatusBadRequest)
		return
	}

	parsed, err := url.Parse(raw)
	if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
		http.Error(w, "invalid url", http.StatusBadRequest)
		return
	}

	if cached, ok := metadataCache.Load(raw); ok {
		writeJSON(w, cached)
		return
	}

	if err := guardSSRF(parsed.Hostname()); err != nil {
		// Treat unresolvable / private hosts as "no metadata" rather than 4xx — the
		// client treats a null body as a progressive-enhancement miss.
		writeJSON(w, nil)
		return
	}

	meta, err := fetchLinkMetadata(r.Context(), parsed)
	if err != nil {
		flog.Warn("link metadata fetch failed", "url", raw, "error", err)
		writeJSON(w, nil)
		return
	}

	metadataCache.Store(raw, *meta)
	writeJSON(w, meta)
}

// guardSSRF resolves the host and rejects loopback, private, link-local, and
// unspecified addresses so the endpoint can't be turned into an internal-network
// probe.
func guardSSRF(host string) error {
	ips, err := net.LookupIP(host)
	if err != nil {
		return err
	}
	if len(ips) == 0 {
		return errors.New("no addresses for host")
	}
	for _, ip := range ips {
		if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
			ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
			return errors.New("private or local address")
		}
	}
	return nil
}

func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) {
	ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", metadataUserAgent)
	req.Header.Set("Accept", "text/html")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, errors.New("upstream non-2xx")
	}

	body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes))
	if err != nil {
		return nil, err
	}
	html := string(body)

	domain := strings.TrimPrefix(target.Hostname(), "www.")
	title := firstNonNil(metaContent(html, "og:title"), parseTitle(html))
	description := firstNonNil(
		metaContent(html, "og:description"),
		metaContent(html, "description"),
	)
	image := resolveURL(metaContent(html, "og:image"), target)
	favicon := parseFavicon(html, target)

	return &LinkMetadata{
		URL:         target.String(),
		Title:       title,
		Description: description,
		Image:       image,
		Favicon:     favicon,
		Domain:      domain,
	}, nil
}

var titleRe = regexp.MustCompile(`(?i)<title[^>]*>([^<]*)</title>`)

func parseTitle(html string) *string {
	m := titleRe.FindStringSubmatch(html)
	if len(m) < 2 {
		return nil
	}
	s := strings.TrimSpace(m[1])
	if s == "" {
		return nil
	}
	return &s
}

// metaContent finds <meta property|name="<prop>" content="..."> in either attribute order.
func metaContent(html, prop string) *string {
	escaped := regexp.QuoteMeta(prop)
	re := regexp.MustCompile(
		`(?i)<meta[^>]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` +
			`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`,
	)
	m := re.FindStringSubmatch(html)
	if len(m) == 0 {
		return nil
	}
	for _, g := range m[1:] {
		if g != "" {
			return &g
		}
	}
	return nil
}

var (
	faviconRe1 = regexp.MustCompile(`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`)
	faviconRe2 = regexp.MustCompile(`(?i)<link[^>]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`)
)

func parseFavicon(html string, base *url.URL) *string {
	for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} {
		if m := re.FindStringSubmatch(html); len(m) >= 2 {
			return resolveURL(&m[1], base)
		}
	}
	// Fall back to /favicon.ico
	fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String()
	return &fallback
}

func resolveURL(src *string, base *url.URL) *string {
	if src == nil || *src == "" {
		return nil
	}
	parsed, err := url.Parse(*src)
	if err != nil {
		return src
	}
	resolved := base.ResolveReference(parsed).String()
	return &resolved
}

func firstNonNil(values ...*string) *string {
	for _, v := range values {
		if v != nil && *v != "" {
			return v
		}
	}
	return nil
}
