package integration

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gopkg.in/yaml.v3"
)

// TestKMPModelSchemaContract validates that KMP @Serializable model classes match
// the OpenAPI spec schemas field-by-field. It checks:
//   - Every spec field has a matching KMP property (via @SerialName or property name)
//   - Types are compatible (spec string→String, integer→Int, number→Double, etc.)
//   - Nullability is compatible (spec nullable:true → Kotlin Type?)
//
// This catches schema drift when the Go API evolves the spec but KMP models aren't updated.
func TestKMPModelSchemaContract(t *testing.T) {
	specSchemas := loadSpecSchemas(t)
	kmpModels := loadKMPModels(t)

	require.NotEmpty(t, specSchemas, "should parse schemas from openapi.yaml")
	require.NotEmpty(t, kmpModels, "should parse @Serializable classes from KMP models")

	t.Logf("Spec schemas: %d, KMP models: %d", len(specSchemas), len(kmpModels))

	// -------------------------------------------------------------------
	// Direction 1: spec → KMP — every mapped spec schema field should exist in KMP
	// -------------------------------------------------------------------
	t.Run("spec fields exist in KMP models", func(t *testing.T) {
		for specName, mapping := range schemaToKMPClass {
			schema, ok := specSchemas[specName]
			if !ok {
				t.Errorf("spec schema %q not found in openapi.yaml", specName)
				continue
			}

			kmpClass, ok := kmpModels[mapping.kmpClassName]
			if !ok {
				t.Errorf("KMP class %q (mapped from spec %q) not found in model files", mapping.kmpClassName, specName)
				continue
			}

			t.Run(specName+"→"+mapping.kmpClassName, func(t *testing.T) {
				// Build KMP field index by JSON name
				kmpFieldsByJSON := make(map[string]kmpField)
				for _, f := range kmpClass.fields {
					kmpFieldsByJSON[f.jsonName] = f
				}

				for fieldName, specField := range schema.properties {
					overrideKey := specName + "." + fieldName

					// Skip fields known to be absent from KMP
					if _, ok := knownMissingFromKMP[overrideKey]; ok {
						continue
					}

					kf, found := kmpFieldsByJSON[fieldName]
					if !found {
						t.Errorf("spec field %q not found in KMP class %s", fieldName, mapping.kmpClassName)
						continue
					}

					// Type check (unless overridden)
					if _, ok := knownTypeOverrides[overrideKey]; !ok {
						expectedKotlin := mapSpecTypeToKotlin(specField)
						actualKotlin := normalizeKotlinType(kf.kotlinType)

						if !typesCompatible(expectedKotlin, actualKotlin) {
							t.Errorf("type mismatch: %s.%s — spec %s(%s) → expected Kotlin %q, got %q",
								mapping.kmpClassName, fieldName,
								specField.typeName, specField.format,
								expectedKotlin, actualKotlin)
						}
					}

					// Nullability: if spec says nullable, KMP must allow it
					if specField.nullable && !kf.nullable && !specField.isRef && !specField.isArray {
						if _, ok := knownTypeOverrides[overrideKey]; !ok {
							t.Errorf("nullability mismatch: %s.%s — spec says nullable, KMP type %s is non-nullable",
								mapping.kmpClassName, fieldName, kf.kotlinType)
						}
					}
				}
			})
		}
	})

	// -------------------------------------------------------------------
	// Direction 2: KMP → spec — KMP fields should exist in spec (or be documented)
	// -------------------------------------------------------------------
	t.Run("KMP fields exist in spec", func(t *testing.T) {
		for specName, mapping := range schemaToKMPClass {
			schema, ok := specSchemas[specName]
			if !ok {
				continue
			}

			kmpClass, ok := kmpModels[mapping.kmpClassName]
			if !ok {
				continue
			}

			t.Run(mapping.kmpClassName+"→"+specName, func(t *testing.T) {
				for _, kf := range kmpClass.fields {
					overrideKey := specName + "." + kf.jsonName

					if _, ok := knownExtraInKMP[overrideKey]; ok {
						continue
					}

					// Skip private backing fields (e.g., _verified)
					if strings.HasPrefix(kf.propertyName, "_") {
						continue
					}

					if _, found := schema.properties[kf.jsonName]; !found {
						t.Errorf("KMP field %s.%s (json: %q) not in spec schema %s",
							mapping.kmpClassName, kf.propertyName, kf.jsonName, specName)
					}
				}
			})
		}
	})

	// -------------------------------------------------------------------
	// Direction 3: all spec schemas should be mapped (or excluded)
	// -------------------------------------------------------------------
	t.Run("all spec schemas mapped", func(t *testing.T) {
		var unmapped []string
		for name := range specSchemas {
			if _, ok := schemaToKMPClass[name]; ok {
				continue
			}
			if _, ok := excludedSchemas[name]; ok {
				continue
			}
			unmapped = append(unmapped, name)
		}
		sort.Strings(unmapped)
		if len(unmapped) > 0 {
			t.Errorf("OpenAPI schemas without KMP mapping:\n  %s\nAdd to schemaToKMPClass or excludedSchemas.",
				strings.Join(unmapped, "\n  "))
		}
	})
}

// ==========================================================================
// Schema ↔ KMP class mapping
// ==========================================================================

type classMapping struct {
	kmpClassName string
}

var schemaToKMPClass = map[string]classMapping{
	// Auth
	"LoginRequest":            {kmpClassName: "LoginRequest"},
	"RegisterRequest":         {kmpClassName: "RegisterRequest"},
	"ForgotPasswordRequest":   {kmpClassName: "ForgotPasswordRequest"},
	"VerifyResetCodeRequest":  {kmpClassName: "VerifyResetCodeRequest"},
	"ResetPasswordRequest":    {kmpClassName: "ResetPasswordRequest"},
	"UpdateProfileRequest":    {kmpClassName: "UpdateProfileRequest"},
	"VerifyEmailRequest":      {kmpClassName: "VerifyEmailRequest"},
	"AppleSignInRequest":      {kmpClassName: "AppleSignInRequest"},
	"GoogleSignInRequest":     {kmpClassName: "GoogleSignInRequest"},
	"UserResponse":            {kmpClassName: "User"},
	"UserProfileResponse":     {kmpClassName: "UserProfile"},
	"LoginResponse":           {kmpClassName: "AuthResponse"},
	"RegisterResponse":        {kmpClassName: "RegisterResponse"},
	"SocialSignInResponse":    {kmpClassName: "AppleSignInResponse"}, // Same shape
	"VerifyEmailResponse":     {kmpClassName: "VerifyEmailResponse"},
	"VerifyResetCodeResponse": {kmpClassName: "VerifyResetCodeResponse"},

	// Lookups
	"ResidenceTypeResponse":        {kmpClassName: "ResidenceType"},
	"TaskCategoryResponse":         {kmpClassName: "TaskCategory"},
	"TaskPriorityResponse":         {kmpClassName: "TaskPriority"},
	"TaskFrequencyResponse":        {kmpClassName: "TaskFrequency"},
	"ContractorSpecialtyResponse":  {kmpClassName: "ContractorSpecialty"},
	"SeededDataResponse":           {kmpClassName: "SeededDataResponse"},
	"TaskTemplateResponse":         {kmpClassName: "TaskTemplate"},
	"TaskTemplateCategoryGroup":    {kmpClassName: "TaskTemplateCategoryGroup"},
	"TaskTemplatesGroupedResponse": {kmpClassName: "TaskTemplatesGroupedResponse"},

	// Residence
	"CreateResidenceRequest":    {kmpClassName: "ResidenceCreateRequest"},
	"UpdateResidenceRequest":    {kmpClassName: "ResidenceUpdateRequest"},
	"JoinWithCodeRequest":       {kmpClassName: "JoinResidenceRequest"},
	"GenerateShareCodeRequest":  {kmpClassName: "GenerateShareCodeRequest"},
	"ResidenceUserResponse":     {kmpClassName: "ResidenceUserResponse"},
	"ResidenceResponse":         {kmpClassName: "ResidenceResponse"},
	"TotalSummary":              {kmpClassName: "TotalSummary"},
	"MyResidencesResponse":      {kmpClassName: "MyResidencesResponse"},
	"ShareCodeResponse":         {kmpClassName: "ShareCodeResponse"},
	"JoinResidenceResponse":     {kmpClassName: "JoinResidenceResponse"},
	"GenerateShareCodeResponse": {kmpClassName: "GenerateShareCodeResponse"},

	// Task
	"CreateTaskRequest":    {kmpClassName: "TaskCreateRequest"},
	"UpdateTaskRequest":    {kmpClassName: "TaskUpdateRequest"},
	"TaskUserResponse":     {kmpClassName: "TaskUserResponse"},
	"TaskResponse":         {kmpClassName: "TaskResponse"},
	"KanbanColumnResponse": {kmpClassName: "TaskColumn"},
	"KanbanBoardResponse":  {kmpClassName: "TaskColumnsResponse"},

	// Task Completion
	"CreateTaskCompletionRequest": {kmpClassName: "TaskCompletionCreateRequest"},
	"TaskCompletionImageResponse": {kmpClassName: "TaskCompletionImage"},
	"TaskCompletionResponse":      {kmpClassName: "TaskCompletionResponse"},

	// Contractor
	"CreateContractorRequest": {kmpClassName: "ContractorCreateRequest"},
	"UpdateContractorRequest": {kmpClassName: "ContractorUpdateRequest"},
	"ContractorResponse":      {kmpClassName: "Contractor"},

	// Document
	"CreateDocumentRequest": {kmpClassName: "DocumentCreateRequest"},
	"UpdateDocumentRequest": {kmpClassName: "DocumentUpdateRequest"},
	"DocumentImageResponse": {kmpClassName: "DocumentImage"},
	"DocumentResponse":      {kmpClassName: "Document"},

	// Notification
	"RegisterDeviceRequest":    {kmpClassName: "DeviceRegistrationRequest"},
	"DeviceResponse":           {kmpClassName: "DeviceRegistrationResponse"},
	"NotificationPreference":   {kmpClassName: "NotificationPreference"},
	"UpdatePreferencesRequest": {kmpClassName: "UpdateNotificationPreferencesRequest"},
	"Notification":             {kmpClassName: "Notification"},
	"NotificationListResponse": {kmpClassName: "NotificationListResponse"},

	// Subscription
	"SubscriptionStatusResponse": {kmpClassName: "SubscriptionStatus"},
	"SubscriptionResponse":       {kmpClassName: "VerificationSubscriptionInfo"},
	"UsageResponse":              {kmpClassName: "UsageStats"},
	"TierLimitsClientResponse":   {kmpClassName: "TierLimits"},
	"FeatureBenefit":             {kmpClassName: "FeatureBenefit"},
	"Promotion":                  {kmpClassName: "Promotion"},

	// Common
	"ErrorResponse":   {kmpClassName: "ErrorResponse"},
	"MessageResponse": {kmpClassName: "MessageResponse"},
}

// excludedSchemas are spec schemas intentionally not mapped to KMP classes.
var excludedSchemas = map[string]string{
	"TaskWithSummaryResponse":            "KMP uses generic WithSummaryResponse<T>",
	"DeleteWithSummaryResponse":          "KMP uses generic WithSummaryResponse<T>",
	"ResidenceWithSummaryResponse":       "KMP uses generic WithSummaryResponse<T>",
	"ResidenceDeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
	"TaskCompletionWithSummaryResponse":  "KMP uses generic WithSummaryResponse<T>",
	"ProcessPurchaseRequest":             "KMP splits into platform-specific requests",
	"CurrentUserResponse":                "KMP unifies into User class",
	"DocumentType":                       "Enum — KMP uses DocumentType enum class",
	"NotificationType":                   "Enum — KMP uses String",
	"ToggleFavoriteResponse":             "Simple message+bool, not worth a dedicated mapping",
	"SharePackageResponse":               "Used for .honeydue file sharing, handled by SharedContractor",
	"UnregisterDeviceRequest":            "Simple oneoff request",
	"UpdateTaskCompletionRequest":        "Not yet used in KMP",
	"UpgradeTriggerResponse":             "Shape differs from KMP UpgradeTriggerData",
	"UploadResult":                       "Handled inline in upload response parsing",
}

// knownTypeOverrides documents intentional type differences.
var knownTypeOverrides = map[string]string{
	// Spec says string (decimal), KMP uses Double for form binding
	"TaskResponse.estimated_cost":             "KMP uses Double for numeric form binding",
	"TaskResponse.actual_cost":                "KMP uses Double for numeric form binding",
	"CreateTaskRequest.estimated_cost":        "KMP uses Double for numeric form binding",
	"UpdateTaskRequest.estimated_cost":        "KMP uses Double for numeric form binding",
	"UpdateTaskRequest.actual_cost":           "KMP uses Double for numeric form binding",
	"ResidenceResponse.bathrooms":             "KMP uses Double for numeric form binding",
	"ResidenceResponse.lot_size":              "KMP uses Double for numeric form binding",
	"ResidenceResponse.purchase_price":        "KMP uses Double for numeric form binding",
	"CreateResidenceRequest.bathrooms":        "KMP uses Double for numeric form binding",
	"CreateResidenceRequest.lot_size":         "KMP uses Double for numeric form binding",
	"CreateResidenceRequest.purchase_price":   "KMP uses Double for numeric form binding",
	"UpdateResidenceRequest.bathrooms":        "KMP uses Double for numeric form binding",
	"UpdateResidenceRequest.lot_size":         "KMP uses Double for numeric form binding",
	"UpdateResidenceRequest.purchase_price":   "KMP uses Double for numeric form binding",
	"CreateTaskCompletionRequest.actual_cost": "KMP uses Double for numeric form binding",
	"TaskCompletionResponse.actual_cost":      "KMP uses Double for numeric form binding",

	// Spec says nullable Boolean, KMP uses non-nullable Boolean (defaults to false)
	"CreateContractorRequest.is_favorite": "KMP defaults is_favorite to false, not nullable",

	// Spec uses inline object for created_by, KMP uses typed classes
	"DocumentResponse.created_by":   "Spec uses inline object, KMP uses DocumentUser typed class",
	"ContractorResponse.created_by": "Spec uses inline object, KMP uses ContractorUser typed class",

	// Spec says string (JSON), KMP uses Map<String,String>
	"Notification.data": "KMP deserializes JSON string into Map<String,String>",

	// Spec uses $ref to enum, KMP uses String
	"CreateDocumentRequest.document_type": "KMP uses String; spec uses DocumentType $ref",
	"UpdateDocumentRequest.document_type": "KMP uses String; spec uses DocumentType $ref",
	"Notification.notification_type":      "KMP uses String; spec uses NotificationType $ref",

	// Spec has number/double, KMP has Double — these are actually compatible
	// but the parser sees "number" vs "Double" which the type checker handles
}

// knownMissingFromKMP: spec fields intentionally absent from KMP.
var knownMissingFromKMP = map[string]string{
	"ErrorResponse.details":           "KMP uses 'errors' field with different type",
	"TaskTemplateResponse.created_at": "KMP doesn't use template timestamps",
	"TaskTemplateResponse.updated_at": "KMP doesn't use template timestamps",
	"Notification.user_id":            "KMP doesn't need user_id on notifications",
	"Notification.error_message":      "KMP doesn't surface notification error messages",
	"Notification.updated_at":         "KMP doesn't use notification updated_at",
	"NotificationPreference.id":       "KMP doesn't need preference record ID",
	"NotificationPreference.user_id":  "KMP doesn't need user_id on preferences",
	"FeatureBenefit.id":               "KMP doesn't use benefit record ID",
	"FeatureBenefit.display_order":    "KMP doesn't use benefit display order",
	"FeatureBenefit.is_active":        "KMP doesn't filter by active status",
	"Promotion.id":                    "KMP uses promotion_id string instead",
	"Promotion.start_date":            "KMP doesn't filter by promotion dates",
	"Promotion.end_date":              "KMP doesn't filter by promotion dates",
	"Promotion.target_tier":           "KMP doesn't filter by target tier",
	"Promotion.is_active":             "KMP doesn't filter by active status",
	"LoginRequest.email":              "Spec allows email login, KMP only sends username",

	// Document create/update file fields — KMP handles file upload via multipart, not JSON
	"CreateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields",
	"CreateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields",
	"CreateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields",
	"CreateDocumentRequest.file_url":  "KMP handles file upload via multipart, not JSON fields",
	"UpdateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields",
	"UpdateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields",
	"UpdateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields",
	"UpdateDocumentRequest.file_url":  "KMP handles file upload via multipart, not JSON fields",
}

// knownExtraInKMP: KMP fields not in the spec (client-side additions).
var knownExtraInKMP = map[string]string{
	// Document client-side fields
	"DocumentResponse.category":              "Client-side field for UI grouping",
	"DocumentResponse.tags":                  "Client-side field",
	"DocumentResponse.notes":                 "Client-side field",
	"DocumentResponse.item_name":             "Client-side warranty field",
	"DocumentResponse.provider":              "Client-side warranty field",
	"DocumentResponse.provider_contact":      "Client-side warranty field",
	"DocumentResponse.claim_phone":           "Client-side warranty field",
	"DocumentResponse.claim_email":           "Client-side warranty field",
	"DocumentResponse.claim_website":         "Client-side warranty field",
	"DocumentResponse.start_date":            "Client-side warranty field",
	"DocumentResponse.days_until_expiration": "Client-side computed field",
	"DocumentResponse.warranty_status":       "Client-side computed field",

	// DocumentImage extra fields
	"DocumentImageResponse.uploaded_at": "KMP has uploaded_at for display",

	// TaskCompletionImage extra fields
	"TaskCompletionImageResponse.uploaded_at": "KMP has uploaded_at for display",

	// TaskResponse completions array (included in kanban response)
	"TaskResponse.completions":          "KMP includes completions array for kanban",
	"TaskResponse.custom_interval_days": "KMP supports custom frequency intervals",

	// ErrorResponse: KMP has 'errors', 'status_code', 'detail' not in spec
	"ErrorResponse.errors":      "KMP error response includes field-level errors map",
	"ErrorResponse.status_code": "KMP includes HTTP status code",
	"ErrorResponse.detail":      "KMP error response includes detail field for validation errors",

	// User: KMP has 'profile' field, spec splits into UserResponse + CurrentUserResponse
	"UserResponse.profile": "KMP User unifies UserResponse + CurrentUserResponse; profile is on CurrentUserResponse",

	// Contractor addedBy alias
	"ContractorResponse.added_by": "KMP has added_by alias for created_by_id",

	// SeededDataResponse: KMP field types differ (direct objects vs Response wrappers)
	// These are compatible at JSON level but the type names differ
}

// ==========================================================================
// OpenAPI spec parsing
// ==========================================================================

type specSchema struct {
	properties map[string]specField
}

type specField struct {
	typeName                string
	format                  string
	nullable                bool
	isRef                   bool
	isArray                 bool
	hasAdditionalProperties bool
}

func loadSpecSchemas(t *testing.T) map[string]specSchema {
	t.Helper()

	data, err := os.ReadFile("../../docs/openapi.yaml")
	require.NoError(t, err, "Failed to read openapi.yaml")

	var doc struct {
		Components struct {
			Schemas map[string]yaml.Node `yaml:"schemas"`
		} `yaml:"components"`
	}
	require.NoError(t, yaml.Unmarshal(data, &doc), "Failed to parse openapi.yaml")

	result := make(map[string]specSchema)
	for name, node := range doc.Components.Schemas {
		schema := parseSchemaNode(&node)
		result[name] = schema
	}
	return result
}

func parseSchemaNode(node *yaml.Node) specSchema {
	s := specSchema{properties: make(map[string]specField)}

	if node.Kind != yaml.MappingNode {
		return s
	}

	// Find "properties" key in the mapping
	for i := 0; i < len(node.Content)-1; i += 2 {
		key := node.Content[i]
		val := node.Content[i+1]

		if key.Value == "properties" && val.Kind == yaml.MappingNode {
			// Parse each property
			for j := 0; j < len(val.Content)-1; j += 2 {
				propName := val.Content[j].Value
				propNode := val.Content[j+1]
				s.properties[propName] = parseFieldNode(propNode)
			}
		}
	}

	return s
}

func parseFieldNode(node *yaml.Node) specField {
	f := specField{}

	if node.Kind != yaml.MappingNode {
		return f
	}

	for i := 0; i < len(node.Content)-1; i += 2 {
		key := node.Content[i].Value
		val := node.Content[i+1]

		switch key {
		case "type":
			f.typeName = val.Value
		case "format":
			f.format = val.Value
		case "nullable":
			f.nullable = val.Value == "true"
		case "$ref":
			f.isRef = true
		case "items":
			// Array items — check if array type
		case "additionalProperties":
			f.hasAdditionalProperties = true
		}

		if key == "type" && val.Value == "array" {
			f.isArray = true
		}
	}

	return f
}

// ==========================================================================
// KMP Kotlin model parsing
// ==========================================================================

type kmpModel struct {
	className string
	fields    []kmpField
}

type kmpField struct {
	propertyName string
	jsonName     string // from @SerialName or property name
	kotlinType   string
	nullable     bool
}

// Regex patterns for parsing Kotlin data classes
var (
	// Match: @Serializable\ndata class ClassName(
	reSerializableClass = regexp.MustCompile(`@Serializable\s*\n\s*data\s+class\s+(\w+)\s*\(`)

	// Match: @SerialName("json_name") val propName: Type
	// or:    @SerialName("json_name") private val propName: Type
	reSerialNameField = regexp.MustCompile(`@SerialName\("([^"]+)"\)\s*(?:private\s+)?val\s+(\w+)\s*:\s*([^\n=,)]+)`)

	// Match: val propName: Type (without @SerialName)
	rePlainField = regexp.MustCompile(`(?:^|\n)\s+val\s+(\w+)\s*:\s*([^\n=,)]+)`)
)

func loadKMPModels(t *testing.T) map[string]kmpModel {
	t.Helper()

	modelsDir := filepath.Join("..", "..", "..", "HoneyDueKMM", "composeApp", "src", "commonMain",
		"kotlin", "com", "example", "honeydue", "models")

	info, err := os.Stat(modelsDir)
	if os.IsNotExist(err) {
		t.Skipf("KMP models directory not found at %s", modelsDir)
	}
	require.NoError(t, err)
	require.True(t, info.IsDir())

	matches, err := filepath.Glob(filepath.Join(modelsDir, "*.kt"))
	require.NoError(t, err)
	require.NotEmpty(t, matches)

	result := make(map[string]kmpModel)

	for _, file := range matches {
		data, err := os.ReadFile(file)
		require.NoError(t, err)
		content := string(data)

		models := parseKotlinModels(content)
		for _, m := range models {
			result[m.className] = m
		}
	}

	return result
}

func parseKotlinModels(content string) []kmpModel {
	var models []kmpModel

	// Find all @Serializable data class declarations
	classMatches := reSerializableClass.FindAllStringSubmatchIndex(content, -1)

	for _, loc := range classMatches {
		className := content[loc[2]:loc[3]]
		classStart := loc[0]

		// Find the constructor body (from opening ( to matching ) )
		openParen := strings.Index(content[classStart:], "(")
		if openParen < 0 {
			continue
		}
		openParen += classStart

		closeParen := findMatchingParen(content, openParen)
		if closeParen < 0 {
			continue
		}

		constructorBody := content[openParen+1 : closeParen]

		// Parse fields from constructor
		fields := parseConstructorFields(constructorBody)

		models = append(models, kmpModel{
			className: className,
			fields:    fields,
		})
	}

	return models
}

func parseConstructorFields(body string) []kmpField {
	var fields []kmpField

	// Split by lines and parse each val declaration
	lines := strings.Split(body, "\n")

	for i := 0; i < len(lines); i++ {
		line := strings.TrimSpace(lines[i])

		// Check for @SerialName annotation
		if strings.Contains(line, "@SerialName(") {
			// May be on same line as val, or next line
			combined := line
			// If val is not on this line, combine with next
			if !strings.Contains(line, "val ") && i+1 < len(lines) {
				i++
				combined = line + " " + strings.TrimSpace(lines[i])
			}

			match := reSerialNameField.FindStringSubmatch(combined)
			if match != nil {
				jsonName := match[1]
				propName := match[2]
				kotlinType := strings.TrimSpace(match[3])
				nullable := strings.HasSuffix(kotlinType, "?")
				// Clean up type: remove trailing comma, default value
				kotlinType = cleanKotlinType(kotlinType)

				fields = append(fields, kmpField{
					propertyName: propName,
					jsonName:     jsonName,
					kotlinType:   kotlinType,
					nullable:     nullable,
				})
			}
			continue
		}

		// Check for plain val (no @SerialName)
		if strings.Contains(line, "val ") && !strings.Contains(line, "get()") {
			// Skip computed properties (have get() = ...)
			match := rePlainField.FindStringSubmatch("\n" + line)
			if match != nil {
				propName := match[1]
				kotlinType := strings.TrimSpace(match[2])
				nullable := strings.HasSuffix(kotlinType, "?")
				kotlinType = cleanKotlinType(kotlinType)

				fields = append(fields, kmpField{
					propertyName: propName,
					jsonName:     propName, // No @SerialName, so JSON name = property name
					kotlinType:   kotlinType,
					nullable:     nullable,
				})
			}
		}
	}

	return fields
}

func cleanKotlinType(t string) string {
	// Remove trailing comma
	t = strings.TrimSuffix(strings.TrimSpace(t), ",")
	// Remove default value assignment
	if idx := strings.Index(t, " ="); idx > 0 {
		t = strings.TrimSpace(t[:idx])
	}
	// Remove trailing ? for the clean type name
	// (but we already captured nullable separately)
	return strings.TrimSpace(t)
}

func findMatchingParen(s string, openIdx int) int {
	depth := 0
	for i := openIdx; i < len(s); i++ {
		switch s[i] {
		case '(':
			depth++
		case ')':
			depth--
			if depth == 0 {
				return i
			}
		}
	}
	return -1
}

// ==========================================================================
// Type mapping and comparison
// ==========================================================================

func mapSpecTypeToKotlin(f specField) string {
	if f.isArray {
		return "List"
	}
	if f.isRef {
		return "Object" // Any object reference
	}
	if f.hasAdditionalProperties {
		return "Map"
	}

	switch f.typeName {
	case "string":
		return "String"
	case "integer":
		if f.format == "int64" {
			return "Long"
		}
		return "Int"
	case "number":
		return "Double"
	case "boolean":
		return "Boolean"
	case "object":
		return "Map"
	default:
		return "Any"
	}
}

func normalizeKotlinType(t string) string {
	// Remove nullable marker
	t = strings.TrimSuffix(t, "?")
	// Extract base type from generics
	if idx := strings.Index(t, "<"); idx > 0 {
		t = t[:idx]
	}
	return t
}

func typesCompatible(expected, actual string) bool {
	if expected == actual {
		return true
	}
	// $ref matches any object type
	if expected == "Object" {
		return true
	}
	// Long ↔ Int is acceptable for most API integers
	if expected == "Long" && actual == "Int" {
		return true
	}
	if expected == "Int" && actual == "Long" {
		return true
	}
	return false
}

// ==========================================================================
// Summary test — prints a nice overview
// ==========================================================================

func TestKMPModelContractSummary(t *testing.T) {
	specSchemas := loadSpecSchemas(t)
	kmpModels := loadKMPModels(t)

	t.Logf("=== KMP Model Schema Contract Summary ===")
	t.Logf("OpenAPI schemas: %d", len(specSchemas))
	t.Logf("KMP model classes: %d", len(kmpModels))
	t.Logf("Mapped schema→class: %d", len(schemaToKMPClass))
	t.Logf("Excluded schemas: %d", len(excludedSchemas))
	t.Logf("Known type overrides: %d", len(knownTypeOverrides))
	t.Logf("Known missing from KMP: %d", len(knownMissingFromKMP))
	t.Logf("Known extra in KMP: %d", len(knownExtraInKMP))

	// List mapped pairs
	var pairs []string
	for spec, mapping := range schemaToKMPClass {
		pairs = append(pairs, fmt.Sprintf("  %s → %s", spec, mapping.kmpClassName))
	}
	sort.Strings(pairs)
	t.Logf("Mappings:\n%s", strings.Join(pairs, "\n"))

	// Count total fields validated
	totalFields := 0
	for specName := range schemaToKMPClass {
		if schema, ok := specSchemas[specName]; ok {
			totalFields += len(schema.properties)
		}
	}
	t.Logf("Total spec fields validated: %d", totalFields)

	// Verify all overrides reference valid schema.field combos
	for key := range knownTypeOverrides {
		parts := strings.SplitN(key, ".", 2)
		assert.Len(t, parts, 2, "knownTypeOverrides key %q should be Schema.field", key)
	}
	for key := range knownMissingFromKMP {
		parts := strings.SplitN(key, ".", 2)
		assert.Len(t, parts, 2, "knownMissingFromKMP key %q should be Schema.field", key)
	}
	for key := range knownExtraInKMP {
		parts := strings.SplitN(key, ".", 2)
		assert.Len(t, parts, 2, "knownExtraInKMP key %q should be Schema.field", key)
	}
}
