package handler

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
	"gopkg.in/yaml.v3"
)

// historyTestSetup wires a fresh root with the embedded defaults
// (which declare the records: rules for mdl/rsk/ssr) plus a
// permissive ACL for *@example.com so the test cohort can write
// anywhere under archive/.
//
// Returns (cfg, do) where do invokes ServeFileAPI directly — we
// bypass the dispatch tree because the system-under-test is the
// serveFilePut path, not the entire HTTP stack.
func historyTestSetup(t *testing.T) (config.Config, func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder) {
	t.Helper()
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, ".zddc"),
		[]byte("acl:\n  permissions:\n    \"*@example.com\": rwcd\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zddc.InvalidateCache(root)
	cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}

	do := func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
		var req *http.Request
		if body != nil {
			req = httptest.NewRequest(method, target, bytes.NewReader(body))
			req.Header.Set("Content-Type", "application/yaml")
		} else {
			req = httptest.NewRequest(method, target, nil)
		}
		for k, v := range headers {
			req.Header.Set(k, v)
		}
		ctx := context.WithValue(req.Context(), EmailKey, email)
		ctx = context.WithValue(ctx, ElevatedKey, true)
		req = req.WithContext(ctx)
		rec := httptest.NewRecorder()
		ServeFileAPI(cfg, rec, req)
		return rec
	}
	return cfg, do
}

// TestRecordPut_CreateStampsAuditFields verifies that a PUT to a
// fresh mdl row inserts created_*, updated_*, revision=1 server-side,
// and that the response body echoes the stamped YAML.
func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
	cfg, do := historyTestSetup(t)

	// Build a body with the right components for the embedded
	// mdl rule's filename_format.
	body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Test spec\n")
	url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml"

	rec := do(http.MethodPut, url, "alice@example.com", body, nil)
	if rec.Code != http.StatusCreated {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}

	// Response body should be the stamped YAML.
	out := map[string]any{}
	if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil {
		t.Fatalf("parse response body: %v", err)
	}
	if out["created_by"] != "alice@example.com" {
		t.Errorf("created_by=%v want alice@example.com", out["created_by"])
	}
	if out["updated_by"] != "alice@example.com" {
		t.Errorf("updated_by=%v want alice@example.com", out["updated_by"])
	}
	if out["revision"] != 1 {
		t.Errorf("revision=%v want 1", out["revision"])
	}
	if out["created_at"] == "" || out["updated_at"] == "" {
		t.Errorf("created_at/updated_at empty: %+v", out)
	}
	if _, hasPrev := out["previous_sha"]; hasPrev {
		t.Errorf("previous_sha should be absent on create: %+v", out)
	}

	// On-disk file matches the response body.
	abs := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", "ACM-PRJ-EL-SPC-0001.yaml")
	disk, err := os.ReadFile(abs)
	if err != nil {
		t.Fatalf("read disk: %v", err)
	}
	if !bytes.Equal(disk, rec.Body.Bytes()) {
		t.Errorf("response body != disk bytes\nresponse=%s\ndisk=%s", rec.Body.String(), disk)
	}

	// No history dir yet (create only).
	histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history")
	if _, err := os.Stat(histDir); !os.IsNotExist(err) {
		t.Errorf(".history/ should not exist after create-only; got err=%v", err)
	}
}

// TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior verifies
// that the second write captures the first into .history/<base>/,
// chains previous_sha, and increments revision.
func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
	cfg, do := historyTestSetup(t)
	url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml"

	body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
	rec := do(http.MethodPut, url, "alice@example.com", body1, nil)
	if rec.Code != http.StatusCreated {
		t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
	}
	firstEtag := strings.Trim(rec.Result().Header.Get("ETag"), `"`)

	body2 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V2\n")
	rec = do(http.MethodPut, url, "bob@example.com", body2, map[string]string{
		"If-Match": `"` + firstEtag + `"`,
	})
	if rec.Code != http.StatusOK {
		t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
	}

	out := map[string]any{}
	if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil {
		t.Fatalf("parse update response: %v", err)
	}
	if out["created_by"] != "alice@example.com" {
		t.Errorf("created_by should be preserved as alice: %v", out["created_by"])
	}
	if out["updated_by"] != "bob@example.com" {
		t.Errorf("updated_by should be bob: %v", out["updated_by"])
	}
	if out["revision"] != 2 {
		t.Errorf("revision=%v want 2", out["revision"])
	}
	if out["previous_sha"] == "" || out["previous_sha"] == nil {
		t.Errorf("previous_sha should be non-empty on update: %+v", out)
	}

	// .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes).
	histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history", "ACM-PRJ-EL-SPC-0001")
	ents, err := os.ReadDir(histDir)
	if err != nil {
		t.Fatalf("read history dir: %v", err)
	}
	if len(ents) != 1 {
		t.Fatalf("history entries=%d want 1", len(ents))
	}
	// The archived file's title is V1 (the prior version).
	prior, err := os.ReadFile(filepath.Join(histDir, ents[0].Name()))
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Contains(prior, []byte("title: V1")) {
		t.Errorf("archived prior version missing title=V1; got %s", prior)
	}
}

// TestRecordPut_ConflictPreservesHistory ensures a 412 doesn't
// write anything — no history entry, no overwrite.
func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
	cfg, do := historyTestSetup(t)
	url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml"

	body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
	if rec := do(http.MethodPut, url, "alice@example.com", body1, nil); rec.Code != http.StatusCreated {
		t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
	}

	body2 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V2\n")
	rec := do(http.MethodPut, url, "bob@example.com", body2, map[string]string{
		"If-Match": `"deadbeefdeadbeefdeadbeefdeadbeef"`, // wrong
	})
	if rec.Code != http.StatusPreconditionFailed {
		t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String())
	}

	histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history")
	if _, err := os.Stat(histDir); !os.IsNotExist(err) {
		t.Errorf("history dir should not exist after 412 conflict; got err=%v", err)
	}
}

// TestRecordPut_ClientAuditFieldsStripped: client tries to forge
// audit fields → server silently strips and overwrites them.
func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) {
	_, do := historyTestSetup(t)
	url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml"

	body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Forged\n" +
		"created_by: eve@evil.com\nupdated_by: eve@evil.com\nrevision: 999\n")
	rec := do(http.MethodPut, url, "alice@example.com", body, nil)
	if rec.Code != http.StatusCreated {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	out := map[string]any{}
	if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil {
		t.Fatal(err)
	}
	if out["created_by"] != "alice@example.com" {
		t.Errorf("client-forged created_by leaked through: %v", out["created_by"])
	}
	if out["revision"] != 1 {
		t.Errorf("client-forged revision leaked through: %v", out["revision"])
	}
}

// TestRecordPut_FilenameMismatch: body fields compose to a different
// tracking number than the URL → 422 with a "/" path error.
func TestRecordPut_FilenameMismatch(t *testing.T) {
	_, do := historyTestSetup(t)
	// URL claims sequence=0002 but body says 0001 → mismatch.
	url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0002.yaml"
	body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n")
	rec := do(http.MethodPut, url, "alice@example.com", body, nil)
	if rec.Code != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d body=%s", rec.Code, rec.Body.String())
	}
}

// TestRecordPut_LockedFieldRejected: rsk rule locks type=RSK; a
// client submitting type=SPC for an rsk row gets 422 with
// path=/type.
func TestRecordPut_LockedFieldRejected(t *testing.T) {
	_, do := historyTestSetup(t)
	url := "/Project/archive/Acme/rsk/ACM-PRJ-EL-RSK-0001-001.yaml"
	// Client tries type=SPC even though rsk/ locks type=RSK.
	body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\nrow: '001'\ntitle: X\n")
	rec := do(http.MethodPut, url, "alice@example.com", body, nil)
	if rec.Code != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d body=%s", rec.Code, rec.Body.String())
	}
	var errs struct {
		Errors []struct {
			Path    string `json:"path"`
			Message string `json:"message"`
		} `json:"errors"`
	}
	_ = json.Unmarshal(rec.Body.Bytes(), &errs)
	found := false
	for _, e := range errs.Errors {
		if e.Path == "/type" {
			found = true
		}
	}
	if !found {
		t.Errorf("expected /type lock error; got %v", errs.Errors)
	}
}

// TestRecordPut_SSRHistoryAtPartyLevel: writing to an SSR row's
// canonical archive/<party>/ssr.yaml puts history at
// archive/<party>/.history/ssr/, NOT at archive/.history/<party>/.
func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) {
	cfg, do := historyTestSetup(t)
	// We bypass the SSR create handler and just PUT directly to the
	// canonical path the SSR rewrites would land on.
	abs := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
	if err := os.MkdirAll(abs, 0o755); err != nil {
		t.Fatal(err)
	}
	// The plain file API uses the bytes as-is; ssr.yaml's records:
	// rule will trigger audit stamping but no filename composition
	// (no filename_format on the SSR records: entry).
	url := "/Project/archive/0330C1/ssr.yaml"
	body := []byte("kind: SSR\nvendorType: subcontractor\ncontractNo: PO-001\nscopeSummary: Concrete\n")
	if rec := do(http.MethodPut, url, "alice@example.com", body, nil); rec.Code != http.StatusCreated {
		t.Fatalf("first put status=%d body=%s", rec.Code, rec.Body.String())
	}

	// Read back current ETag so we can update with If-Match.
	getRec := do(http.MethodGet, url, "alice@example.com", nil, nil)
	_ = getRec // ETag rebuilt from disk by fileETag inside serveFilePut

	rec := do(http.MethodPut, url, "bob@example.com", []byte("kind: SSR\nvendorType: supplier\ncontractNo: PO-002\nscopeSummary: Pipe\n"), nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("second put status=%d body=%s", rec.Code, rec.Body.String())
	}

	// History at archive/0330C1/.history/ssr/, NOT at archive/.history/.
	wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".history", "ssr")
	if _, err := os.Stat(wanted); err != nil {
		t.Fatalf("expected history at %s; err=%v", wanted, err)
	}
	bad := filepath.Join(cfg.Root, "Project", "archive", ".history")
	if _, err := os.Stat(bad); !os.IsNotExist(err) {
		t.Errorf("history must NOT live at %s; err=%v", bad, err)
	}
}

// TestRollupCreate_AssignsRowAndComposesFilename: posting an rsk
// row via the rollup create endpoint causes the server to compute
// the filename from the body components AND auto-assign the next
// row number within the table-scope group.
func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
	cfg, _ := historyTestSetup(t)
	// Materialize the party folder (rollup create requires it).
	partyAbs := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
	if err := os.MkdirAll(partyAbs, 0o755); err != nil {
		t.Fatal(err)
	}

	// First row: full table-tracking components + the routing party
	// field. Server should pick row=001.
	body1 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0001","title":"Schedule slip"}`
	rec := doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body1)
	if rec.Code != http.StatusCreated {
		t.Fatalf("first rsk create status=%d body=%s", rec.Code, rec.Body.String())
	}
	loc := rec.Result().Header.Get("Location")
	if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-001.yaml") {
		t.Errorf("first row location=%q want ...-RSK-0001-001.yaml", loc)
	}

	// Second row in the same table: row=002.
	body2 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0001","title":"Cost overrun"}`
	rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body2)
	if rec.Code != http.StatusCreated {
		t.Fatalf("second rsk create status=%d body=%s", rec.Code, rec.Body.String())
	}
	loc = rec.Result().Header.Get("Location")
	if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-002.yaml") {
		t.Errorf("second row location=%q want ...-RSK-0001-002.yaml", loc)
	}

	// Different table-scope (sequence=0002) restarts at row=001.
	body3 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0002","title":"Risk in second register"}`
	rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body3)
	if rec.Code != http.StatusCreated {
		t.Fatalf("third rsk create status=%d body=%s", rec.Code, rec.Body.String())
	}
	loc = rec.Result().Header.Get("Location")
	if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0002-001.yaml") {
		t.Errorf("third row (new scope) location=%q want ...-RSK-0002-001.yaml", loc)
	}

	// All three files contain audit fields (proves WriteWithHistory ran).
	rskDir := filepath.Join(partyAbs, "rsk")
	ents, err := os.ReadDir(rskDir)
	if err != nil {
		t.Fatal(err)
	}
	yamlCount := 0
	for _, e := range ents {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
			continue
		}
		yamlCount++
		data, err := os.ReadFile(filepath.Join(rskDir, e.Name()))
		if err != nil {
			t.Fatal(err)
		}
		if !bytes.Contains(data, []byte("created_by: alice@example.com")) {
			t.Errorf("%s missing created_by stamp: %s", e.Name(), data)
		}
	}
	if yamlCount != 3 {
		t.Errorf("expected 3 rsk row files; got %d", yamlCount)
	}
}

// doForm is a small helper that dispatches a form POST through
// RecognizeFormRequest → ServeForm (the rollup/SSR create entry
// point). Lets the history tests share the same harness without
// pulling in the full ssrTestSetup helper.
func doForm(t *testing.T, cfg config.Config, method, target, email, body string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(method, target, bytes.NewReader([]byte(body)))
	req.Header.Set("Content-Type", "application/json")
	ctx := context.WithValue(req.Context(), EmailKey, email)
	ctx = context.WithValue(ctx, ElevatedKey, true)
	req = req.WithContext(ctx)
	rec := httptest.NewRecorder()
	formReq := RecognizeFormRequest(cfg.Root, method, target)
	if formReq == nil {
		t.Fatalf("RecognizeFormRequest returned nil for %s %s", method, target)
	}
	ServeForm(cfg, formReq, rec, req)
	return rec
}
