// Package archrules — тесты-сканеры исходников для правил, которые не
// выражаются линтером: структура проекта и SQL миграций.
//
// Каждое правило здесь — бывшая строка прозаической конвенции: у него есть
// детерминированный оракул, поэтому ему место в конвейере сборки, а не в
// промпте ревью (процедура промоута — references/promote.md скилла
// av-dev-pipeline:review-pipeline).
package archrules

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

const modulePath = "git.vakhrushev.me/av/jellybit"

// repoRoot — корень репозитория относительно каталога пакета.
const repoRoot = "../.."

// Транспорты — тонкие обёртки над ядром: не знают друг о друге и никем из ядра
// не импортируются (CLAUDE.md, «Единое ядро, тонкие транспорты»).
var transports = map[string]bool{
	"internal/httpapi": true,
	"internal/tgbot":   true,
}

func TestТранспортыНеЗависятДругОтДруга(t *testing.T) {
	for pkg, imports := range internalImports(t) {
		if !transports[pkg] {
			continue
		}
		for _, imp := range imports {
			if transports[imp] && imp != pkg {
				t.Errorf("%s импортирует транспорт %s: транспорты не знают друг о друге, общая логика живёт в ядре", pkg, imp)
			}
		}
	}
}

func TestЯдроНеЗависитОтТранспортов(t *testing.T) {
	for pkg, imports := range internalImports(t) {
		if transports[pkg] || pkg == "cmd/jellybit" {
			continue
		}
		for _, imp := range imports {
			if transports[imp] {
				t.Errorf("%s импортирует транспорт %s: зависимость направлена не туда, ядро не знает о доставке", pkg, imp)
			}
		}
	}
}

// lastLegacyMigration — последняя миграция, написанная до того, как конвенция
// сложилась: 0001 заводила AUTOINCREMENT и DEFAULT datetime('now'), 0006 и 0008
// как раз уводили схему на ULID и RFC 3339 и потому упоминают старую форму.
// Миграции неизменяемы, переписывать их нельзя — правило действует на новые.
const lastLegacyMigration = 8

// docs/conventions/database.md: PK — TEXT ULID через internal/ident, время
// генерирует приложение (store.Now), а не SQLite.
func TestМиграцииБезAutoincrementИСерверногоВремени(t *testing.T) {
	forbidden := []struct {
		re  *regexp.Regexp
		why string
	}{
		{regexp.MustCompile(`(?i)autoincrement`), "PK — TEXT ULID через internal/ident, без AUTOINCREMENT"},
		{regexp.MustCompile(`(?i)default\s*\(?\s*(datetime\s*\(\s*'now'|current_timestamp)`), "время генерирует приложение через store.Now(), а не DEFAULT в схеме (fail-loud при забытой вставке)"},
	}
	dir := filepath.Join(repoRoot, "internal/store/migrations")
	entries, err := os.ReadDir(dir)
	if err != nil {
		t.Fatalf("читаю каталог миграций: %v", err)
	}
	for _, e := range entries {
		if e.IsDir() || migrationNumber(t, e.Name()) <= lastLegacyMigration {
			continue
		}
		body, err := os.ReadFile(filepath.Join(dir, e.Name()))
		if err != nil {
			t.Fatalf("читаю %s: %v", e.Name(), err)
		}
		for _, f := range forbidden {
			if loc := f.re.FindIndex(body); loc != nil {
				t.Errorf("%s: строка %d — %s", e.Name(), lineOf(body, loc[0]), f.why)
			}
		}
	}
}

// docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда
// по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг
// подстрокой — его ловим здесь.
func TestОшибкиНеМатчатсяПоТексту(t *testing.T) {
	re := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`)
	for _, path := range goFiles(t) {
		body, err := os.ReadFile(path)
		if err != nil {
			t.Fatalf("читаю %s: %v", path, err)
		}
		if loc := re.FindIndex(body); loc != nil {
			rel, _ := filepath.Rel(repoRoot, path)
			t.Errorf("%s:%d — ошибку матчим через errors.Is/errors.As, а не по тексту сообщения", rel, lineOf(body, loc[0]))
		}
	}
}

// internalImports возвращает карту «пакет репозитория → его внутренние импорты»
// (пути относительно корня модуля).
func internalImports(t *testing.T) map[string][]string {
	t.Helper()
	out := map[string][]string{}
	fset := token.NewFileSet()
	for _, path := range goFiles(t) {
		f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
		if err != nil {
			t.Fatalf("разбираю %s: %v", path, err)
		}
		rel, err := filepath.Rel(repoRoot, filepath.Dir(path))
		if err != nil {
			t.Fatalf("отношу путь %s: %v", path, err)
		}
		for _, imp := range f.Imports {
			p := strings.Trim(imp.Path.Value, `"`)
			if after, ok := strings.CutPrefix(p, modulePath+"/"); ok {
				out[rel] = append(out[rel], after)
			}
		}
	}
	return out
}

// goFiles — все нетестовые .go файлы репозитория (без tmp и вендорных каталогов).
func goFiles(t *testing.T) []string {
	t.Helper()
	var files []string
	err := filepath.WalkDir(repoRoot, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			switch d.Name() {
			case "tmp", "vendor", ".git", "node_modules":
				return filepath.SkipDir
			}
			return nil
		}
		if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
			files = append(files, path)
		}
		return nil
	})
	if err != nil {
		t.Fatalf("обхожу репозиторий: %v", err)
	}
	return files
}

// migrationNumber достаёт числовой префикс имени миграции (0009_… → 9).
func migrationNumber(t *testing.T, name string) int {
	t.Helper()
	prefix, _, ok := strings.Cut(name, "_")
	if !ok {
		t.Fatalf("имя миграции без числового префикса: %s", name)
	}
	n, err := strconv.Atoi(prefix)
	if err != nil {
		t.Fatalf("нечисловой префикс миграции %s: %v", name, err)
	}
	return n
}

func lineOf(body []byte, offset int) int {
	return 1 + strings.Count(string(body[:offset]), "\n")
}
