package orm

import (
	"fmt"
	"strings"
)

// Domain represents a search filter expression.
// Mirrors: odoo/orm/domains.py Domain class
//
// Odoo uses prefix (Polish) notation:
//
//	['&', ('name', 'ilike', 'test'), ('active', '=', True)]
//
// Go equivalent:
//
//	And(Leaf("name", "ilike", "test"), Leaf("active", "=", true))
type Domain []DomainNode

// DomainNode is either an Operator or a Condition (leaf).
type DomainNode interface {
	isDomainNode()
}

// Operator is a logical operator in a domain expression.
// Mirrors: odoo/orm/domains.py DOMAIN_OPERATORS
type Operator string

const (
	OpAnd Operator = "&"
	OpOr  Operator = "|"
	OpNot Operator = "!"
)

func (o Operator) isDomainNode() {}

// Condition is a leaf node in a domain expression.
// Mirrors: odoo/orm/domains.py DomainLeaf
//
// Odoo: ('field_name', 'operator', value)
type Condition struct {
	Field    string // Field name (supports dot notation: "partner_id.name")
	Operator string // Comparison operator
	Value    Value  // Comparison value
}

func (c Condition) isDomainNode() {}

// Valid comparison operators.
// Mirrors: odoo/orm/domains.py COMPARISON_OPERATORS
var validOperators = map[string]bool{
	"=": true, "!=": true,
	"<": true, ">": true, "<=": true, ">=": true,
	"in": true, "not in": true,
	"like": true, "not like": true,
	"ilike": true, "not ilike": true,
	"=like": true, "=ilike": true,
	"any": true, "not any": true,
	"child_of": true, "parent_of": true,
}

// Leaf creates a domain condition (leaf node).
func Leaf(field, operator string, value Value) Condition {
	return Condition{Field: field, Operator: operator, Value: value}
}

// And combines conditions with AND (default in Odoo).
func And(nodes ...DomainNode) Domain {
	if len(nodes) == 0 {
		return nil
	}
	if len(nodes) == 1 {
		return Domain{nodes[0]}
	}
	result := Domain{}
	for i := 0; i < len(nodes)-1; i++ {
		result = append(result, OpAnd)
	}
	result = append(result, nodes...)
	return result
}

// Or combines conditions with OR.
func Or(nodes ...DomainNode) Domain {
	if len(nodes) == 0 {
		return nil
	}
	if len(nodes) == 1 {
		return Domain{nodes[0]}
	}
	result := Domain{}
	for i := 0; i < len(nodes)-1; i++ {
		result = append(result, OpOr)
	}
	result = append(result, nodes...)
	return result
}

// Not negates a condition.
func Not(node DomainNode) Domain {
	return Domain{OpNot, node}
}

// DomainCompiler compiles a Domain to SQL WHERE clause.
// Mirrors: odoo/orm/domains.py Domain._to_sql()
type DomainCompiler struct {
	model        *Model
	env          *Environment // For operators that need DB access (child_of, parent_of, any, not any)
	params       []interface{}
	joins        []joinClause
	aliasCounter int
}

type joinClause struct {
	table string
	alias string
	on    string
}

// CompileResult holds the compiled SQL WHERE clause, JOINs, and parameters.
type CompileResult struct {
	Where  string
	Joins  string
	Params []interface{}
}

// Compile converts a domain to a SQL WHERE clause with parameters and JOINs.
func (dc *DomainCompiler) Compile(domain Domain) (string, []interface{}, error) {
	if len(domain) == 0 {
		return "TRUE", nil, nil
	}

	dc.params = nil
	dc.joins = nil
	dc.aliasCounter = 0
	sql, err := dc.compileNodes(domain, 0)
	if err != nil {
		return "", nil, err
	}
	return sql, dc.params, nil
}

// JoinSQL returns the SQL JOIN clauses generated during compilation.
func (dc *DomainCompiler) JoinSQL() string {
	if len(dc.joins) == 0 {
		return ""
	}
	var parts []string
	for _, j := range dc.joins {
		parts = append(parts, fmt.Sprintf("LEFT JOIN %q AS %q ON %s", j.table, j.alias, j.on))
	}
	return " " + strings.Join(parts, " ")
}

func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
	if pos >= len(domain) {
		return "TRUE", nil
	}

	node := domain[pos]

	switch n := node.(type) {
	case Operator:
		switch n {
		case OpAnd:
			left, err := dc.compileNodes(domain, pos+1)
			if err != nil {
				return "", err
			}
			right, err := dc.compileNodes(domain, pos+2)
			if err != nil {
				return "", err
			}
			return fmt.Sprintf("(%s AND %s)", left, right), nil

		case OpOr:
			left, err := dc.compileNodes(domain, pos+1)
			if err != nil {
				return "", err
			}
			right, err := dc.compileNodes(domain, pos+2)
			if err != nil {
				return "", err
			}
			return fmt.Sprintf("(%s OR %s)", left, right), nil

		case OpNot:
			inner, err := dc.compileNodes(domain, pos+1)
			if err != nil {
				return "", err
			}
			return fmt.Sprintf("(NOT %s)", inner), nil
		}

	case Condition:
		return dc.compileCondition(n)

	case domainGroup:
		// domainGroup wraps a sub-domain as a single node.
		// Compile it recursively as a full domain.
		subSQL, _, err := dc.compileDomainGroup(Domain(n))
		if err != nil {
			return "", err
		}
		return subSQL, nil
	}

	return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
}

// compileDomainGroup compiles a sub-domain that was wrapped via domainGroup.
// It reuses the same DomainCompiler (sharing params and joins) so parameter
// indices stay consistent with the outer query.
func (dc *DomainCompiler) compileDomainGroup(sub Domain) (string, []interface{}, error) {
	if len(sub) == 0 {
		return "TRUE", nil, nil
	}
	sql, err := dc.compileNodes(sub, 0)
	if err != nil {
		return "", nil, err
	}
	return sql, nil, nil
}

func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
	if !validOperators[c.Operator] {
		return "", fmt.Errorf("invalid operator: %q", c.Operator)
	}

	// Handle dot notation (e.g., "partner_id.name")
	parts := strings.Split(c.Field, ".")
	column := parts[0]

	// TODO: Handle JOINs for dot notation paths
	// For now, only support direct fields
	if len(parts) > 1 {
		// Placeholder for JOIN resolution
		return dc.compileJoinedCondition(parts, c.Operator, c.Value)
	}

	return dc.compileSimpleCondition(column, c.Operator, c.Value)
}

func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value Value) (string, error) {
	// Operators that need special handling (not in compileQualifiedCondition)
	switch operator {
	case "child_of":
		return dc.compileHierarchyOp(column, value, true)
	case "parent_of":
		return dc.compileHierarchyOp(column, value, false)
	case "any":
		return dc.compileAnyOp(column, value, false)
	case "not any":
		return dc.compileAnyOp(column, value, true)
	default:
		return dc.compileQualifiedCondition(fmt.Sprintf("%q", column), operator, value)
	}
}

// compileJoinedCondition resolves dot-notation paths (e.g., "partner_id.country_id.code")
// by generating LEFT JOINs through the relational chain.
func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator string, value Value) (string, error) {
	currentModel := dc.model
	currentAlias := dc.model.Table()

	// Walk the path: each segment except the last is a Many2one FK to JOIN through
	for i := 0; i < len(fieldPath)-1; i++ {
		fieldName := fieldPath[i]
		f := currentModel.GetField(fieldName)
		if f == nil {
			return "", fmt.Errorf("field %q not found on %s", fieldName, currentModel.Name())
		}
		if f.Type != TypeMany2one {
			return "", fmt.Errorf("field %q on %s is not Many2one, cannot traverse", fieldName, currentModel.Name())
		}

		comodel := Registry.Get(f.Comodel)
		if comodel == nil {
			return "", fmt.Errorf("comodel %q not found for field %q", f.Comodel, fieldName)
		}

		// Generate alias and JOIN
		dc.aliasCounter++
		alias := fmt.Sprintf("_j%d", dc.aliasCounter)
		dc.joins = append(dc.joins, joinClause{
			table: comodel.Table(),
			alias: alias,
			on:    fmt.Sprintf("%s.%q = %q.\"id\"", currentAlias, f.Column(), alias),
		})

		currentModel = comodel
		currentAlias = alias
	}

	// The last segment is the actual field to filter on
	leafField := fieldPath[len(fieldPath)-1]
	qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField)

	return dc.compileQualifiedCondition(qualifiedColumn, operator, value)
}

// compileQualifiedCondition compiles a condition with a fully qualified column (alias.column).
func (dc *DomainCompiler) compileQualifiedCondition(qualifiedColumn, operator string, value Value) (string, error) {
	paramIdx := len(dc.params) + 1

	switch operator {
	case "=", "!=", "<", ">", "<=", ">=":
		if value == nil || value == false {
			if operator == "=" {
				return fmt.Sprintf("%s IS NULL", qualifiedColumn), nil
			}
			return fmt.Sprintf("%s IS NOT NULL", qualifiedColumn), nil
		}
		dc.params = append(dc.params, value)
		return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil

	case "in", "not in":
		vals := normalizeSlice(value)
		if vals == nil {
			return "FALSE", nil
		}
		if len(vals) == 0 {
			if operator == "in" {
				return "FALSE", nil
			}
			return "TRUE", nil
		}
		placeholders := make([]string, len(vals))
		for i, v := range vals {
			dc.params = append(dc.params, v)
			placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
		}
		op := "IN"
		if operator == "not in" {
			op = "NOT IN"
		}
		return fmt.Sprintf("%s %s (%s)", qualifiedColumn, op, strings.Join(placeholders, ", ")), nil

	case "like", "not like", "ilike", "not ilike":
		dc.params = append(dc.params, wrapLikeValue(value))
		sqlOp := "LIKE"
		switch operator {
		case "not like":
			sqlOp = "NOT LIKE"
		case "ilike":
			sqlOp = "ILIKE"
		case "not ilike":
			sqlOp = "NOT ILIKE"
		}
		return fmt.Sprintf("%s %s $%d", qualifiedColumn, sqlOp, paramIdx), nil

	case "=like":
		dc.params = append(dc.params, value)
		return fmt.Sprintf("%s LIKE $%d", qualifiedColumn, paramIdx), nil

	case "=ilike":
		dc.params = append(dc.params, value)
		return fmt.Sprintf("%s ILIKE $%d", qualifiedColumn, paramIdx), nil

	default:
		dc.params = append(dc.params, value)
		return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil
	}
}

// compileHierarchyOp implements child_of / parent_of by querying the DB for hierarchy IDs.
// Mirrors: odoo/orm/domains.py _expression._get_hierarchy_ids
//
//   - child_of: finds all descendants via parent_id traversal, then "id" IN (...)
//   - parent_of: finds all ancestors via parent_id traversal, then "id" IN (...)
//
// Requires dc.env to be set for DB access.
func (dc *DomainCompiler) compileHierarchyOp(column string, value Value, isChildOf bool) (string, error) {
	if dc.env == nil {
		return "", fmt.Errorf("child_of/parent_of requires Environment on DomainCompiler")
	}

	// Normalize the root ID(s)
	rootIDs := toInt64Slice(value)
	if len(rootIDs) == 0 {
		return "FALSE", nil
	}

	table := dc.model.Table()
	var allIDs map[int64]bool

	if isChildOf {
		// child_of: find all descendants (including roots) via parent_id
		allIDs = make(map[int64]bool)
		queue := make([]int64, len(rootIDs))
		copy(queue, rootIDs)
		for _, id := range rootIDs {
			allIDs[id] = true
		}

		for len(queue) > 0 {
			// Build placeholders for current batch
			placeholders := make([]string, len(queue))
			args := make([]interface{}, len(queue))
			for i, id := range queue {
				args[i] = id
				placeholders[i] = fmt.Sprintf("$%d", i+1)
			}

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

			rows, err := dc.env.tx.Query(dc.env.ctx, query, args...)
			if err != nil {
				return "", fmt.Errorf("child_of query: %w", err)
			}

			var nextQueue []int64
			for rows.Next() {
				var childID int64
				if err := rows.Scan(&childID); err != nil {
					rows.Close()
					return "", err
				}
				if !allIDs[childID] {
					allIDs[childID] = true
					nextQueue = append(nextQueue, childID)
				}
			}
			rows.Close()
			if err := rows.Err(); err != nil {
				return "", err
			}
			queue = nextQueue
		}
	} else {
		// parent_of: find all ancestors (including roots) via parent_id
		allIDs = make(map[int64]bool)
		queue := make([]int64, len(rootIDs))
		copy(queue, rootIDs)
		for _, id := range rootIDs {
			allIDs[id] = true
		}

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

			query := fmt.Sprintf(
				`SELECT "parent_id" FROM %q WHERE "id" IN (%s) AND "parent_id" IS NOT NULL`,
				table, strings.Join(placeholders, ", "),
			)

			rows, err := dc.env.tx.Query(dc.env.ctx, query, args...)
			if err != nil {
				return "", fmt.Errorf("parent_of query: %w", err)
			}

			var nextQueue []int64
			for rows.Next() {
				var parentID int64
				if err := rows.Scan(&parentID); err != nil {
					rows.Close()
					return "", err
				}
				if !allIDs[parentID] {
					allIDs[parentID] = true
					nextQueue = append(nextQueue, parentID)
				}
			}
			rows.Close()
			if err := rows.Err(); err != nil {
				return "", err
			}
			queue = nextQueue
		}
	}

	if len(allIDs) == 0 {
		return "FALSE", nil
	}

	// Build "id" IN (1, 2, 3, ...) with parameters
	paramIdx := len(dc.params) + 1
	placeholders := make([]string, 0, len(allIDs))
	for id := range allIDs {
		dc.params = append(dc.params, id)
		placeholders = append(placeholders, fmt.Sprintf("$%d", paramIdx))
		paramIdx++
	}

	return fmt.Sprintf("%q IN (%s)", column, strings.Join(placeholders, ", ")), nil
}

// compileAnyOp implements 'any' and 'not any' operators.
// Mirrors: odoo/orm/domains.py for 'any' / 'not any' operators
//
//   - any: EXISTS (SELECT 1 FROM comodel WHERE comodel.fk = model.id AND <subdomain>)
//   - not any: NOT EXISTS (...)
//
// The value must be a Domain (sub-domain) to apply on the comodel.
func (dc *DomainCompiler) compileAnyOp(column string, value Value, negate bool) (string, error) {
	// Resolve the field to find the comodel
	f := dc.model.GetField(column)
	if f == nil {
		return "", fmt.Errorf("any/not any: field %q not found on %s", column, dc.model.Name())
	}

	comodel := Registry.Get(f.Comodel)
	if comodel == nil {
		return "", fmt.Errorf("any/not any: comodel %q not found for field %q", f.Comodel, column)
	}

	// The value should be a Domain (sub-domain for the comodel)
	subDomain, ok := value.(Domain)
	if !ok {
		return "", fmt.Errorf("any/not any: value must be a Domain, got %T", value)
	}

	// Compile the sub-domain against the comodel
	subCompiler := &DomainCompiler{model: comodel, env: dc.env}
	subWhere, subParams, err := subCompiler.Compile(subDomain)
	if err != nil {
		return "", fmt.Errorf("any/not any: compile subdomain: %w", err)
	}

	// Rebase parameter indices: shift them by the current param count
	baseIdx := len(dc.params)
	dc.params = append(dc.params, subParams...)
	rebased := subWhere
	// Replace $N with $(N+baseIdx) in the sub-where clause
	for i := len(subParams); i >= 1; i-- {
		old := fmt.Sprintf("$%d", i)
		new := fmt.Sprintf("$%d", i+baseIdx)
		rebased = strings.ReplaceAll(rebased, old, new)
	}

	// Determine the join condition based on field type
	var joinCond string
	switch f.Type {
	case TypeOne2many:
		// One2many: comodel has a FK pointing back to this model
		inverseField := f.InverseField
		if inverseField == "" {
			return "", fmt.Errorf("any/not any: One2many field %q has no InverseField", column)
		}
		inverseF := comodel.GetField(inverseField)
		if inverseF == nil {
			return "", fmt.Errorf("any/not any: inverse field %q not found on %s", inverseField, comodel.Name())
		}
		joinCond = fmt.Sprintf("%q.%q = %q.\"id\"", comodel.Table(), inverseF.Column(), dc.model.Table())

	case TypeMany2many:
		// Many2many: use junction table
		relation := f.Relation
		if relation == "" {
			t1, t2 := dc.model.Table(), comodel.Table()
			if t1 > t2 {
				t1, t2 = t2, t1
			}
			relation = fmt.Sprintf("%s_%s_rel", t1, t2)
		}
		col1 := f.Column1
		if col1 == "" {
			col1 = dc.model.Table() + "_id"
		}
		col2 := f.Column2
		if col2 == "" {
			col2 = comodel.Table() + "_id"
		}
		joinCond = fmt.Sprintf(
			"%q.\"id\" IN (SELECT %q FROM %q WHERE %q = %q.\"id\")",
			comodel.Table(), col2, relation, col1, dc.model.Table(),
		)

	case TypeMany2one:
		// Many2one: this model has the FK
		joinCond = fmt.Sprintf("%q.\"id\" = %q.%q", comodel.Table(), dc.model.Table(), f.Column())

	default:
		return "", fmt.Errorf("any/not any: field %q is type %s, expected relational", column, f.Type)
	}

	subJoins := subCompiler.JoinSQL()
	prefix := "EXISTS"
	if negate {
		prefix = "NOT EXISTS"
	}

	return fmt.Sprintf("%s (SELECT 1 FROM %q%s WHERE %s AND %s)",
		prefix, comodel.Table(), subJoins, joinCond, rebased,
	), nil
}

// toInt64Slice normalizes a value to []int64 for hierarchy operators.
func toInt64Slice(value Value) []int64 {
	switch v := value.(type) {
	case int64:
		return []int64{v}
	case int:
		return []int64{int64(v)}
	case int32:
		return []int64{int64(v)}
	case float64:
		return []int64{int64(v)}
	case []int64:
		return v
	case []int:
		out := make([]int64, len(v))
		for i, x := range v {
			out[i] = int64(x)
		}
		return out
	case []interface{}:
		out := make([]int64, 0, len(v))
		for _, x := range v {
			switch n := x.(type) {
			case int64:
				out = append(out, n)
			case int:
				out = append(out, int64(n))
			case float64:
				out = append(out, int64(n))
			}
		}
		return out
	}
	return nil
}

// normalizeSlice converts typed slices to []interface{} for IN/NOT IN operators.
func normalizeSlice(value Value) []interface{} {
	switch v := value.(type) {
	case []interface{}:
		return v
	case []int64:
		out := make([]interface{}, len(v))
		for i, x := range v {
			out[i] = x
		}
		return out
	case []float64:
		out := make([]interface{}, len(v))
		for i, x := range v {
			out[i] = x
		}
		return out
	case []string:
		out := make([]interface{}, len(v))
		for i, x := range v {
			out[i] = x
		}
		return out
	case []int:
		out := make([]interface{}, len(v))
		for i, x := range v {
			out[i] = x
		}
		return out
	}
	return nil
}

// wrapLikeValue wraps a string value with % wildcards for LIKE/ILIKE operators,
// matching Odoo's behavior where ilike/like auto-wrap the search term.
// If the value already contains %, it is left as-is.
// Mirrors: odoo/orm/domains.py _expression._unaccent_wrap (value wrapping)
func wrapLikeValue(value Value) Value {
	s, ok := value.(string)
	if !ok {
		return value
	}
	if strings.Contains(s, "%") || strings.Contains(s, "_") {
		return value // Already has wildcards, leave as-is
	}
	return "%" + s + "%"
}
