// job_download_test.go — covers GET /api/jobs/{id}/log.{txt,ndjson}.
package http

import (
	"context"
	"encoding/json"
	stdhttp "net/http"
	"strings"
	"testing"
	"time"

	"github.com/oklog/ulid/v2"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

// seedJobWithLogs creates a job + a few log lines for it. Returns the
// job ID. Caller is responsible for the test server + auth.
func seedJobWithLogs(t *testing.T, st *store.Store, hostID string, lineCount int) string {
	t.Helper()
	jobID := ulid.Make().String()
	now := time.Now().UTC()
	if err := st.CreateJob(context.Background(), store.Job{
		ID: jobID, HostID: hostID, Kind: "diff",
		ActorKind: "user", CreatedAt: now,
	}); err != nil {
		t.Fatalf("create job: %v", err)
	}
	if err := st.MarkJobStarted(context.Background(), jobID, now); err != nil {
		t.Fatalf("mark started: %v", err)
	}
	for i := 0; i < lineCount; i++ {
		stream := "stdout"
		if i%5 == 0 {
			stream = "stderr"
		}
		payload := `{"message_type":"change","path":"/etc/file` +
			ulid.Make().String()[:6] + `","modifier":"M"}`
		if err := st.AppendJobLog(context.Background(), jobID, int64(i+1),
			now.Add(time.Duration(i)*time.Millisecond),
			stream, payload); err != nil {
			t.Fatalf("append log: %v", err)
		}
	}
	if err := st.MarkJobFinished(context.Background(), jobID, "succeeded", 0, nil, "", now); err != nil {
		t.Fatalf("mark finished: %v", err)
	}
	return jobID
}

// TestJobLogDownloadTxt: plain-text format includes a header + one
// line per log row in the expected shape.
func TestJobLogDownloadTxt(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, _ := enrolHostForWS(t, srv, st, "dl-txt-host")
	jobID := seedJobWithLogs(t, st, hostID, 12)
	cookie := loginAsAdmin(t, st)

	req, _ := stdhttp.NewRequest("GET",
		ts.URL+"/api/jobs/"+jobID+"/log.txt", nil)
	req.AddCookie(cookie)
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusOK {
		t.Fatalf("status: got %d, want 200", res.StatusCode)
	}
	if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
		t.Errorf("content-type: got %q", ct)
	}
	if cd := res.Header.Get("Content-Disposition"); !strings.Contains(cd, ".txt") {
		t.Errorf("content-disposition: got %q", cd)
	}
	body := readBody(t, res.Body)
	// Header lines.
	if !strings.HasPrefix(body, "# job ") {
		t.Errorf("expected '# job ...' header line; got %q", short(body))
	}
	if !strings.Contains(body, "12 log lines") {
		t.Errorf("expected '12 log lines'; got %q", short(body))
	}
	// One body line per log row — count non-comment, non-empty lines.
	var rows int
	for _, line := range strings.Split(body, "\n") {
		l := strings.TrimSpace(line)
		if l == "" || strings.HasPrefix(l, "#") {
			continue
		}
		rows++
	}
	if rows != 12 {
		t.Errorf("expected 12 body rows, got %d", rows)
	}
	// Tag check: at least one ERR row (every 5th was stderr).
	if !strings.Contains(body, " ERR ") {
		t.Errorf("expected at least one ERR row")
	}
}

// TestJobLogDownloadNDJSON: each line is a self-contained JSON object.
func TestJobLogDownloadNDJSON(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, _ := enrolHostForWS(t, srv, st, "dl-ndjson-host")
	jobID := seedJobWithLogs(t, st, hostID, 5)
	cookie := loginAsAdmin(t, st)

	req, _ := stdhttp.NewRequest("GET",
		ts.URL+"/api/jobs/"+jobID+"/log.ndjson", nil)
	req.AddCookie(cookie)
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusOK {
		t.Fatalf("status: got %d, want 200", res.StatusCode)
	}
	if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-ndjson") {
		t.Errorf("content-type: got %q", ct)
	}
	body := readBody(t, res.Body)
	// Each non-empty line should parse as an object with seq/ts/stream/payload.
	var seen int
	for _, line := range strings.Split(body, "\n") {
		if strings.TrimSpace(line) == "" {
			continue
		}
		var obj struct {
			Seq     int64  `json:"seq"`
			TS      string `json:"ts"`
			Stream  string `json:"stream"`
			Payload string `json:"payload"`
		}
		if err := json.Unmarshal([]byte(line), &obj); err != nil {
			t.Fatalf("parse line %q: %v", line, err)
		}
		if obj.Seq == 0 || obj.TS == "" || obj.Stream == "" || obj.Payload == "" {
			t.Errorf("incomplete object: %+v", obj)
		}
		seen++
	}
	if seen != 5 {
		t.Errorf("parsed %d objects, want 5", seen)
	}
}

// TestJobLogDownloadNotFound: 404 for an unknown job id.
func TestJobLogDownloadNotFound(t *testing.T) {
	t.Parallel()
	_, ts, st := rawTestServer(t)
	cookie := loginAsAdmin(t, st)
	req, _ := stdhttp.NewRequest("GET",
		ts.URL+"/api/jobs/"+ulid.Make().String()+"/log.txt", nil)
	req.AddCookie(cookie)
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusNotFound {
		t.Fatalf("status: got %d, want 404", res.StatusCode)
	}
}

// TestJobLogDownloadUnauthenticated: without a session cookie, 401.
func TestJobLogDownloadUnauthenticated(t *testing.T) {
	t.Parallel()
	_, ts, _ := rawTestServer(t)
	res, err := stdhttp.Get(ts.URL + "/api/jobs/x/log.txt")
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusUnauthorized {
		t.Fatalf("status: got %d, want 401", res.StatusCode)
	}
}
