package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"
)

type Case struct{ Name, Method, Path, Body string }

func cases() []Case {
	return []Case{
		{"posts", "GET", "/api/posts", ""},
		{"search_case", "GET", "/api/posts/search?q=python", ""},
		{"search_empty", "GET", "/api/posts/search?q=", ""},
		{"search_none", "GET", "/api/posts/search?q=notfound", ""},
		{"search_percent", "GET", "/api/posts/search?q=%25", ""},
		{"search_underscore", "GET", "/api/posts/search?q=_", ""},
		{"search_unicode", "GET", "/api/posts/search?q=caf%C3%A9", ""},
		{"search_missing", "GET", "/api/posts/search", ""},
		{"search_repeated", "GET", "/api/posts/search?q=python&q=notfound", ""},
		{"tag", "GET", "/api/posts/by-tag/python", ""},
		{"tag_empty", "GET", "/api/posts/by-tag/empty", ""},
		{"tag_missing", "GET", "/api/posts/by-tag/missing", ""},
		{"detail", "GET", "/api/posts/1", ""},
		{"detail_draft", "GET", "/api/posts/2", ""},
		{"detail_missing", "GET", "/api/posts/999", ""},
		{"detail_invalid", "GET", "/api/posts/nope", ""},
		{"detail_negative", "GET", "/api/posts/-1", ""},
		{"detail_plus", "GET", "/api/posts/+1", ""},
		{"detail_float", "GET", "/api/posts/1.0", ""},
		{"user", "GET", "/api/users/1", ""},
		{"user_missing", "GET", "/api/users/999", ""},
		{"user_invalid", "GET", "/api/users/nope", ""},
		{"email", "GET", "/api/users/find?email=alice%40example.com", ""},
		{"email_case", "GET", "/api/users/find?email=ALICE%40example.com", ""},
		{"email_missing_arg", "GET", "/api/users/find", ""},
		{"email_duplicate", "GET", "/api/users/find?email=duplicate%40example.com", ""},
		{"create", "POST", "/api/posts", `{"author_id":1,"title":"New café","body":"Text","tag_slugs":["python","django"]}`},
		{"create_defaults", "POST", "/api/posts", `{"author_id":1,"title":"","body":""}`},
		{"create_coercion", "POST", "/api/posts", `{"author_id":"1","title":"Coercion","body":"Text","extra":123}`},
		{"create_bool", "POST", "/api/posts", `{"author_id":true,"title":"Boolean","body":"Text"}`},
		{"create_float_id", "POST", "/api/posts", `{"author_id":1.5,"title":"Float","body":"Text"}`},
		{"create_missing_author", "POST", "/api/posts", `{"author_id":999,"title":"New","body":"Text"}`},
		{"create_duplicate_tags", "POST", "/api/posts", `{"author_id":1,"title":"New","body":"Text","tag_slugs":["python","python"]}`},
		{"create_unknown_tag", "POST", "/api/posts", `{"author_id":1,"title":"Partial","body":"Text","tag_slugs":["python","missing"]}`},
		{"create_required", "POST", "/api/posts", `{}`},
		{"create_null", "POST", "/api/posts", `null`},
		{"create_array", "POST", "/api/posts", `[]`},
		{"create_null_fields", "POST", "/api/posts", `{"author_id":null,"title":null,"body":null,"tag_slugs":null}`},
		{"create_types", "POST", "/api/posts", `{"author_id":"abc","title":42,"body":true,"tag_slugs":[5]}`},
		{"create_tag_type", "POST", "/api/posts", `{"author_id":1,"title":"X","body":"Y","tag_slugs":"python"}`},
		{"create_bad_json", "POST", "/api/posts", `{`},
		{"create_empty_body", "POST", "/api/posts", ``},
		{"create_long_title", "POST", "/api/posts", `{"author_id":1,"title":"` + strings.Repeat("x", 256) + `","body":"Y"}`},
		{"comment", "POST", "/api/posts/1/comments", `{"author_id":2,"body":"Hello café"}`},
		{"comment_draft", "POST", "/api/posts/2/comments", `{"author_id":1,"body":"Draft"}`},
		{"comment_post_missing", "POST", "/api/posts/999/comments", `{"author_id":1,"body":"X"}`},
		{"comment_author_missing", "POST", "/api/posts/1/comments", `{"author_id":999,"body":"X"}`},
		{"comment_both_missing", "POST", "/api/posts/999/comments", `{"author_id":999,"body":"X"}`},
		{"comment_required", "POST", "/api/posts/1/comments", `{}`},
		{"comment_invalid_path", "POST", "/api/posts/nope/comments", `{"author_id":1,"body":"X"}`},
		{"method", "DELETE", "/api/posts", ""},
		{"head", "HEAD", "/api/posts", ""},
		{"options", "OPTIONS", "/api/posts", ""},
		{"user_huge", "GET", "/api/users/9223372036854775808", ""},
		{"detail_underscore_id", "GET", "/api/posts/0_1", ""},
		{"create_huge_id", "POST", "/api/posts", `{"author_id":9223372036854775808,"title":"Huge","body":"X"}`},
		{"create_integral_float", "POST", "/api/posts", `{"author_id":1.0,"title":"Float","body":"X"}`},
		{"create_integral_string", "POST", "/api/posts", `{"author_id":"1.0","title":"Float","body":"X"}`},
		{"create_scalar", "POST", "/api/posts", `42`},
		{"create_string_payload", "POST", "/api/posts", `"hello"`},
		{"json_missing_comma", "POST", "/api/posts", `{"title":"a" "body":"b"}`},
		{"json_missing_colon", "POST", "/api/posts", `{"title" "a"}`},
		{"json_unterminated", "POST", "/api/posts", `{"title":"abc`},
		{"json_trailing", "POST", "/api/posts", `{} garbage`},
		{"json_bad_value", "POST", "/api/posts", `{"title":oops}`},
		{"json_unicode_offset", "POST", "/api/posts", `{"title":"café" "body":"x"}`},
	}
}

type Observation struct {
	Case    Case              `json:"case"`
	Status  int               `json:"status"`
	Headers map[string]string `json:"headers"`
	Body    any               `json:"body"`
	State   map[string]any    `json:"state"`
}

func decode(b []byte) (any, error) {
	var v any
	d := json.NewDecoder(bytes.NewReader(b))
	d.UseNumber()
	err := d.Decode(&v)
	return v, err
}

var pathPattern = regexp.MustCompile(`File "[^"]*/(\.venv/[^\"]+|blog/[^\"]+|core/[^\"]+|adapters/[^\"]+)"`)

func normalizeText(s string) string { return pathPattern.ReplaceAllString(s, `File "<repo>/$1"`) }

// Normalize only named fields. Verify generated clocks rather than deleting them.
func normalize(v any, start, end time.Time) (any, error) {
	switch x := v.(type) {
	case map[string]any:
		for k, val := range x {
			if k == "created_at" || k == "updated_at" {
				if s, ok := val.(string); ok {
					t, err := time.Parse(time.RFC3339Nano, s)
					if err != nil {
						return nil, fmt.Errorf("invalid %s: %w", k, err)
					}
					if t.UTC().Year() < 2021 || t.UTC().Year() > 2024 {
						if t.Before(start.Add(-time.Second)) || t.After(end.Add(time.Second)) {
							return nil, fmt.Errorf("%s outside request window: %s", k, s)
						}
						x[k] = "<request-time>"
					} else {
						x[k] = t.UTC().Format(time.RFC3339Nano)
					}
					continue
				}
			}
			n, err := normalize(val, start, end)
			if err != nil {
				return nil, err
			}
			x[k] = n
		}
		if tags, ok := x["tags"].([]any); ok {
			sort.Slice(tags, func(i, j int) bool { return canonical(tags[i]) < canonical(tags[j]) })
		}
		return x, nil
	case []any:
		for i, val := range x {
			n, err := normalize(val, start, end)
			if err != nil {
				return nil, err
			}
			x[i] = n
		}
		// Only reorder contiguous equal-date groups, never the full ordered array.
		for i := 0; i < len(x); {
			m, ok := x[i].(map[string]any)
			if !ok {
				break
			}
			date, ok := m["created_at"].(string)
			if !ok {
				break
			}
			j := i + 1
			for j < len(x) {
				n, ok := x[j].(map[string]any)
				if !ok || n["created_at"] != date {
					break
				}
				j++
			}
			sort.Slice(x[i:j], func(a, b int) bool { return canonical(x[i+a]) < canonical(x[i+b]) })
			i = j
		}
		return x, nil
	case string:
		return x, nil
	}
	return v, nil
}
func canonical(v any) string { b, _ := json.Marshal(v); return string(b) }

func runContract(ctx context.Context, dsn, url, dir string, record bool) error {
	c, err := openDB(ctx, dsn)
	if err != nil {
		return err
	}
	defer c.Close(ctx)
	client := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	if record {
		if err = os.MkdirAll(dir, 0755); err != nil {
			return err
		}
	}
	failures := 0
	for _, tc := range cases() {
		if err = fixture(ctx, c); err != nil {
			return err
		}
		start := time.Now()
		req, err := http.NewRequestWithContext(ctx, tc.Method, url+tc.Path, strings.NewReader(tc.Body))
		if err != nil {
			return err
		}
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("X-Harness-Request", tc.Name)
		resp, err := client.Do(req)
		if err != nil {
			return fmt.Errorf("%s: %w", tc.Name, err)
		}
		raw, err := io.ReadAll(resp.Body)
		resp.Body.Close()
		if err != nil {
			return err
		}
		end := time.Now()
		body, err := decode(raw)
		if err != nil {
			body = string(raw)
			if resp.StatusCode >= 500 && strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") {
				body = normalizeText(string(raw))
			}
		}
		// Persisted post detail clocks must be exactly the values returned over HTTP.
		if m, ok := body.(map[string]any); ok && resp.StatusCode == 200 && m["updated_at"] != nil {
			var updated time.Time
			var views int
			err = c.QueryRow(ctx, "SELECT updated_at,view_count FROM blog_post WHERE id=$1", m["id"].(json.Number).String()).Scan(&updated, &views)
			if err != nil {
				return err
			}
			parsed, e := time.Parse(time.RFC3339Nano, m["updated_at"].(string))
			// DjangoJSONEncoder truncates datetime microseconds to milliseconds.
			if e != nil || !parsed.Equal(updated.Truncate(time.Millisecond)) || fmt.Sprint(m["view_count"]) != fmt.Sprint(views) {
				return fmt.Errorf("%s: response/database state mismatch", tc.Name)
			}
		}
		st, err := state(ctx, c)
		if err != nil {
			return err
		}
		body, err = normalize(body, start, end)
		if err != nil {
			return fmt.Errorf("%s body: %w", tc.Name, err)
		}
		nst, err := normalize(st, start, end)
		if err != nil {
			return fmt.Errorf("%s state: %w", tc.Name, err)
		}
		headers := map[string]string{}
		for _, h := range []string{"Content-Type", "Allow", "Location", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy", "Cross-Origin-Opener-Policy", "Vary"} {
			if v := resp.Header.Get(h); v != "" {
				headers[h] = v
			}
		}
		obs := Observation{tc, resp.StatusCode, headers, body, nst.(map[string]any)}
		path := filepath.Join(dir, tc.Name+".json")
		if record {
			b, _ := json.MarshalIndent(obs, "", "  ")
			if err = os.WriteFile(path, append(b, '\n'), 0644); err != nil {
				return err
			}
			fmt.Printf("captured %-25s %d\n", tc.Name, resp.StatusCode)
		} else {
			raw, err = os.ReadFile(path)
			if err != nil {
				return err
			}
			var expected Observation
			decoder := json.NewDecoder(bytes.NewReader(raw))
			decoder.UseNumber()
			if err = decoder.Decode(&expected); err != nil {
				return err
			}
			// Allow is an HTTP token set; Django constructs it from a Python set.
			for _, headers := range []map[string]string{obs.Headers, expected.Headers} {
				if value, ok := headers["Allow"]; ok {
					tokens := strings.Split(value, ",")
					for i := range tokens {
						tokens[i] = strings.TrimSpace(tokens[i])
					}
					sort.Strings(tokens)
					headers["Allow"] = strings.Join(tokens, ", ")
				}
			}
			if canonical(obs) != canonical(expected) {
				failures++
				fmt.Printf("FAIL %s\n", tc.Name)
				if err = os.MkdirAll(".artifacts/mismatches", 0755); err != nil {
					return err
				}
				b, _ := json.MarshalIndent(obs, "", "  ")
				os.WriteFile(filepath.Join(".artifacts/mismatches", tc.Name+".json"), b, 0644)
			} else {
				fmt.Printf("PASS %s\n", tc.Name)
			}
		}
	}
	if failures > 0 {
		return fmt.Errorf("%d/%d contract cases failed", failures, len(cases()))
	}
	fmt.Printf("%d contract cases complete (%s)\n", len(cases()), fixtureVersion)
	return nil
}
