//go:build contract

// Contract tests for the two mock services (§13). They run against live
// containers:
//
//	docker compose --profile mocks -f docker-compose.yml -f docker-compose.test.yml up -d --build
//	ATOMIZER_TOKEN=… WORK_PERFORMER_TOKEN=… go test -tags=contract ./internal/contract/
//
// URLs default to the loopback ports published by the mocks profile.
package contract

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"math"
	"net"
	"net/http"
	"os"
	"strings"
	"testing"
	"time"

	"bountyboard/internal/workperform"
)

func env(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}

var (
	atomizerURL    = env("CONTRACT_ATOMIZER_URL", "http://127.0.0.1:8090")
	performerURL   = env("CONTRACT_PERFORMER_URL", "http://127.0.0.1:8091")
	atomizerToken  = os.Getenv("ATOMIZER_TOKEN")
	performerToken = os.Getenv("WORK_PERFORMER_TOKEN")
)

func postJSON(t *testing.T, url, token string, body any) (*http.Response, []byte) {
	t.Helper()
	b, _ := json.Marshal(body)
	req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
	req.Header.Set("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("POST %s: %v", url, err)
	}
	data, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	return resp, data
}

func TestAtomizerContract(t *testing.T) {
	// healthz
	resp, err := http.Get(atomizerURL + "/healthz")
	if err != nil {
		t.Skipf("atomizer not reachable at %s (start the mocks profile): %v", atomizerURL, err)
	}
	resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Fatalf("healthz: %d", resp.StatusCode)
	}

	// wrong bearer token rejected (when a token is configured)
	if atomizerToken != "" {
		r, _ := postJSON(t, atomizerURL+"/v1/atomize", "wrong-token", map[string]any{"taskId": "x", "title": "x"})
		if r.StatusCode != http.StatusUnauthorized {
			t.Fatalf("wrong token: %d, want 401", r.StatusCode)
		}
	}

	// atomize honors the §5.1 response contract
	r, data := postJSON(t, atomizerURL+"/v1/atomize", atomizerToken, map[string]any{
		"taskId":             "01JCONTRACT",
		"title":              "Implement user import",
		"description":        "Import users from CSV with mapping and validation.",
		"acceptanceCriteria": []string{"valid rows imported", "errors reported per row"},
		"constraints":        map[string]int{"minTasks": 2, "maxTasks": 5},
	})
	if r.StatusCode != http.StatusOK {
		t.Fatalf("atomize: %d %s", r.StatusCode, data)
	}
	var out struct {
		Tasks []struct {
			Title             string  `json:"title"`
			Description       string  `json:"description"`
			EffortCoefficient float64 `json:"effortCoefficient"`
		} `json:"tasks"`
		Model string `json:"model"`
	}
	if err := json.Unmarshal(data, &out); err != nil {
		t.Fatalf("decode: %v: %s", err, data)
	}
	if len(out.Tasks) < 2 || len(out.Tasks) > 5 {
		t.Fatalf("task count %d outside constraints", len(out.Tasks))
	}
	sum := 0.0
	for _, task := range out.Tasks {
		if task.Title == "" || task.EffortCoefficient <= 0 || task.EffortCoefficient > 1 {
			t.Fatalf("bad task: %+v", task)
		}
		sum += task.EffortCoefficient
	}
	if math.Abs(sum-1) > 0.001 {
		t.Fatalf("coefficient sum %v, want 1±0.001", sum)
	}

	// extend returns exactly one task with coefficient in (0, 2]
	r, data = postJSON(t, atomizerURL+"/v1/extend", atomizerToken, map[string]any{
		"taskId": "01JCONTRACT", "title": "Implement user import",
		"description":   "Import users from CSV.",
		"extensionNote": "add reusable column-mapping presets",
	})
	if r.StatusCode != http.StatusOK {
		t.Fatalf("extend: %d %s", r.StatusCode, data)
	}
	var ext struct {
		Task struct {
			Title             string  `json:"title"`
			EffortCoefficient float64 `json:"effortCoefficient"`
		} `json:"task"`
	}
	if err := json.Unmarshal(data, &ext); err != nil {
		t.Fatalf("decode extend: %v", err)
	}
	if ext.Task.Title == "" || ext.Task.EffortCoefficient <= 0 || ext.Task.EffortCoefficient > 2 {
		t.Fatalf("bad extension: %+v", ext.Task)
	}

	// extend without a note is rejected
	r, _ = postJSON(t, atomizerURL+"/v1/extend", atomizerToken, map[string]any{
		"taskId": "x", "title": "x",
	})
	if r.StatusCode != http.StatusBadRequest {
		t.Fatalf("extend without note: %d, want 400", r.StatusCode)
	}
}

func TestWorkPerformerContract(t *testing.T) {
	resp, err := http.Get(performerURL + "/healthz")
	if err != nil {
		t.Skipf("work performer not reachable at %s (start the mocks profile): %v", performerURL, err)
	}
	resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Fatalf("healthz: %d", resp.StatusCode)
	}

	// callback receiver on the host, reachable from the container via
	// host.docker.internal (extra_hosts: host-gateway)
	callbackCh := make(chan struct {
		body []byte
		sig  string
	}, 1)
	ln, err := net.Listen("tcp", "0.0.0.0:0")
	if err != nil {
		t.Fatal(err)
	}
	defer ln.Close()
	go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		select {
		case callbackCh <- struct {
			body []byte
			sig  string
		}{body, r.Header.Get("X-Signature")}:
		default:
		}
		w.WriteHeader(200)
	}))
	port := ln.Addr().(*net.TCPAddr).Port
	callbackURL := fmt.Sprintf("http://host.docker.internal:%d/cb", port)

	r, data := postJSON(t, performerURL+"/v1/jobs", performerToken, map[string]any{
		"taskId": "01JCONTRACTWP", "title": "Write hello world",
		"description":        "Create hello.txt containing hello world.",
		"acceptanceCriteria": []string{"hello.txt exists"},
		"callbackUrl":        callbackURL,
	})
	if r.StatusCode != http.StatusAccepted {
		t.Fatalf("submit: %d %s", r.StatusCode, data)
	}
	var accepted struct {
		JobID  string `json:"jobId"`
		Status string `json:"status"`
	}
	if err := json.Unmarshal(data, &accepted); err != nil || accepted.JobID == "" {
		t.Fatalf("bad 202 body: %s", data)
	}

	// status endpoint
	req, _ := http.NewRequest(http.MethodGet, performerURL+"/v1/jobs/"+accepted.JobID, nil)
	req.Header.Set("Authorization", "Bearer "+performerToken)
	sResp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	sBody, _ := io.ReadAll(sResp.Body)
	sResp.Body.Close()
	var status struct {
		Status string `json:"status"`
	}
	if err := json.Unmarshal(sBody, &status); err != nil {
		t.Fatalf("status decode: %s", sBody)
	}
	if !strings.Contains("queued running succeeded failed", status.Status) {
		t.Fatalf("status %q", status.Status)
	}

	// HMAC-signed callback arrives (the simulated path is fast; the real
	// claude path may take minutes — allow a generous window)
	select {
	case cb := <-callbackCh:
		if !workperform.VerifySignature(performerToken, cb.body, cb.sig) {
			t.Fatalf("callback signature invalid")
		}
		var payload workperform.Callback
		if err := json.Unmarshal(cb.body, &payload); err != nil {
			t.Fatalf("callback decode: %v", err)
		}
		if payload.JobID != accepted.JobID || payload.TaskID != "01JCONTRACTWP" {
			t.Fatalf("callback ids: %+v", payload)
		}
		if payload.Status != "succeeded" && payload.Status != "failed" {
			t.Fatalf("callback status %q", payload.Status)
		}
		// artifacts must be fetchable (rewrite in-network host for the host-side test)
		for _, a := range payload.Artifacts {
			url := strings.Replace(a.URL, "http://work-performer:8091", performerURL, 1)
			aResp, err := http.Get(url)
			if err != nil || aResp.StatusCode != 200 {
				t.Fatalf("artifact %s not fetchable: %v %v", a.URL, err, aResp)
			}
			aResp.Body.Close()
		}
	case <-time.After(5 * time.Minute):
		t.Fatal("no callback within 5 minutes")
	}

	// cancel is accepted for unknown-but-wellformed ids → 404, and DELETE on
	// a finished job still answers
	req, _ = http.NewRequest(http.MethodDelete, performerURL+"/v1/jobs/"+accepted.JobID, nil)
	req.Header.Set("Authorization", "Bearer "+performerToken)
	dResp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	dResp.Body.Close()
	if dResp.StatusCode != 200 {
		t.Fatalf("cancel: %d", dResp.StatusCode)
	}
}
