// httptool.go uzak (kind=http) tool'ların çalıştırılmasıdır (Faz 4b′).
//
// GÜVENLİK (bu dosya bir SSRF korumasıdır):
// Kullanıcı tanımlı bir URL'ye istek atabilen tool, tanımı güvenilir olmayan
// girdiyle (skill, model, kullanıcı metni) kontrolsüz ağ erişimine dönüşebilir.
// Bu yüzden:
//  1. yalnızca http/https kabul edilir (file://, gopher://, ftp:// yok),
//  2. hedef IP GERÇEKTEN BAĞLANILAN IP üzerinden denetlenir (DNS rebinding'e kapalı:
//     önce "izinli" görünen, bağlantı anında içeri yönlendilen sunucu olamaz),
//  3. loopback / özel ağ / link-local / cloud metadata aralıkları reddedilir,
//  4. yönlendirme sayısı sınırlıdır,
//  5. yanıt gövdesi ve süre sınırlıdır.
//
// Geliştirme makinelerinde iç ağdaki servislere (ör. localhost CATIA otomasyonu)
// ulaşmak için TOOL_HTTP_ALLOW_PRIVATE açılabilir — varsayılan KAPALI.
package tool

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

// HTTPPolicy uzak tool'ların çalışma politikasıdır (sunucu açılışında ayarlanır).
type HTTPPolicy struct {
	Enabled      bool  // false ise kind=http tool'lar ErrHTTPDisabled döner (genel kapı)
	AllowPrivate bool  // true ise loopback/özel ağ HEDEFLENEBİLİR (yalnızca geliştirme)
	MaxBytes     int64 // yanıt gövdesi üst sınırı
	MaxRedirects int   // izlenecek en çok yönlendirme
	Timeout      time.Duration
	// DefaultClient testler/simülasyon için; nil ise SSRF korumalı kendi istemcimiz kurulur.
	DefaultClient *http.Client
}

// Varsayılan sınırlar: dışarıya veri sızdırmayan ama işe yarayan değerler.
const (
	DefaultHTTPMaxBytes     int64 = 1 << 20 // 1 MiB
	DefaultHTTPMaxRedirects       = 2
	DefaultHTTPTimeout            = 30 * time.Second
)

// Hata değerleri: Executor bunları durum kodu ve deneme kararına çevirir.
var (
	// ErrHTTPDisabled genel kapı kapalıyken uzak tool çalıştırılamaz.
	ErrHTTPDisabled = errors.New("tool: uzak (http) tool çalıştırma kapalı (TOOL_HTTP_ENABLED=false)")
	// ErrHTTPBlocked hedef iç ağ/özel adrese işaret ediyor (SSRF koruması).
	ErrHTTPBlocked = errors.New("tool: hedef adres güvenlik nedeniyle engellendi")
	// ErrHTTPRefused uzak servis 4xx döndü (kalıcı hata: tekrar denemenin anlamı yok).
	ErrHTTPRefused = errors.New("tool: uzak servis isteği reddetti")
	// ErrHTTPUnavailable uzak servis 5xx ya da bağlantı hatası (yeniden denenebilir).
	ErrHTTPUnavailable = errors.New("tool: uzak servis yanıt vermedi")
)

// HTTPConfig, Tool.Config alanının HTTP tool için okunmuş halidir.
//
//	{
//	  "method":  "POST",
//	  "url":     "http://catia-sunucu:8080/api/plane",
//	  "headers": {"X-Api-Key": "..."},
//	  "bodyTemplate": "{\"name\":\"{{name}}\"}"
//	}
type HTTPConfig struct {
	Method  string            `json:"method"`
	URL     string            `json:"url"`
	Headers map[string]string `json:"headers"`
	// BodyTemplate istek gövdesidir; `{{alan}}` yer tutucuları girdiden doldurulur.
	BodyTemplate string `json:"bodyTemplate"`
	// Result "text" ise gövde yalnızca metin olarak döner; varsayılan JSON da ayrıştırılır.
	Result string `json:"result"`
}

// ReadHTTPConfig, Tool.Config'ten HTTP ayarını okur. url zorunludur.
func ReadHTTPConfig(t *Tool) (HTTPConfig, error) {
	var c HTTPConfig
	if t == nil {
		return c, errors.New("tool tanımı yok")
	}
	raw, _ := json.Marshal(t.Config)
	if err := json.Unmarshal(raw, &c); err != nil {
		return c, fmt.Errorf("config okunamadı: %w", err)
	}
	c.Method = strings.ToUpper(strings.TrimSpace(c.Method))
	if c.Method == "" {
		c.Method = http.MethodGet
	}
	c.URL = strings.TrimSpace(c.URL)
	if c.URL == "" {
		return c, errors.New("config.url zorunlu")
	}
	return c, nil
}

// NewHTTPHandler, verilen tool için çalışma zamanı handler üretir.
func NewHTTPHandler(t *Tool, policy HTTPPolicy) (Handler, error) {
	cfg, err := ReadHTTPConfig(t)
	if err != nil {
		return nil, &ValidationError{Errors: []FieldError{{Field: "config", Message: err.Error()}}}
	}
	if u, perr := url.Parse(cfg.URL); perr != nil || u.Scheme == "" || u.Host == "" ||
		(u.Scheme != "http" && u.Scheme != "https") {
		return nil, &ValidationError{Errors: []FieldError{{Field: "config.url", Message: "geçerli bir http:// veya https:// adresi olmalı"}}}
	}
	client := policy.DefaultClient
	if client == nil {
		client = newSafeHTTPClient(policy)
	}
	return func(ctx context.Context, _ *Tool, in map[string]any) (map[string]any, error) {
		return callHTTP(ctx, client, cfg, in, policy)
	}, nil
}

// newSafeHTTPClient, hedef IP'yi BAĞLANILDIĞI anda denetleyen istemci kurar.
//
// Neden DialContext'te denetim? URL'deki alan adı ön kontrolde "güvenli" görünebilir
// (örn. attacker.com → 1.2.3.4) ama bağlantı anında 127.0.0.1'e çözülebilir
// (DNS rebinding). Denetim ÇÖZÜMLENMİŞ IP üzerinde yapılmalıdır.
func newSafeHTTPClient(p HTTPPolicy) *http.Client {
	dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
	transport := &http.Transport{
		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
			host, _, err := net.SplitHostPort(addr)
			if err != nil {
				return nil, err
			}
			ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
			if err != nil {
				return nil, fmt.Errorf("adres çözümlenemedi: %w", err)
			}
			if !p.AllowPrivate {
				for _, ip := range ips {
					if isBlockedIP(ip) {
						return nil, fmt.Errorf("%w: %s (%s)", ErrHTTPBlocked, host, ip)
					}
				}
			}
			return dialer.DialContext(ctx, network, addr)
		},
		TLSHandshakeTimeout: 10 * time.Second,
	}
	timeout := p.Timeout
	if timeout <= 0 {
		timeout = DefaultHTTPTimeout
	}
	return &http.Client{
		Transport: transport,
		Timeout:   timeout,
		CheckRedirect: func(_ *http.Request, via []*http.Request) error {
			if len(via) > p.maxRedirects() {
				return errors.New("çok fazla yönlendirme")
			}
			return nil
		},
	}
}

func (p HTTPPolicy) maxRedirects() int {
	if p.MaxRedirects <= 0 {
		return DefaultHTTPMaxRedirects
	}
	return p.MaxRedirects
}

// isBlockedIP, hedefin iç ağa / kaynak sunucusuna / özel ağa gitmesini engeller.
// Cloud metadata (169.254.169.254) link-local aralığındadır.
func isBlockedIP(ip net.IP) bool {
	if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() ||
		ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
		return true
	}
	if v4 := ip.To4(); v4 != nil {
		if v4[0] == 169 && v4[1] == 254 { // link-local 169.254.0.0/16
			return true
		}
		if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { // CGNAT 100.64.0.0/10
			return true
		}
	}
	return false
}

// callHTTP tek bir uzak çağrıyı yapar.
func callHTTP(ctx context.Context, client *http.Client, cfg HTTPConfig, in map[string]any, policy HTTPPolicy) (map[string]any, error) {
	target := renderTemplate(cfg.URL, in)
	u, err := url.Parse(target)
	if err != nil || u.Scheme == "" || u.Host == "" {
		return nil, errors.New("geçersiz url")
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return nil, fmt.Errorf("%w: şema %q desteklenmiyor", ErrHTTPBlocked, u.Scheme)
	}

	var body string
	if cfg.BodyTemplate != "" {
		body = renderTemplate(cfg.BodyTemplate, in)
	}
	var reader io.Reader
	if body != "" {
		reader = strings.NewReader(body)
	}
	req, err := http.NewRequestWithContext(ctx, cfg.Method, u.String(), reader)
	if err != nil {
		return nil, err
	}
	if body != "" {
		req.Header.Set("Content-Type", "application/json")
	}
	for k, v := range cfg.Headers {
		// CRLF enjeksiyonu: başlık adında/değerinde satır sonu olamaz.
		if strings.ContainsAny(k, "\r\n") || strings.ContainsAny(v, "\r\n") {
			return nil, errors.New("başlıkta satır sonu olamaz")
		}
		req.Header.Set(strings.TrimSpace(k), renderTemplate(v, in))
	}

	resp, err := client.Do(req)
	if err != nil {
		if errors.Is(err, ErrHTTPBlocked) {
			return nil, err
		}
		return nil, fmt.Errorf("%w: %v", ErrHTTPUnavailable, err)
	}
	defer resp.Body.Close()

	maxBytes := policy.MaxBytes
	if maxBytes <= 0 {
		maxBytes = DefaultHTTPMaxBytes
	}
	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
	if err != nil {
		return nil, fmt.Errorf("%w: gövde okunamadı: %v", ErrHTTPUnavailable, err)
	}
	truncated := int64(len(raw)) > maxBytes
	if truncated {
		raw = raw[:maxBytes]
	}
	out := map[string]any{"status": resp.StatusCode, "body": string(raw)}
	if truncated {
		out["truncated"] = true
	}
	if cfg.Result != "text" {
		var parsed any
		if json.Unmarshal(raw, &parsed) == nil {
			out["json"] = parsed
		}
	}

	switch {
	case resp.StatusCode >= 200 && resp.StatusCode < 300:
		return out, nil
	case resp.StatusCode >= 500:
		// Geçici: Executor'ın retry politikası devreye girsin.
		return out, retryableError{fmt.Errorf("%w: HTTP %d", ErrHTTPUnavailable, resp.StatusCode)}
	default:
		// 4xx kalıcı: yeniden denemek anlamlı değil.
		return out, fmt.Errorf("%w: HTTP %d", ErrHTTPRefused, resp.StatusCode)
	}
}

// retryableError, Executor'ın "geçici hata" protokolünü karşılar (FailedError.Retryable
// sarmalanmış hata bu arayüzü arar). 5xx ve bağlantı hataları geçicidir; 4xx değildir.
type retryableError struct{ err error }

func (e retryableError) Error() string   { return e.err.Error() }
func (e retryableError) Unwrap() error   { return e.err }
func (e retryableError) Retryable() bool { return true }

// renderTemplate, `{{alan}}` yer tutucularını girdiyle doldurur (yoksa boş bırakır).
func renderTemplate(tpl string, in map[string]any) string {
	var b strings.Builder
	for i := 0; i < len(tpl); {
		if tpl[i] == '{' && i+1 < len(tpl) && tpl[i+1] == '{' {
			end := strings.Index(tpl[i+2:], "}}")
			if end < 0 {
				b.WriteString(tpl[i:]) // eksik kapatma: olduğu gibi yaz
				break
			}
			b.WriteString(lookupString(in, strings.TrimSpace(tpl[i+2:i+2+end])))
			i += 2 + end + 2
			continue
		}
		b.WriteByte(tpl[i])
		i++
	}
	return b.String()
}

func lookupString(in map[string]any, key string) string {
	v, ok := in[key]
	if !ok || v == nil {
		return ""
	}
	switch x := v.(type) {
	case string:
		return x
	case float64:
		return strconv.FormatFloat(x, 'f', -1, 64)
	case bool:
		return strconv.FormatBool(x)
	default:
		raw, err := json.Marshal(x)
		if err != nil {
			return ""
		}
		return string(raw)
	}
}
