package db

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// WHY THIS EXISTS. `sqlc generate` is not run in this repo: the generated
// files carry hand-maintained blocks it would delete (SumDrawerByShifts in
// cashier.sql.go, the ListCashierShiftsRow change in 8e4c843). New queries are
// therefore added by hand, in db/query/*.sql for the SQL text and in the
// matching *.sql.go for the code.
//
// That makes hand-authoring a silent trap. db/query/*.sql uses `sqlc.arg(name)`
// because that is the macro sqlc reads; the file it EMITS has the macro already
// rewritten to $1..$N, and only the rewritten form is valid SQL. Copying the
// text across verbatim — the obvious thing to do — sends this to Postgres:
//
//	UPDATE customers SET name = COALESCE(NULLIF(sqlc.arg(name)::text, ''), name) ...
//
// which parses `sqlc.arg(name)` as schema-qualified `sqlc`.`arg` and fails at
// runtime with `ERROR: schema "sqlc" does not exist (SQLSTATE 3F000)`. It
// compiles, it vets clean, every unit test passes, and the route returns a bare
// 500 in production. That is exactly how it reached live once: the query was
// written correctly in db/query/partners.sql, the .sql.go const was copied
// un-substituted, and only a live PUT /customers/me surfaced it.
//
// The bug is invisible to the compiler and to unit tests that use a fake store,
// so it needs a test of its own. This one is cheap and covers all 24 generated
// files rather than only the one that was wrong.

// TestGeneratedSQLHasNoUnsubstitutedMacros fails if any *.sql.go contains a
// sqlc.arg( or sqlc.narg( call outside a comment. Comments are stripped first
// so the explanatory notes a future reader needs are not themselves flagged.
func TestGeneratedSQLHasNoUnsubstitutedMacros(t *testing.T) {
	files, err := filepath.Glob("*.sql.go")
	if err != nil {
		t.Fatal(err)
	}
	if len(files) == 0 {
		t.Fatal("no generated files matched *.sql.go — the glob is wrong, so this test is passing vacuously")
	}

	const macro = "sqlc.arg("
	for _, f := range files {
		src, err := os.ReadFile(f)
		if err != nil {
			t.Fatalf("read %s: %v", f, err)
		}
		code := stripLineComments(string(src))
		if strings.Contains(code, macro) {
			// Name the offending const rather than just the file: a file can
			// hold many queries, and the fix is per-const.
			t.Errorf("%s sends an unsubstituted sqlc.arg(... ) to Postgres.\n"+
				"  sqlc.arg is a GENERATOR macro, not SQL. sqlc rewrites it to $1..$N when it\n"+
				"  emits this file; the emitted form is the only valid SQL. A hand-written const\n"+
				"  that keeps the macro parses as schema `sqlc`.`arg` and fails at runtime with\n"+
				"  SQLSTATE 3F000 — after compiling, vetting and passing unit tests.\n"+
				"  Substitute the positional placeholders here; leave the sqlc.arg form in\n"+
				"  db/query/%s, which is what the generator reads.\n"+
				"  offending const(s): %s",
				f, querySourceFor(f), constsContaining(code, macro))
		}
	}
}

// querySourceFor maps generated file to the db/query source it came from:
// partners.sql.go -> partners.sql. Only a hint in the failure message, so a
// missing counterpart is not itself an error.
func querySourceFor(generated string) string {
	return strings.TrimSuffix(generated, ".sql.go") + ".sql"
}

// constsContaining lists the const names whose body holds needle, so a failure
// points at one query instead of making the reader grep the file.
func constsContaining(code, needle string) string {
	var hits []string
	var cur string
	for _, line := range strings.Split(code, "\n") {
		t := strings.TrimSpace(line)
		if name, ok := declName(t); ok {
			cur = name
		}
		if cur != "" && strings.Contains(line, needle) {
			hits = append(hits, cur)
		}
	}
	if len(hits) == 0 {
		return "(could not attribute — check the whole file)"
	}
	return strings.Join(dedupe(hits), ", ")
}

// declName extracts the identifier from a `const x = ...` or `var x = ...`
// declaration. It deliberately does not handle grouped `const (...)` blocks: the
// generated files declare one const per query at top level, and a partial
// answer there is better than a wrong one, since a nil result degrades to
// "(could not attribute)" rather than to a misleading name.
func declName(t string) (string, bool) {
	for _, kw := range []string{"const ", "var "} {
		if rest, ok := strings.CutPrefix(t, kw); ok {
			name, _, found := strings.Cut(rest, " ")
			if found && name != "" && name != "(" {
				return name, true
			}
		}
	}
	return "", false
}

func dedupe(in []string) []string {
	seen := map[string]bool{}
	var out []string
	for _, v := range in {
		if !seen[v] {
			seen[v] = true
			out = append(out, v)
		}
	}
	return out
}

// stripLineComments removes // line comments. Block comments are not stripped:
// none of the generated files use them inside SQL, and mishandling them would
// be a worse failure mode than leaving one in place.
func stripLineComments(src string) string {
	var b strings.Builder
	for _, line := range strings.Split(src, "\n") {
		if i := strings.Index(line, "//"); i >= 0 {
			line = line[:i]
		}
		b.WriteString(line)
		b.WriteByte('\n')
	}
	return b.String()
}
