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"
)

// planReviewSetup writes a tree shaped like a real ZDDC project with
// `archive/Acme/received/Acme-0042/` populated and an admin grant for
// alice@example.com. Returns the cfg, a do() helper that POSTs Plan
// Review requests, and the root path.
func planReviewSetup(t *testing.T) (config.Config, func(target, email string, body []byte) *httptest.ResponseRecorder, string) {
	t.Helper()
	root := t.TempDir()

	// Root .zddc grants alice subtree-admin everywhere AND sets the
	// document_controller role so the cascade's reviewing/+staging/
	// admin grants resolve to her. The role membership also confers
	// `c` authority on received/ via the WORM list in the defaults,
	// which Plan Review's pre-flight requires.
	mustWriteHelper(t, filepath.Join(root, ".zddc"),
		"admins:\n  - alice@example.com\n"+
			"roles:\n  document_controller:\n    members: [alice@example.com]\n")

	for _, d := range []string{"Project-1/archive/Acme/received/Acme-0042"} {
		if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", d, err)
		}
	}
	// Seed a ZDDC-parseable file so the title derives correctly.
	mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Foundation.pdf"),
		"%PDF-")
	zddc.InvalidateCache(root)

	cfg := config.Config{
		Root:          root,
		EmailHeader:   "X-Auth-Request-Email",
		MaxWriteBytes: 64 * 1024,
	}

	do := func(target, email string, body []byte) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
		req.Header.Set(headerOp, opPlanReview)
		req.Header.Set("Content-Type", "application/yaml")
		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, root
}

func mustWriteHelper(t *testing.T, path, body string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("mkdir parent of %s: %v", path, err)
	}
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatalf("write %s: %v", path, err)
	}
}

func planReviewBody() string {
	return strings.Join([]string{
		"review_lead: bob@vendor.com",
		"approver: carol@example.com",
		"plan_review_complete_date: 2026-05-30",
		"plan_response_date: 2026-06-15",
	}, "\n") + "\n"
}

// TestPlanReview_FreshConvergence runs Plan Review against a tree with
// no existing workflow folders. Expects both reviewing/ and staging/
// to be created, each with a .zddc declaring received_path +
// planned_date, and the response to confirm both were created.
func TestPlanReview_FreshConvergence(t *testing.T) {
	cfg, do, root := planReviewSetup(t)

	rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
		[]byte(planReviewBody()))
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	var resp planReviewResponse
	if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
	}
	if resp.Tracking != "Acme-0042" {
		t.Errorf("Tracking=%q, want Acme-0042", resp.Tracking)
	}
	if !resp.Reviewing.Created || !resp.Reviewing.ZddcWritten {
		t.Errorf("Reviewing not fully converged: %+v", resp.Reviewing)
	}
	if !resp.Staging.Created || !resp.Staging.ZddcWritten {
		t.Errorf("Staging not fully converged: %+v", resp.Staging)
	}

	// Workflow folders: should carry received_path + ACL only.
	for _, side := range []struct {
		path     string
		wantDate string
		actor    string
	}{
		{resp.Reviewing.Path, "2026-05-30", "bob@vendor.com"},
		{resp.Staging.Path, "2026-06-15", "carol@example.com"},
	} {
		abs := filepath.Join(root, filepath.FromSlash(strings.Trim(side.path, "/")))
		base := filepath.Base(abs)
		if !strings.HasPrefix(base, side.wantDate) {
			t.Errorf("folder %q does not start with date %q", base, side.wantDate)
		}
		zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
		if err != nil {
			t.Fatalf("parse %s/.zddc: %v", abs, err)
		}
		if zf.ReceivedPath != "archive/Acme/received/Acme-0042" {
			t.Errorf("%s: received_path=%q", abs, zf.ReceivedPath)
		}
		// Workflow .zddc must NOT carry planned dates — those live in
		// the canonical received/.zddc and are sealed.
		if zf.PlannedReviewDate != "" || zf.PlannedResponseDate != "" {
			t.Errorf("%s: workflow .zddc must not carry planned dates", abs)
		}
		if v, ok := zf.ACL.Permissions[side.actor]; !ok || v != "rwcda" {
			t.Errorf("%s: ACL[%s]=%q, want rwcda", abs, side.actor, v)
		}
	}

	// Canonical received/.zddc: planned dates are sealed here.
	zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc"))
	if err != nil {
		t.Fatalf("parse received .zddc: %v", err)
	}
	if zfRecv.PlannedReviewDate != "2026-05-30" {
		t.Errorf("received planned_review_date=%q", zfRecv.PlannedReviewDate)
	}
	if zfRecv.PlannedResponseDate != "2026-06-15" {
		t.Errorf("received planned_response_date=%q", zfRecv.PlannedResponseDate)
	}
	// Constrained schema: no ACL, no admins, no roles, no received_path.
	if len(zfRecv.ACL.Permissions) != 0 || len(zfRecv.Admins) != 0 ||
		len(zfRecv.Roles) != 0 || zfRecv.ReceivedPath != "" {
		t.Errorf("received .zddc has unexpected content: acl=%v admins=%v roles=%v rp=%q",
			zfRecv.ACL.Permissions, zfRecv.Admins, zfRecv.Roles, zfRecv.ReceivedPath)
	}
	if resp.Title != "Foundation" {
		t.Errorf("Title=%q, want Foundation (from received file)", resp.Title)
	}
	_ = cfg
}

// TestPlanReview_Idempotent runs Plan Review twice with the same body;
// the second run is a no-op (created=false everywhere) and folder/.zddc
// state is unchanged.
func TestPlanReview_Idempotent(t *testing.T) {
	_, do, root := planReviewSetup(t)

	first := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
		[]byte(planReviewBody()))
	if first.Code != http.StatusOK {
		t.Fatalf("first status=%d; body=%s", first.Code, first.Body.String())
	}
	var firstResp planReviewResponse
	if err := json.Unmarshal(first.Body.Bytes(), &firstResp); err != nil {
		t.Fatalf("decode first: %v", err)
	}

	second := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
		[]byte(planReviewBody()))
	if second.Code != http.StatusOK {
		t.Fatalf("second status=%d; body=%s", second.Code, second.Body.String())
	}
	var secondResp planReviewResponse
	if err := json.Unmarshal(second.Body.Bytes(), &secondResp); err != nil {
		t.Fatalf("decode second: %v", err)
	}

	if secondResp.Reviewing.Created || secondResp.Staging.Created {
		t.Errorf("second run created=true: %+v", secondResp)
	}
	if firstResp.Reviewing.Path != secondResp.Reviewing.Path {
		t.Errorf("reviewing path drifted: %q vs %q",
			firstResp.Reviewing.Path, secondResp.Reviewing.Path)
	}
	if firstResp.Staging.Path != secondResp.Staging.Path {
		t.Errorf("staging path drifted: %q vs %q",
			firstResp.Staging.Path, secondResp.Staging.Path)
	}

	// Confirm no duplicate folders snuck in.
	reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
	entries, err := os.ReadDir(reviewingRoot)
	if err != nil {
		t.Fatalf("read %s: %v", reviewingRoot, err)
	}
	if len(entries) != 1 {
		t.Errorf("reviewing/ has %d entries, want 1", len(entries))
	}
}

// TestPlanReview_ReceivedZddcIsWriteOnce — re-running Plan Review with
// different planned dates leaves received/.zddc alone (sealed at first
// run). Workflow folder ACLs can still be re-converged on subsequent
// runs.
func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) {
	_, do, root := planReviewSetup(t)

	if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
		[]byte(planReviewBody())); rec.Code != http.StatusOK {
		t.Fatalf("first POST status=%d; body=%s", rec.Code, rec.Body.String())
	}

	// Second run with a different review_lead AND a different planned
	// date. The workflow .zddc should reflect the new actor, but the
	// canonical received/.zddc must keep its original dates.
	updated := strings.Join([]string{
		"review_lead: dave@vendor.com",
		"approver: carol@example.com",
		"plan_review_complete_date: 2099-01-01", // attempted but should be ignored
		"plan_response_date: 2099-01-15",
	}, "\n") + "\n"
	if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
		[]byte(updated)); rec.Code != http.StatusOK {
		t.Fatalf("second POST status=%d; body=%s", rec.Code, rec.Body.String())
	}

	// received/.zddc unchanged.
	zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc"))
	if err != nil {
		t.Fatalf("parse received: %v", err)
	}
	if zfRecv.PlannedReviewDate != "2026-05-30" || zfRecv.PlannedResponseDate != "2026-06-15" {
		t.Errorf("received dates drifted: review=%q response=%q",
			zfRecv.PlannedReviewDate, zfRecv.PlannedResponseDate)
	}

	// reviewing/.zddc reflects the new review_lead.
	reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
	entries, err := os.ReadDir(reviewingRoot)
	if err != nil {
		t.Fatalf("read %s: %v", reviewingRoot, err)
	}
	if len(entries) != 1 {
		t.Fatalf("expected 1 reviewing folder, got %d", len(entries))
	}
	zf, err := zddc.ParseFile(filepath.Join(reviewingRoot, entries[0].Name(), ".zddc"))
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if _, ok := zf.ACL.Permissions["dave@vendor.com"]; !ok {
		t.Errorf("reviewing ACL did not switch to dave: %v", zf.ACL.Permissions)
	}
}

// TestPlanReview_Forbidden — a user without admin authority on the
// workflow roots gets 403 and no folders are created.
func TestPlanReview_Forbidden(t *testing.T) {
	_, do, root := planReviewSetup(t)

	rec := do("/Project-1/archive/Acme/received/Acme-0042/", "stranger@vendor.com",
		[]byte(planReviewBody()))
	if rec.Code != http.StatusForbidden {
		t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Project-1", "reviewing")); err == nil {
		// reviewing/ should not have been materialised. The mkdir
		// happens AFTER the ACL check in the handler, so refusal
		// guarantees no state change.
		entries, _ := os.ReadDir(filepath.Join(root, "Project-1", "reviewing"))
		if len(entries) > 0 {
			t.Errorf("reviewing/ created despite 403: %d entries", len(entries))
		}
	}
}

// TestCommentResolvedName — counter scope is per-target, plain target
// gets +C1, subsequent targets get sequential +C2/+C3.
func TestCommentResolvedName(t *testing.T) {
	root := t.TempDir()

	resolved, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf")
	if err != nil {
		t.Fatalf("first: %v", err)
	}
	if resolved != "Acme-0042_A+C1 (RFI) - Foundation.pdf" {
		t.Errorf("first=%q, want +C1", resolved)
	}

	// Seed a +C1 file; next should be +C2.
	if err := os.WriteFile(filepath.Join(root, resolved), []byte("x"), 0o644); err != nil {
		t.Fatalf("seed: %v", err)
	}
	resolved2, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf")
	if err != nil {
		t.Fatalf("second: %v", err)
	}
	if resolved2 != "Acme-0042_A+C2 (RFI) - Foundation.pdf" {
		t.Errorf("second=%q, want +C2", resolved2)
	}

	// Different target → independent counter at +C1.
	resolvedB, err := zddc.CommentResolvedName(root, "Acme-0042_B (RFI) - Foundation-Spec.pdf")
	if err != nil {
		t.Fatalf("B: %v", err)
	}
	if resolvedB != "Acme-0042_B+C1 (RFI) - Foundation-Spec.pdf" {
		t.Errorf("B=%q, want +C1", resolvedB)
	}
}
