package orm

import (
	"fmt"
	"log"
)

// ApplyRecordRules adds ir.rule domain filters to a search.
// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain()
//
// Rules work as follows:
// - Global rules (no groups) are AND-ed together
// - Group rules (user belongs to one of the rule's groups) are OR-ed together
// - The final domain is: original AND global_rules AND (group_rule_1 OR group_rule_2 OR ...)
//
// Implementation:
// 1. Built-in company filter (for models with company_id)
// 2. Custom ir.rule records loaded from the database, domain_force parsed
func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
	if env.su {
		return domain // Superuser bypasses record rules
	}

	// 1. Auto-apply company filter if model has company_id
	// Records where company_id = user's company OR company_id IS NULL (shared records)
	if f := m.GetField("company_id"); f != nil && f.Type == TypeMany2one {
		myCompany := Leaf("company_id", "=", env.CompanyID())
		noCompany := Leaf("company_id", "=", nil)
		companyFilter := Or(myCompany, noCompany)
		if len(domain) == 0 {
			domain = companyFilter
		} else {
			// AND the company filter with existing domain
			result := Domain{OpAnd}
			result = append(result, domain...)
			result = append(result, companyFilter...)
			domain = result
		}
	}

	// 2. Load ir.rule records from DB
	// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._get_rules() + _compute_domain()
	//
	// Query rules that apply to this model for the current user:
	// - Rule must be active and have perm_read = true
	// - Either the rule is global (no groups assigned),
	//   or the user belongs to one of the rule's groups via rule_group_rel.
	// Use a savepoint so that a failed query (e.g., missing table) doesn't abort the parent tx.
	sp, spErr := env.tx.Begin(env.ctx)
	if spErr != nil {
		return domain
	}

	rows, err := sp.Query(env.ctx,
		`SELECT r.id, r.domain_force, COALESCE(r."global", false) AS is_global
		 FROM ir_rule r
		 JOIN ir_model m ON m.id = r.model_id
		 WHERE m.model = $1
		   AND r.active = true
		   AND r.perm_read = true
		   AND (
		       r."global" = true
		       OR r.id IN (
		           SELECT rg.rule_group_id
		           FROM rule_group_rel rg
		           JOIN res_groups_users_rel gu ON gu.gid = rg.group_id
		           WHERE gu.uid = $2
		       )
		   )
		 ORDER BY r.id`,
		m.Name(), env.UID())
	if err != nil {
		sp.Rollback(env.ctx)
		return domain
	}

	type ruleRow struct {
		id          int64
		domainForce *string
		global      bool
	}
	var rules []ruleRow

	for rows.Next() {
		var r ruleRow
		if err := rows.Scan(&r.id, &r.domainForce, &r.global); err != nil {
			continue
		}
		rules = append(rules, r)
	}
	rows.Close()
	if err := sp.Commit(env.ctx); err != nil {
		// Non-fatal: rules already read
		_ = err
	}

	if len(rules) == 0 {
		return domain
	}

	// Parse domain_force strings and split into global vs. group rules.
	// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain()
	//   global rules  → AND together
	//   group rules   → OR together
	//   final = original AND all_global AND (group_1 OR group_2 OR ...)
	var globalDomains []DomainNode
	var groupDomains []DomainNode
	parseErrors := 0

	for _, r := range rules {
		if r.domainForce == nil || *r.domainForce == "" || *r.domainForce == "[]" {
			// Empty domain_force = match everything, skip
			continue
		}

		parsed, err := ParseDomainString(*r.domainForce, env)
		if err != nil {
			parseErrors++
			log.Printf("orm: failed to parse domain_force for ir.rule %d: %v (raw: %s)", r.id, err, *r.domainForce)
			continue
		}
		if len(parsed) == 0 {
			continue
		}

		if r.global {
			// Global rule: wrap as a single node for AND-ing
			globalDomains = append(globalDomains, domainAsNode(parsed))
		} else {
			// Group rule: wrap as a single node for OR-ing
			groupDomains = append(groupDomains, domainAsNode(parsed))
		}
	}

	if parseErrors > 0 {
		log.Printf("orm: %d ir.rule domain_force parse error(s) for %s", parseErrors, m.Name())
	}

	// Merge group domains with OR
	if len(groupDomains) > 0 {
		orDomain := Or(groupDomains...)
		globalDomains = append(globalDomains, domainAsNode(orDomain))
	}

	// AND all rule domains into the original domain
	if len(globalDomains) > 0 {
		ruleDomain := And(globalDomains...)
		if len(domain) == 0 {
			domain = ruleDomain
		} else {
			result := Domain{OpAnd}
			result = append(result, domain...)
			result = append(result, ruleDomain...)
			domain = result
		}
	}

	return domain
}

// domainAsNode wraps a Domain (which is a []DomainNode) into a single DomainNode
// so it can be used as an operand for And() / Or().
// If the domain has a single node, return it directly.
// If multiple nodes, wrap in a domainGroup.
func domainAsNode(d Domain) DomainNode {
	if len(d) == 1 {
		return d[0]
	}
	return domainGroup(d)
}

// domainGroup wraps a Domain as a single DomainNode for use in And()/Or() combinations.
// When compiled, it produces the same SQL as the contained domain.
type domainGroup Domain

func (dg domainGroup) isDomainNode() {}

// CheckRecordRuleAccess verifies the user can access specific record IDs.
// Returns an error if any record is not accessible.
func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string) error {
	if env.su || len(ids) == 0 {
		return nil
	}

	// Check company_id if the model has it
	f := m.GetField("company_id")
	if f == nil || f.Type != TypeMany2one {
		return nil
	}

	// Count records that match the company filter
	placeholders := make([]string, len(ids))
	args := make([]interface{}, len(ids))
	for i, id := range ids {
		args[i] = id
		placeholders[i] = fmt.Sprintf("$%d", i+1)
	}
	args = append(args, env.CompanyID())

	query := fmt.Sprintf(
		`SELECT COUNT(*) FROM %q WHERE "id" IN (%s) AND ("company_id" = $%d OR "company_id" IS NULL)`,
		m.Table(),
		joinStrings(placeholders, ", "),
		len(ids)+1,
	)

	var count int64
	err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count)
	if err != nil {
		return nil // Fail open on error
	}

	if count < int64(len(ids)) {
		return fmt.Errorf("orm: access denied by record rules on %s (company filter)", m.Name())
	}
	return nil
}
