package auth

import (
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
)

const (
	// Stored value: Laravel Sanctum writes tokenable_type = App\Models\User,
	// matching TokenableType in internal/auth/service.go.
	userModelPath = `App\Models\User`
	// The bug: a doubled backslash inside '...' is NOT an escape in Postgres
	// (standard_conforming_strings = on), so the literal is 22 chars and the
	// predicate can never match. sqlc copies it verbatim into a Go raw string.
	doubledUserModel = `App\\Models\\User`
	doubledBackslash = `\\`
)

// TestDeleteTokensByUserTokenableTypeLiteral is the regression test for the
// sign-out-everywhere defect: db/query/users.sql used 'App\\Models\\User', so
// DELETE FROM personal_access_tokens matched zero rows and every access token
// survived a password change (internal/auth/service.go DeleteTokensByUser).
func TestDeleteTokensByUserTokenableTypeLiteral(t *testing.T) {
	consts := rawStringConsts(t, filepath.Join("..", "db", "users.sql.go"))
	sql, ok := consts["deleteTokensByUser"]
	if !ok {
		t.Fatalf("deleteTokensByUser const not found in internal/db/users.sql.go; sqlc output shape changed")
	}
	if strings.Contains(sql, doubledUserModel) {
		t.Errorf("deleteTokensByUser contains %q; must be %q or the tokenable_type predicate never matches",
			doubledUserModel, userModelPath)
	}
	if !strings.Contains(sql, userModelPath) {
		t.Errorf("deleteTokensByUser does not contain %q", userModelPath)
	}
}

// TestNoDoubledBackslashInSQLLiterals is the whole-surface net for this bug
// class, so the next occurrence is caught here rather than in production. It
// scans the db/query sources and the sqlc-generated internal/db constants.
// A doubled backslash is never meaningful in this schema: it appears only in
// polymorphic model paths such as 'App\Models\Invoice', where a single
// backslash is the correct form.
func TestNoDoubledBackslashInSQLLiterals(t *testing.T) {
	sources, err := filepath.Glob(filepath.Join("..", "..", "db", "query", "*.sql"))
	if err != nil {
		t.Fatal(err)
	}
	if len(sources) == 0 {
		t.Fatal("no db/query/*.sql matched; the glob is broken, test would pass vacuously")
	}
	generated, err := filepath.Glob(filepath.Join("..", "..", "internal", "db", "*.sql.go"))
	if err != nil {
		t.Fatal(err)
	}
	if len(generated) == 0 {
		t.Fatal("no internal/db/*.sql.go matched; the glob is broken, test would pass vacuously")
	}

	var hits []string
	singleBackslash := 0
	for _, f := range sources {
		hits = append(hits, doubledBackslashesInQuotedLiterals(t, f)...)
	}
	for _, f := range generated {
		for name, sql := range rawStringConsts(t, f) {
			if strings.Contains(sql, doubledBackslash) {
				hits = append(hits, fmt.Sprintf("%s: const %s", filepath.Base(f), name))
			}
			if strings.Contains(sql, `App\Models`) {
				singleBackslash++
			}
		}
	}
	if singleBackslash == 0 {
		t.Fatal("no single-backslash model path found in the query surface; sqlc quoting may have changed and this test would pass vacuously")
	}
	if len(hits) > 0 {
		t.Errorf("doubled backslash inside a SQL string literal (unmatchable literal, not an escape): %v", hits)
	}
}

// doubledBackslashesInQuotedLiterals reports file:line for every doubled
// backslash inside a single-quoted SQL string literal. Line and block comments
// are skipped, so commented-out SQL cannot trip the guard.
func doubledBackslashesInQuotedLiterals(t *testing.T, path string) []string {
	t.Helper()
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	src := string(b)
	var hits []string
	var inStr, inLine, inBlock bool
	line := 1
	for i := 0; i < len(src); i++ {
		c := src[i]
		if c == '\n' {
			line++
			inLine = false
			continue
		}
		if inLine {
			continue
		}
		if inBlock {
			if c == '*' && i+1 < len(src) && src[i+1] == '/' {
				inBlock = false
				i++
			}
			continue
		}
		if !inStr {
			switch {
			case c == '-' && i+1 < len(src) && src[i+1] == '-':
				inLine = true
				i++
			case c == '/' && i+1 < len(src) && src[i+1] == '*':
				inBlock = true
				i++
			case c == '\'':
				inStr = true
			}
			continue
		}
		if c == '\\' && i+1 < len(src) && src[i+1] == '\\' {
			hits = append(hits, fmt.Sprintf("%s:%d", filepath.Base(path), line))
			i++
			continue
		}
		if c == '\'' {
			inStr = false
		}
	}
	return hits
}

// rawStringConsts returns the contents of the Go raw-string (backtick) literals
// in a file, keyed by constant name. sqlc emits query SQL as raw strings, so
// this isolates the emitted SQL from Go comments and interpreted string literals
// such as `const InvoiceModel = "App\\Models\\Invoice"`, where the doubled
// backslash is a correct escape.
func rawStringConsts(t *testing.T, path string) map[string]string {
	t.Helper()
	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, path, nil, 0)
	if err != nil {
		t.Fatalf("parse %s: %v", path, err)
	}
	out := map[string]string{}
	ast.Inspect(f, func(n ast.Node) bool {
		vs, ok := n.(*ast.ValueSpec)
		if !ok {
			return true
		}
		for i, v := range vs.Values {
			lit, ok := v.(*ast.BasicLit)
			if !ok || lit.Kind != token.STRING || !strings.HasPrefix(lit.Value, "`") {
				continue
			}
			s, err := strconv.Unquote(lit.Value)
			if err != nil {
				t.Fatalf("unquote %s in %s: %v", lit.Value, path, err)
			}
			name := fmt.Sprintf("line %d", fset.Position(lit.Pos()).Line)
			if i < len(vs.Names) {
				name = vs.Names[i].Name
			}
			out[name] = s
		}
		return true
	})
	return out
}
