package orm

import (
	"fmt"
	"strings"
)

// Recordset represents an ordered set of records for a model.
// Mirrors: odoo/orm/models.py BaseModel (which IS the recordset)
//
// In Odoo, a model instance IS a recordset. Every operation returns recordsets:
//
//	partners = self.env['res.partner'].search([('name', 'ilike', 'test')])
//	for partner in partners:
//	    print(partner.name)
//
// Go equivalent:
//
//	partners := env.Model("res.partner").Search(And(Leaf("name", "ilike", "test")))
//	for _, rec := range partners.Records() {
//	    fmt.Println(rec.Get("name"))
//	}
type Recordset struct {
	env   *Environment
	model *Model
	ids   []int64
}

// Env returns the environment of this recordset.
func (rs *Recordset) Env() *Environment { return rs.env }

// Model returns the model of this recordset.
func (rs *Recordset) ModelDef() *Model { return rs.model }

// IDs returns the record IDs in this set.
func (rs *Recordset) IDs() []int64 { return rs.ids }

// Len returns the number of records.
func (rs *Recordset) Len() int { return len(rs.ids) }

// IsEmpty returns true if no records.
func (rs *Recordset) IsEmpty() bool { return len(rs.ids) == 0 }

// Ensure checks that this recordset contains exactly one record.
// Mirrors: odoo.models.BaseModel.ensure_one()
func (rs *Recordset) Ensure() error {
	if len(rs.ids) != 1 {
		return fmt.Errorf("orm: expected singleton, got %d records for %s", len(rs.ids), rs.model.name)
	}
	return nil
}

// ID returns the ID of a singleton recordset. Panics if not singleton.
func (rs *Recordset) ID() int64 {
	if err := rs.Ensure(); err != nil {
		panic(err)
	}
	return rs.ids[0]
}

// Browse returns a recordset for the given IDs.
// Mirrors: self.env['model'].browse([1, 2, 3])
func (rs *Recordset) Browse(ids ...int64) *Recordset {
	return &Recordset{
		env:   rs.env,
		model: rs.model,
		ids:   ids,
	}
}

// Sudo returns this recordset with superuser privileges.
// Mirrors: records.sudo()
func (rs *Recordset) Sudo() *Recordset {
	return &Recordset{
		env:   rs.env.Sudo(),
		model: rs.model,
		ids:   rs.ids,
	}
}

// WithContext returns this recordset with additional context.
func (rs *Recordset) WithContext(ctx map[string]interface{}) *Recordset {
	return &Recordset{
		env:   rs.env.WithContext(ctx),
		model: rs.model,
		ids:   rs.ids,
	}
}

// --- CRUD Operations ---

// Create creates a new record and returns a recordset containing it.
// Mirrors: self.env['model'].create(vals)
func (rs *Recordset) Create(vals Values) (*Recordset, error) {
	m := rs.model

	// Handle _inherits: separate parent vals and create/update parent records
	if err := preprocessInheritsCreate(rs.env, m, vals); err != nil {
		return nil, err
	}

	// Phase 1: Apply defaults for missing fields
	ApplyDefaults(m, vals)

	// Apply dynamic defaults from model's DefaultGet hook (e.g., DB lookups)
	if m.DefaultGet != nil {
		dynDefaults := m.DefaultGet(rs.env, nil)
		for k, v := range dynDefaults {
			if _, exists := vals[k]; !exists {
				vals[k] = v
			}
		}
	}

	// Add magic fields
	if rs.env.uid > 0 {
		vals["create_uid"] = rs.env.uid
		vals["write_uid"] = rs.env.uid
	}

	// Phase 2: BeforeCreate hook (e.g., sequence generation)
	if m.BeforeCreate != nil {
		if err := m.BeforeCreate(rs.env, vals); err != nil {
			return nil, err
		}
	}

	// Phase 1: Validate required fields
	if err := ValidateRequired(m, vals, true); err != nil {
		return nil, err
	}

	// Build INSERT statement
	var columns []string
	var placeholders []string
	var args []interface{}
	idx := 1

	for _, name := range m.fieldOrder {
		f := m.fields[name]
		if name == "id" || !f.IsStored() {
			continue
		}
		val, exists := vals[name]
		if !exists {
			continue
		}
		// Odoo sends false for empty fields; convert to nil for non-boolean types
		val = sanitizeFieldValue(f, val)
		// Skip nil values (let DB use column default)
		if val == nil {
			continue
		}
		columns = append(columns, fmt.Sprintf("%q", f.Column()))
		placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
		args = append(args, val)
		idx++
	}

	if len(columns) == 0 {
		// Create with defaults only
		columns = append(columns, `"create_date"`)
		placeholders = append(placeholders, "NOW()")
	}

	query := fmt.Sprintf(
		`INSERT INTO %q (%s) VALUES (%s) RETURNING "id"`,
		m.table,
		strings.Join(columns, ", "),
		strings.Join(placeholders, ", "),
	)

	var id int64
	err := rs.env.tx.QueryRow(rs.env.ctx, query, args...).Scan(&id)
	if err != nil {
		return nil, fmt.Errorf("orm: create %s: %w", m.name, err)
	}

	// Invalidate cache for this model
	rs.env.cache.Invalidate(m.name)

	// Process relational field commands (O2M/M2M)
	if err := processRelationalCommands(rs.env, m, id, vals); err != nil {
		return nil, err
	}

	// Run stored computed fields (after children exist for O2M-based computes)
	if err := RunStoredComputes(m, rs.env, id, vals); err != nil {
		return nil, err
	}
	// Write any newly computed values to the record
	computedOnly := make(Values)
	for _, name := range m.fieldOrder {
		f := m.fields[name]
		if f.Compute != "" && f.Store {
			if v, ok := vals[name]; ok {
				computedOnly[name] = v
			}
		}
	}
	if len(computedOnly) > 0 {
		if err := writeDirectNohook(rs.env, m, id, computedOnly); err != nil {
			return nil, err
		}
	}

	result := rs.Browse(id)

	// Run constraints after record is fully created (with children + computes)
	for _, constraint := range m.Constraints {
		if err := constraint(result); err != nil {
			return nil, err
		}
	}

	return result, nil
}

// preprocessRelatedWrites handles write-back for Related fields.
// When writing to a Related field (e.g., "company_id.name"), this function
// follows the FK chain and writes to the target model instead.
// Mirrors: odoo/orm/fields.py _inverse_related()
func preprocessRelatedWrites(env *Environment, m *Model, ids []int64, vals Values) error {
	// Prevent infinite recursion
	depth := 0
	if d, ok := env.context["_write_related_depth"]; ok {
		if di, ok := d.(int); ok {
			depth = di
		}
	}
	if depth > 5 {
		return nil // Safety: max recursion depth
	}

	var relatedFields []string
	for fieldName := range vals {
		f := m.GetField(fieldName)
		if f != nil && f.Related != "" && !f.IsStored() {
			relatedFields = append(relatedFields, fieldName)
		}
	}

	if len(relatedFields) == 0 {
		return nil
	}

	// Read FK values for the records being written
	// We need the FK IDs to know which parent records to update
	for _, fieldName := range relatedFields {
		f := m.GetField(fieldName)
		parts := strings.Split(f.Related, ".")
		if len(parts) != 2 {
			delete(vals, fieldName) // Can't handle multi-hop yet, skip
			continue
		}
		fkFieldName := parts[0]
		targetFieldName := parts[1]

		fkDef := m.GetField(fkFieldName)
		if fkDef == nil || fkDef.Type != TypeMany2one || fkDef.Comodel == "" {
			delete(vals, fieldName)
			continue
		}

		value := vals[fieldName]
		delete(vals, fieldName) // Remove from vals — no local column

		// Read FK IDs for all records
		var fkIDs []int64
		for _, id := range ids {
			var fkID *int64
			env.tx.QueryRow(env.ctx,
				fmt.Sprintf(`SELECT %q FROM %q WHERE id = $1`, fkDef.Column(), m.Table()),
				id,
			).Scan(&fkID)
			if fkID != nil && *fkID > 0 {
				fkIDs = append(fkIDs, *fkID)
			}
		}

		if len(fkIDs) == 0 {
			continue
		}

		// Deduplicate
		seen := make(map[int64]bool)
		var uniqueIDs []int64
		for _, id := range fkIDs {
			if !seen[id] {
				seen[id] = true
				uniqueIDs = append(uniqueIDs, id)
			}
		}

		// Write to target model with increased depth
		depthEnv := env.WithContext(map[string]interface{}{
			"_write_related_depth": depth + 1,
		})
		targetRS := &Recordset{env: depthEnv, model: Registry.MustGet(fkDef.Comodel), ids: uniqueIDs}
		if err := targetRS.Write(Values{targetFieldName: value}); err != nil {
			return fmt.Errorf("related write-back %s.%s → %s.%s: %w",
				m.Name(), fieldName, fkDef.Comodel, targetFieldName, err)
		}
	}

	return nil
}

// Write updates the records in this recordset.
// Mirrors: records.write(vals)
func (rs *Recordset) Write(vals Values) error {
	if len(rs.ids) == 0 {
		return nil
	}

	m := rs.model

	var setClauses []string
	var args []interface{}
	idx := 1

	// Add write metadata
	if rs.env.uid > 0 {
		vals["write_uid"] = rs.env.uid
	}
	vals["write_date"] = "NOW()" // Will be handled specially

	// Handle Related field write-back (must happen before building SET clauses)
	if err := preprocessRelatedWrites(rs.env, m, rs.ids, vals); err != nil {
		return err
	}

	for _, name := range m.fieldOrder {
		f := m.fields[name]
		if name == "id" || !f.IsStored() {
			continue
		}
		// Skip readonly fields (like Python Odoo, silently ignore)
		if f.Readonly && name != "write_uid" && name != "write_date" {
			continue
		}
		val, exists := vals[name]
		if !exists {
			continue
		}

		if name == "write_date" {
			setClauses = append(setClauses, `"write_date" = NOW()`)
			continue
		}

		// Odoo sends false for empty fields; convert to nil for non-boolean types
		val = sanitizeFieldValue(f, val)

		setClauses = append(setClauses, fmt.Sprintf("%q = $%d", f.Column(), idx))
		args = append(args, val)
		idx++
	}

	if len(setClauses) == 0 {
		return nil
	}

	// Build WHERE clause for IDs
	idPlaceholders := make([]string, len(rs.ids))
	for i, id := range rs.ids {
		args = append(args, id)
		idPlaceholders[i] = fmt.Sprintf("$%d", idx)
		idx++
	}

	query := fmt.Sprintf(
		`UPDATE %q SET %s WHERE "id" IN (%s)`,
		m.table,
		strings.Join(setClauses, ", "),
		strings.Join(idPlaceholders, ", "),
	)

	_, err := rs.env.tx.Exec(rs.env.ctx, query, args...)
	if err != nil {
		return fmt.Errorf("orm: write %s: %w", m.name, err)
	}

	// Invalidate cache
	for _, id := range rs.ids {
		rs.env.cache.InvalidateRecord(m.name, id)
	}

	// Process relational field commands (O2M/M2M) for each record
	for _, id := range rs.ids {
		if err := processRelationalCommands(rs.env, m, id, vals); err != nil {
			return err
		}
	}

	// Handle inverse fields (write-back for computed fields).
	// Mirrors: odoo/orm/fields.py Field.inverse
	//
	// When a computed field with an Inverse method is written to, the inverse
	// method is called so it can propagate the value to the underlying fields.
	// In Python Odoo, the inverse method reads self.field_name from cache (which
	// has the new value). We replicate this by caching the new value before calling.
	for fieldName := range vals {
		f := m.GetField(fieldName)
		if f == nil || f.Inverse == "" {
			continue
		}
		inverseMethod, ok := m.Methods[f.Inverse]
		if !ok {
			continue
		}
		// Cache the new value so the inverse method can read it via Get()
		for _, id := range rs.ids {
			rs.env.cache.Set(m.Name(), id, fieldName, vals[fieldName])
		}
		if _, err := inverseMethod(rs); err != nil {
			return fmt.Errorf("orm: inverse %s.%s: %w", m.name, f.Inverse, err)
		}
	}

	// Trigger recompute for stored computed fields that depend on written fields
	if err := TriggerRecompute(rs, vals); err != nil {
		return err
	}

	// Run constraints after write (mirrors Create constraint check)
	for _, constraint := range m.Constraints {
		for _, id := range rs.ids {
			if err := constraint(rs.Browse(id)); err != nil {
				return err
			}
		}
	}

	return nil
}

// Unlink deletes the records in this recordset.
// Mirrors: records.unlink()
func (rs *Recordset) Unlink() error {
	if len(rs.ids) == 0 {
		return nil
	}

	m := rs.model

	var args []interface{}
	placeholders := make([]string, len(rs.ids))
	for i, id := range rs.ids {
		args = append(args, id)
		placeholders[i] = fmt.Sprintf("$%d", i+1)
	}

	query := fmt.Sprintf(
		`DELETE FROM %q WHERE "id" IN (%s)`,
		m.table,
		strings.Join(placeholders, ", "),
	)

	_, err := rs.env.tx.Exec(rs.env.ctx, query, args...)
	if err != nil {
		return fmt.Errorf("orm: unlink %s: %w", m.name, err)
	}

	// Invalidate cache
	for _, id := range rs.ids {
		rs.env.cache.InvalidateRecord(m.name, id)
	}

	return nil
}

// --- Read Operations ---

// Read reads field values for the records in this recordset.
// Mirrors: records.read(['field1', 'field2'])
func (rs *Recordset) Read(fields []string) ([]Values, error) {
	if len(rs.ids) == 0 {
		return nil, nil
	}

	m := rs.model

	// Resolve fields to column names
	if len(fields) == 0 {
		// Read all stored fields
		for _, name := range m.fieldOrder {
			f := m.fields[name]
			if f.IsStored() {
				fields = append(fields, name)
			}
		}
	}

	var columns []string
	var storedFields []string    // Fields that come from the DB query
	var m2mFields []string       // Many2many fields (from junction table)
	var relatedFields []string   // Related fields (from joined table)
	wantDisplayName := false     // Whether display_name was requested

	for _, name := range fields {
		f := m.GetField(name)
		if f == nil {
			return nil, fmt.Errorf("orm: field %q not found on %s", name, m.name)
		}
		if name == "display_name" && f.Compute != "" && !f.Store {
			wantDisplayName = true
			continue
		}
		if f.Type == TypeMany2many {
			m2mFields = append(m2mFields, name)
		} else if f.Related != "" && !f.Store {
			relatedFields = append(relatedFields, name)
		} else if f.IsStored() {
			columns = append(columns, fmt.Sprintf("%q", f.Column()))
			storedFields = append(storedFields, name)
		}
	}

	// If display_name is requested, ensure the rec_name field is fetched from DB
	recName := m.recName
	if wantDisplayName {
		recNameAlreadyFetched := false
		for _, sf := range storedFields {
			if sf == recName {
				recNameAlreadyFetched = true
				break
			}
		}
		if !recNameAlreadyFetched {
			recF := m.GetField(recName)
			if recF != nil && recF.IsStored() {
				columns = append(columns, fmt.Sprintf("%q", recF.Column()))
				storedFields = append(storedFields, recName)
			}
		}
	}

	// Build query
	var args []interface{}
	idPlaceholders := make([]string, len(rs.ids))
	for i, id := range rs.ids {
		args = append(args, id)
		idPlaceholders[i] = fmt.Sprintf("$%d", i+1)
	}

	// Fetch without ORDER BY — we'll reorder to match rs.ids below.
	// This preserves the caller's intended order (e.g., from Search with a custom ORDER).
	query := fmt.Sprintf(
		`SELECT %s FROM %q WHERE "id" IN (%s)`,
		strings.Join(columns, ", "),
		m.table,
		strings.Join(idPlaceholders, ", "),
	)

	rows, err := rs.env.tx.Query(rs.env.ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("orm: read %s: %w", m.name, err)
	}
	defer rows.Close()

	// Collect results keyed by ID so we can reorder them.
	resultsByID := make(map[int64]Values, len(rs.ids))
	for rows.Next() {
		scanDest := make([]interface{}, len(columns))
		for i := range scanDest {
			scanDest[i] = new(interface{})
		}

		if err := rows.Scan(scanDest...); err != nil {
			return nil, fmt.Errorf("orm: scan %s: %w", m.name, err)
		}

		record := make(Values, len(fields))
		for i, name := range storedFields {
			val := *(scanDest[i].(*interface{}))
			record[name] = val

			// Update cache
			if id, ok := record["id"].(int64); ok {
				rs.env.cache.Set(m.name, id, name, val)
			}
		}
		if id, ok := toRecordID(record["id"]); ok {
			resultsByID[id] = record
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	// Reorder results to match the original rs.ids order.
	results := make([]Values, 0, len(rs.ids))
	for _, id := range rs.ids {
		if rec, ok := resultsByID[id]; ok {
			results = append(results, rec)
		}
	}

	// Post-fetch: compute display_name from the rec_name field value.
	// Mirrors: odoo/orm/models.py BaseModel._compute_display_name()
	if wantDisplayName {
		for _, rec := range results {
			if nameVal, ok := rec[recName]; ok {
				switch v := nameVal.(type) {
				case string:
					rec["display_name"] = v
				case nil:
					rec["display_name"] = ""
				default:
					rec["display_name"] = fmt.Sprintf("%v", v)
				}
			} else {
				rec["display_name"] = ""
			}
		}
	}

	// Post-fetch: translations for non-English languages
	// Mirrors: odoo/orm/models.py BaseModel._read() translation lookup
	if rs.env.Lang() != "en_US" && rs.env.Lang() != "" {
		for _, fname := range storedFields {
			f := m.GetField(fname)
			if f == nil || !f.Translate {
				continue
			}
			for _, rec := range results {
				srcVal, _ := rec[fname].(string)
				if srcVal == "" {
					continue
				}
				var translated string
				err := rs.env.tx.QueryRow(rs.env.ctx,
					`SELECT value FROM ir_translation
					 WHERE lang = $1 AND src = $2 AND value != ''
					 LIMIT 1`,
					rs.env.Lang(), srcVal,
				).Scan(&translated)
				if err == nil && translated != "" {
					rec[fname] = translated
				}
			}
		}
	}

	// Post-fetch: M2M fields (from junction tables)
	if len(m2mFields) > 0 && len(rs.ids) > 0 {
		for _, fname := range m2mFields {
			f := m.GetField(fname)
			if f == nil {
				continue
			}
			m2mData, err := ReadM2MField(rs.env, f, rs.ids)
			if err != nil {
				return nil, fmt.Errorf("orm: read M2M %s.%s: %w", m.name, fname, err)
			}
			for _, rec := range results {
				if id, ok := rec["id"].(int64); ok {
					rec[fname] = m2mData[id]
				} else if id, ok := rec["id"].(int32); ok {
					rec[fname] = m2mData[int64(id)]
				}
			}
		}
	}

	// Post-fetch: Related fields (follow M2O chain)
	if len(relatedFields) > 0 {
		for _, fname := range relatedFields {
			f := m.GetField(fname)
			if f == nil || f.Related == "" {
				continue
			}
			parts := strings.Split(f.Related, ".")
			if len(parts) != 2 {
				continue // Only support single-hop related for now
			}
			fkField := parts[0]
			targetField := parts[1]
			fkDef := m.GetField(fkField)
			if fkDef == nil || fkDef.Type != TypeMany2one {
				continue
			}
			// Collect FK IDs from results
			fkIDs := make(map[int64]bool)
			for _, rec := range results {
				if fkID, ok := toRecordID(rec[fkField]); ok && fkID > 0 {
					fkIDs[fkID] = true
				}
			}
			if len(fkIDs) == 0 {
				for _, rec := range results {
					rec[fname] = nil
				}
				continue
			}
			// Fetch related values
			var ids []int64
			for id := range fkIDs {
				ids = append(ids, id)
			}
			comodelRS := rs.env.Model(fkDef.Comodel).Browse(ids...)
			relatedData, err := comodelRS.Read([]string{"id", targetField})
			if err != nil {
				continue // Skip on error
			}
			lookup := make(map[int64]interface{})
			for _, rd := range relatedData {
				if id, ok := toRecordID(rd["id"]); ok {
					lookup[id] = rd[targetField]
				}
			}
			for _, rec := range results {
				if fkID, ok := toRecordID(rec[fkField]); ok {
					rec[fname] = lookup[fkID]
				} else {
					rec[fname] = nil
				}
			}
		}
	}

	return results, nil
}

// Get reads a single field value from a singleton record.
// Mirrors: record.field_name (Python attribute access)
func (rs *Recordset) Get(field string) (Value, error) {
	if err := rs.Ensure(); err != nil {
		return nil, err
	}

	// Check cache first
	if val, ok := rs.env.cache.Get(rs.model.name, rs.ids[0], field); ok {
		return val, nil
	}

	// Read from database
	records, err := rs.Read([]string{field})
	if err != nil {
		return nil, err
	}
	if len(records) == 0 {
		return nil, fmt.Errorf("orm: record %s(%d) not found", rs.model.name, rs.ids[0])
	}

	return records[0][field], nil
}

// --- Search Operations ---

// Search searches for records matching the domain.
// Mirrors: self.env['model'].search(domain, offset=0, limit=None, order=None)
func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, error) {
	m := rs.model
	opt := mergeSearchOpts(opts)

	// Auto-filter archived records unless active_test=false in context
	// Mirrors: odoo/orm/models.py BaseModel._where_calc()
	if activeField := m.GetField("active"); activeField != nil {
		activeTest := true
		if v, ok := rs.env.context["active_test"]; ok {
			if b, ok := v.(bool); ok {
				activeTest = b
			}
		}
		if activeTest {
			activeLeaf := Leaf("active", "=", true)
			if len(domain) == 0 {
				domain = Domain{activeLeaf}
			} else {
				domain = append(Domain{OpAnd}, append(domain, activeLeaf)...)
			}
		}
	}

	// Apply record rules (e.g., multi-company filter)
	domain = ApplyRecordRules(rs.env, m, domain)

	// Compile domain to SQL
	compiler := &DomainCompiler{model: m, env: rs.env}
	where, params, err := compiler.Compile(domain)
	if err != nil {
		return nil, fmt.Errorf("orm: search %s: %w", m.name, err)
	}

	// Build query
	order := m.order
	if opt.Order != "" {
		order = opt.Order
	}

	joinSQL := compiler.JoinSQL()
	// Qualify ORDER BY columns with table name when JOINs are present
	qualifiedOrder := order
	if joinSQL != "" {
		qualifiedOrder = qualifyOrderBy(m.table, order)
	}
	query := fmt.Sprintf(
		`SELECT %q."id" FROM %q%s WHERE %s ORDER BY %s`,
		m.table, m.table, joinSQL, where, qualifiedOrder,
	)

	if opt.Limit > 0 {
		query += fmt.Sprintf(" LIMIT %d", opt.Limit)
	}
	if opt.Offset > 0 {
		query += fmt.Sprintf(" OFFSET %d", opt.Offset)
	}

	rows, err := rs.env.tx.Query(rs.env.ctx, query, params...)
	if err != nil {
		return nil, fmt.Errorf("orm: search %s: %w", m.name, err)
	}
	defer rows.Close()

	var ids []int64
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, fmt.Errorf("orm: search scan %s: %w", m.name, err)
		}
		ids = append(ids, id)
	}

	return rs.Browse(ids...), rows.Err()
}

// SearchCount returns the number of records matching the domain.
// Mirrors: self.env['model'].search_count(domain)
func (rs *Recordset) SearchCount(domain Domain) (int64, error) {
	m := rs.model

	// Auto-filter archived records unless active_test=false in context
	// Mirrors: odoo/orm/models.py BaseModel._where_calc()
	if activeField := m.GetField("active"); activeField != nil {
		activeTest := true
		if v, ok := rs.env.context["active_test"]; ok {
			if b, ok := v.(bool); ok {
				activeTest = b
			}
		}
		if activeTest {
			activeLeaf := Leaf("active", "=", true)
			if len(domain) == 0 {
				domain = Domain{activeLeaf}
			} else {
				domain = append(Domain{OpAnd}, append(domain, activeLeaf)...)
			}
		}
	}

	compiler := &DomainCompiler{model: m, env: rs.env}
	where, params, err := compiler.Compile(domain)
	if err != nil {
		return 0, fmt.Errorf("orm: search_count %s: %w", m.name, err)
	}

	joinSQL := compiler.JoinSQL()
	query := fmt.Sprintf(`SELECT COUNT(*) FROM %q%s WHERE %s`, m.table, joinSQL, where)

	var count int64
	err = rs.env.tx.QueryRow(rs.env.ctx, query, params...).Scan(&count)
	if err != nil {
		return 0, fmt.Errorf("orm: search_count %s: %w", m.name, err)
	}

	return count, nil
}

// SearchRead combines search and read in one call.
// Mirrors: self.env['model'].search_read(domain, fields, offset, limit, order)
func (rs *Recordset) SearchRead(domain Domain, fields []string, opts ...SearchOpts) ([]Values, error) {
	found, err := rs.Search(domain, opts...)
	if err != nil {
		return nil, err
	}
	if found.IsEmpty() {
		return nil, nil
	}
	return found.Read(fields)
}

// NameGet returns display names for the records.
// Mirrors: records.name_get()
func (rs *Recordset) NameGet() (map[int64]string, error) {
	if len(rs.ids) == 0 {
		return nil, nil
	}

	recName := rs.model.recName
	records, err := rs.Read([]string{"id", recName})
	if err != nil {
		return nil, err
	}

	result := make(map[int64]string, len(records))
	for _, rec := range records {
		id, _ := toRecordID(rec["id"])
		name, _ := rec[recName].(string)
		result[id] = name
	}
	return result, nil
}

// Exists filters this recordset to only records that exist in the database.
// Mirrors: records.exists()
func (rs *Recordset) Exists() (*Recordset, error) {
	if len(rs.ids) == 0 {
		return rs, nil
	}

	m := rs.model
	var args []interface{}
	placeholders := make([]string, len(rs.ids))
	for i, id := range rs.ids {
		args = append(args, id)
		placeholders[i] = fmt.Sprintf("$%d", i+1)
	}

	query := fmt.Sprintf(
		`SELECT "id" FROM %q WHERE "id" IN (%s)`,
		m.table,
		strings.Join(placeholders, ", "),
	)

	rows, err := rs.env.tx.Query(rs.env.ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var existing []int64
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		existing = append(existing, id)
	}

	return rs.Browse(existing...), rows.Err()
}

// Records returns individual singleton recordsets for iteration.
// Mirrors: for record in records: ...
func (rs *Recordset) Records() []*Recordset {
	result := make([]*Recordset, len(rs.ids))
	for i, id := range rs.ids {
		result[i] = rs.Browse(id)
	}
	return result
}

// Copy duplicates a singleton record, returning the new record.
// Mirrors: odoo/orm/models.py BaseModel.copy()
//
//	new_partner = partner.Copy(orm.Values{"name": "New Name"})
func (rs *Recordset) Copy(defaults Values) (*Recordset, error) {
	if err := rs.Ensure(); err != nil {
		return nil, err
	}
	m := rs.model

	// Read all stored fields
	records, err := rs.Read(nil)
	if err != nil || len(records) == 0 {
		return nil, fmt.Errorf("orm: copy: record %s(%d) not found", m.name, rs.ids[0])
	}
	original := records[0]

	// Blacklisted fields (never copied)
	blacklist := map[string]bool{
		"id": true, "create_uid": true, "create_date": true,
		"write_uid": true, "write_date": true, "display_name": true,
		"password": true, "password_crypt": true,
	}

	// Also blacklist _inherits FK fields (parent will be copied separately)
	for _, fkField := range m.inherits {
		blacklist[fkField] = true
	}

	newVals := make(Values)

	for _, name := range m.fieldOrder {
		if blacklist[name] {
			continue
		}
		f := m.fields[name]
		if f == nil || !f.IsCopyable() || !f.IsStored() {
			continue
		}

		val, exists := original[name]
		if !exists || val == nil {
			continue
		}

		switch f.Type {
		case TypeMany2many:
			// Re-link existing M2M entries (don't duplicate targets)
			if ids, ok := val.([]int64); ok && len(ids) > 0 {
				cmds := make([]interface{}, len(ids))
				for i, id := range ids {
					cmds[i] = []interface{}{4, id, 0} // Command LINK
				}
				newVals[name] = cmds
			}
		case TypeOne2many:
			// Skip — O2M records are NOT copied by default (copy=false)
			continue
		default:
			newVals[name] = val
		}
	}

	// Append "(copy)" to rec_name field
	recName := m.recName
	if recName == "" {
		recName = "name"
	}
	if nameVal, ok := newVals[recName].(string); ok {
		newVals[recName] = nameVal + " (copy)"
	}

	// Apply user-provided defaults (override copied values)
	if defaults != nil {
		for k, v := range defaults {
			newVals[k] = v
		}
	}

	return rs.Create(newVals)
}

// Union returns the union of this recordset with others.
// Mirrors: records | other_records
func (rs *Recordset) Union(others ...*Recordset) *Recordset {
	seen := make(map[int64]bool)
	var ids []int64
	for _, id := range rs.ids {
		if !seen[id] {
			seen[id] = true
			ids = append(ids, id)
		}
	}
	for _, other := range others {
		for _, id := range other.ids {
			if !seen[id] {
				seen[id] = true
				ids = append(ids, id)
			}
		}
	}
	return rs.Browse(ids...)
}

// Subtract returns records in this set but not in the other.
// Mirrors: records - other_records
func (rs *Recordset) Subtract(other *Recordset) *Recordset {
	exclude := make(map[int64]bool)
	for _, id := range other.ids {
		exclude[id] = true
	}
	var ids []int64
	for _, id := range rs.ids {
		if !exclude[id] {
			ids = append(ids, id)
		}
	}
	return rs.Browse(ids...)
}

// --- Search Options ---

// SearchOpts configures a search operation.
type SearchOpts struct {
	Offset int
	Limit  int
	Order  string
}

func mergeSearchOpts(opts []SearchOpts) SearchOpts {
	if len(opts) == 0 {
		return SearchOpts{}
	}
	return opts[0]
}

// toRecordID extracts an int64 ID from various types PostgreSQL might return.
func toRecordID(v interface{}) (int64, bool) {
	switch n := v.(type) {
	case int64:
		return n, true
	case int32:
		return int64(n), true
	case int:
		return int64(n), true
	case float64:
		return int64(n), true
	}
	return 0, false
}

// qualifyOrderBy prefixes unqualified column names with the table name.
// "name, id desc" → "\"my_table\".name, \"my_table\".id desc"
func qualifyOrderBy(table, order string) string {
	parts := strings.Split(order, ",")
	for i, part := range parts {
		part = strings.TrimSpace(part)
		tokens := strings.Fields(part)
		if len(tokens) == 0 {
			continue
		}
		col := tokens[0]
		// Skip already qualified columns
		if strings.Contains(col, ".") {
			continue
		}
		tokens[0] = fmt.Sprintf("%q.%s", table, col)
		parts[i] = strings.Join(tokens, " ")
	}
	return strings.Join(parts, ", ")
}

// --- Relational Command Processing ---

// processRelationalCommands handles O2M/M2M commands in vals after a Create or Write.
func processRelationalCommands(env *Environment, m *Model, parentID int64, vals Values) error {
	for _, name := range m.fieldOrder {
		f := m.fields[name]
		raw, exists := vals[name]
		if !exists {
			continue
		}

		cmds, ok := ParseCommands(raw)
		if !ok {
			continue
		}

		switch f.Type {
		case TypeOne2many:
			if err := ProcessO2MCommands(env, f, parentID, cmds); err != nil {
				return err
			}
		case TypeMany2many:
			if err := ProcessM2MCommands(env, f, parentID, cmds); err != nil {
				return err
			}
		}
	}
	return nil
}

// sanitizeFieldValue converts Odoo's false/empty values to Go-native types
// suitable for PostgreSQL. Odoo sends false for empty string/numeric/relational
// fields; PostgreSQL rejects false for varchar/int columns.
// Mirrors: odoo/orm/fields.py convert_to_column()
func sanitizeFieldValue(f *Field, val interface{}) interface{} {
	if val == nil {
		return nil
	}

	// Handle the Odoo false → nil conversion for non-boolean fields
	if b, ok := val.(bool); ok && !b {
		if f.Type == TypeBoolean {
			return false // Keep false for boolean fields
		}
		return nil // Convert false → NULL for all other types
	}

	// Handle float→int conversion for integer/M2O fields
	switch f.Type {
	case TypeInteger, TypeMany2one:
		if fv, ok := val.(float64); ok {
			return int64(fv)
		}
	}

	return val
}
