package handler

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

	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)

const sampleTableSpec = `title: Master Deliverables List
description: Sample MDL.
columns:
  - field: id
    title: ID
    width: 6em
  - field: title
    title: Deliverable
  - field: status
    title: Status
    enum: [pending, submitted, accepted]
defaults:
  sort:
    - { field: id, dir: asc }
`

const sampleRowFormSpec = `title: Deliverable
schema:
  type: object
  required: [id, title]
  additionalProperties: false
  properties:
    id:
      type: string
    title:
      type: string
    status:
      type: string
      enum: [pending, submitted, accepted]
`

// tableTestSetup writes a directory tree under a temp root with the
// in-dir layout:
//
//	<root>/Working/MDL/table.yaml       → spec
//	<root>/Working/MDL/form.yaml        → row schema
//	<root>/Working/MDL/<file>.yaml      → row data (one per entry in rows)
//
// Optional extra .zddc files at relative paths can be supplied via zddcFiles.
// Returns (config, do) where do dispatches a request through ServeTable via
// the same recognize → serve path the production catch-all uses.
//
// Note: under the client-side rendering architecture the handler does not
// parse the spec or list row files — the rows/spec on disk are written
// only because the ACL cascade may evaluate paths under them.
func tableTestSetup(t *testing.T, rows map[string]string, zddcFiles map[string]string) (config.Config, func(method, target, email string) *httptest.ResponseRecorder) {
	t.Helper()
	root := t.TempDir()

	mdlDir := filepath.Join(root, "Working", "MDL")
	if err := os.MkdirAll(mdlDir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
		t.Fatalf("write spec: %v", err)
	}
	if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
		t.Fatalf("write form spec: %v", err)
	}
	for name, body := range rows {
		if err := os.WriteFile(filepath.Join(mdlDir, name), []byte(body), 0o644); err != nil {
			t.Fatalf("write row %s: %v", name, err)
		}
	}
	if _, ok := zddcFiles["Working"]; !ok {
		if zddcFiles == nil {
			zddcFiles = make(map[string]string)
		}
		zddcFiles["Working"] = `acl:
  permissions:
    "*@example.com": rwcd
`
	}
	for rel, body := range zddcFiles {
		dir := filepath.Join(root, rel)
		if err := os.MkdirAll(dir, 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", dir, err)
		}
		zddc.InvalidateCache(dir)
		if body == "" {
			continue
		}
		if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
			t.Fatalf("write .zddc: %v", err)
		}
	}
	cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}

	do := func(method, target, email string) *httptest.ResponseRecorder {
		req := httptest.NewRequest(method, target, bytes.NewReader(nil))
		ctx := context.WithValue(req.Context(), EmailKey, email)
		req = req.WithContext(ctx)
		rec := httptest.NewRecorder()

		tableReq := RecognizeTableRequest(cfg.Root, method, target)
		if tableReq == nil {
			rec.WriteHeader(http.StatusNotFound)
			return rec
		}
		ServeTable(cfg, tableReq, rec, req)
		return rec
	}
	return cfg, do
}

func TestRecognizeTableRequest(t *testing.T) {
	root := t.TempDir()
	mdlDir := filepath.Join(root, "Working", "MDL")
	if err := os.MkdirAll(mdlDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
		t.Fatal(err)
	}
	zddc.InvalidateCache(mdlDir)

	cases := []struct {
		method, url string
		wantNil     bool
		wantSpec    string
		wantName    string
	}{
		{"GET", "/Working/MDL/table.html", false, "Working/MDL/table.yaml", "MDL"},
		// Same URL but POST → tables are read-only at the URL level.
		{"POST", "/Working/MDL/table.html", true, "", ""},
		{"PUT", "/Working/MDL/table.html", true, "", ""},
		{"DELETE", "/Working/MDL/table.html", true, "", ""},
		// No table.yaml in this dir → not a table request.
		{"GET", "/Working/Other/table.html", true, "", ""},
		// No table.yaml anywhere → not a table request.
		{"GET", "/Other/MDL/table.html", true, "", ""},
		// Random .html → falls through.
		{"GET", "/index.html", true, "", ""},
		// /form.html in the same dir is form territory, not a table.
		{"GET", "/Working/MDL/form.html", true, "", ""},
		// Path traversal attempt.
		{"GET", "/../etc/passwd/table.html", true, "", ""},
	}

	for _, tc := range cases {
		t.Run(tc.method+" "+tc.url, func(t *testing.T) {
			got := RecognizeTableRequest(root, tc.method, tc.url)
			if tc.wantNil {
				if got != nil {
					t.Errorf("got %+v, want nil", got)
				}
				return
			}
			if got == nil {
				t.Fatalf("got nil, want a TableRequest")
			}
			if got.Name != tc.wantName {
				t.Errorf("Name = %q want %q", got.Name, tc.wantName)
			}
			wantSpec := filepath.Join(root, tc.wantSpec)
			if got.SpecPath != wantSpec {
				t.Errorf("SpecPath = %q want %q", got.SpecPath, wantSpec)
			}
		})
	}
}

// TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the
// embedded tables.html bytes verbatim, with the empty inline context
// placeholder intact (so the client knows to walk the directory).
func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
	rows := map[string]string{
		"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
	}
	_, do := tableTestSetup(t, rows, nil)
	rec := do(http.MethodGet, "/Working/MDL/table.html", "casey@example.com")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
	}
	if ct := rec.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
		t.Errorf("Content-Type = %q want text/html…", ct)
	}
	body := rec.Body.String()
	if !strings.Contains(body, `<table id="table-root"`) {
		t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
	}
	if !strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
		t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk")
	}
}

func TestServeTable_ACLForbidden(t *testing.T) {
	zddcs := map[string]string{
		"Working": `acl:
  permissions:
    "root@example.com": rwcd
tables:
  MDL: ./MDL.table.yaml
`,
	}
	_, do := tableTestSetup(t, map[string]string{"D.yaml": "id: D\n"}, zddcs)
	rec := do(http.MethodGet, "/Working/MDL/table.html", "stranger@example.com")
	if rec.Code != http.StatusForbidden {
		t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
	}
}

// --- default MDL spec fallback ---------------------------------------------

// archivePartyTestSetup builds a minimal Project/archive/<party>/ tree
// with no operator-supplied tables: declaration. RecognizeTableRequest
// should still fire for "mdl" thanks to the default-spec fallback.
func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(method, target, email string) *httptest.ResponseRecorder) {
	t.Helper()
	root := t.TempDir()

	if err := os.WriteFile(filepath.Join(root, ".zddc"),
		[]byte("acl:\n  permissions:\n    \"*@example.com\": rwcda\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	partyDir := filepath.Join(root, "Project", "archive", "Acme")
	if err := os.MkdirAll(partyDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if partyZddcExtras != "" {
		if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), []byte(partyZddcExtras), 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) *httptest.ResponseRecorder {
		req := httptest.NewRequest(method, target, bytes.NewReader(nil))
		ctx := context.WithValue(req.Context(), EmailKey, email)
		req = req.WithContext(ctx)

		tr := RecognizeTableRequest(cfg.Root, method, target)
		rec := httptest.NewRecorder()
		if tr == nil {
			rec.WriteHeader(http.StatusNotFound)
			return rec
		}
		ServeTable(cfg, tr, rec, req)
		return rec
	}
	return root, do
}

func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
	_, do := archivePartyTestSetup(t, "")

	rec := do(http.MethodGet, "/Project/archive/Acme/mdl/table.html", "alice@example.com")
	if rec.Code != http.StatusOK {
		t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, "<html") {
		t.Errorf("expected tables.html bytes, got %q…", body[:min(80, len(body))])
	}
}

func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
	// Default fallback is scoped to <project>/archive/<party>/. A
	// request at a deeper path (e.g. archive/Acme/mdl/sub/) or a
	// non-archive path should return nil (no recognition).
	_, do := archivePartyTestSetup(t, "")

	rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl/table.html", "alice@example.com")
	if rec.Code != http.StatusNotFound {
		t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code)
	}
	rec = do(http.MethodGet, "/Project/working/mdl/table.html", "alice@example.com")
	if rec.Code != http.StatusNotFound {
		t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code)
	}
}

func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
	root := t.TempDir()
	// archive/Acme/ exists but no mdl/table.yaml on disk.
	if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
		t.Fatal(err)
	}

	bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml")
	if !ok {
		t.Fatalf("expected fallback to fire")
	}
	if !strings.Contains(string(bts), "Master Deliverables List") {
		t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
	}

	bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/form.yaml")
	if !ok {
		t.Fatalf("expected form fallback to fire")
	}
	if !strings.Contains(string(bts), "Deliverable") {
		t.Errorf("default form spec missing expected title")
	}
}

func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
	root := t.TempDir()
	mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl")
	if err := os.MkdirAll(mdlDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok {
		t.Errorf("operator file should win over embedded fallback")
	}
}

func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
	root := t.TempDir()
	cases := []string{
		"/Project/working/mdl/table.yaml",
		"/Project/archive/mdl/table.yaml",     // depth 3 — no party segment
		"/Project/archive/Acme/sub/mdl/table.yaml",
	}
	for _, p := range cases {
		if _, ok := IsDefaultMdlSpec(root, p); ok {
			t.Errorf("path %q should NOT trigger default fallback", p)
		}
	}
}

