// Package server implements the HTTP server and RPC dispatch.
// Mirrors: odoo/http.py, odoo/service/server.py
package server

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"strings"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"

	"odoo-go/pkg/orm"
	"odoo-go/pkg/tools"
)

// Server is the main Odoo HTTP server.
// Mirrors: odoo/service/server.py ThreadedServer
type Server struct {
	config   *tools.Config
	pool     *pgxpool.Pool
	mux      *http.ServeMux
	sessions *SessionStore

	// xmlTemplateBundle is the JS source for the compiled XML templates,
	// generated at startup by compileXMLTemplates(). It replaces the
	// pre-compiled build/js/web/static/src/xml_templates_bundle.js that
	// was previously produced by tools/compile_templates.py.
	xmlTemplateBundle string

	// jsBundle is the concatenated JS bundle built at startup. It contains
	// all JS files (except module_loader.js) plus the XML template bundle,
	// served as a single file to avoid hundreds of individual HTTP requests.
	jsBundle string
}

// New creates a new server instance.
func New(cfg *tools.Config, pool *pgxpool.Pool) *Server {
	s := &Server{
		config:   cfg,
		pool:     pool,
		mux:      http.NewServeMux(),
		sessions: NewSessionStore(24*time.Hour, pool),
	}

	// Compile XML templates to JS at startup, replacing the Python build step.
	log.Println("odoo: compiling XML templates...")
	s.xmlTemplateBundle = compileXMLTemplates(cfg.FrontendDir)

	// Concatenate all JS files into a single bundle served at /web/assets/bundle.js.
	// This reduces ~539 individual <script> requests to 1.
	log.Println("odoo: building JS bundle...")
	s.jsBundle = buildJSBundle(cfg, s.xmlTemplateBundle)

	s.registerRoutes()
	return s
}

// registerRoutes sets up HTTP routes.
// Mirrors: odoo/http.py Application._setup_routes()
func (s *Server) registerRoutes() {
	// Webclient HTML shell
	s.mux.HandleFunc("/web", s.handleWebClient)
	s.mux.HandleFunc("/web/", s.handleWebRoute)
	s.mux.HandleFunc("/odoo", s.handleWebClient)
	s.mux.HandleFunc("/odoo/", func(w http.ResponseWriter, r *http.Request) {
		// /odoo/base/static/... → serve static files
		if strings.Contains(r.URL.Path, "/static/") {
			s.handleStatic(w, r)
			return
		}
		s.handleWebClient(w, r)
	})

	// Login page
	s.mux.HandleFunc("/web/login", s.handleLogin)

	// JSON-RPC endpoint (main API)
	s.mux.HandleFunc("/jsonrpc", s.handleJSONRPC)
	s.mux.HandleFunc("/web/dataset/call_kw", s.handleCallKW)
	s.mux.HandleFunc("/web/dataset/call_kw/", s.handleCallKW)
	s.mux.HandleFunc("/web/dataset/call_button", s.handleCallKW)  // call_button uses same dispatch as call_kw
	s.mux.HandleFunc("/web/dataset/call_button/", s.handleCallKW) // with model/method suffix

	// Session endpoints
	s.mux.HandleFunc("/web/session/authenticate", s.handleAuthenticate)
	s.mux.HandleFunc("/web/session/get_session_info", s.handleSessionInfo)
	s.mux.HandleFunc("/web/session/check", s.handleSessionCheck)
	s.mux.HandleFunc("/web/session/modules", s.handleSessionModules)

	// Webclient endpoints
	s.mux.HandleFunc("/web/webclient/load_menus", s.handleLoadMenus)
	s.mux.HandleFunc("/web/webclient/translations", s.handleTranslations)
	s.mux.HandleFunc("/web/webclient/version_info", s.handleVersionInfo)
	s.mux.HandleFunc("/web/webclient/bootstrap_translations", s.handleBootstrapTranslations)

	// Action loading
	s.mux.HandleFunc("/web/action/load", s.handleActionLoad)
	s.mux.HandleFunc("/web/action/load_breadcrumbs", s.handleLoadBreadcrumbs)
	s.mux.HandleFunc("/web/action/run", s.handleActionRun)

	// Model definitions
	s.mux.HandleFunc("/web/model/get_definitions", s.handleGetDefinitions)

	// Database endpoints
	s.mux.HandleFunc("/web/database/list", s.handleDBList)

	// Database manager (mirrors Python Odoo's /web/database/manager)
	s.mux.HandleFunc("/web/database/manager", s.handleDatabaseManager)
	s.mux.HandleFunc("/web/database/create", s.handleDatabaseCreate)

	// Image serving (placeholder for uploaded images)
	s.mux.HandleFunc("/web/image", s.handleImage)
	s.mux.HandleFunc("/web/image/", s.handleImage)

	// Concatenated JS bundle (all modules in one file)
	s.mux.HandleFunc("/web/assets/bundle.js", s.handleJSBundle)

	// PWA manifest
	s.mux.HandleFunc("/web/manifest.webmanifest", s.handleManifest)

	// File upload and download
	s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload)
	s.mux.HandleFunc("/web/content/", s.handleContent)

	// CSV export
	s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)

	// Reports (HTML and PDF report rendering)
	s.mux.HandleFunc("/report/", s.handleReport)
	s.mux.HandleFunc("/report/html/", s.handleReport)
	s.mux.HandleFunc("/report/pdf/", s.handleReportPDF)

	// Logout & Account
	s.mux.HandleFunc("/web/session/logout", s.handleLogout)
	s.mux.HandleFunc("/web/session/account", s.handleSessionAccount)

	// Health check
	s.mux.HandleFunc("/health", s.handleHealth)

	// Static files (catch-all for /<addon>/static/...)
	// NOTE: must be last since it's a broad pattern
}

// handleWebRoute dispatches /web/* sub-routes or falls back to static files.
func (s *Server) handleWebRoute(w http.ResponseWriter, r *http.Request) {
	path := r.URL.Path

	// Known sub-routes are handled by specific handlers above.
	// Anything under /web/static/ is a static file request.
	if strings.HasPrefix(path, "/web/static/") {
		s.handleStatic(w, r)
		return
	}

	// For all other /web/* paths, serve the webclient (SPA routing)
	s.handleWebClient(w, r)
}

// Start starts the HTTP server.
func (s *Server) Start() error {
	addr := fmt.Sprintf("%s:%d", s.config.HTTPInterface, s.config.HTTPPort)
	log.Printf("odoo: HTTP service running on %s", addr)

	srv := &http.Server{
		Addr:         addr,
		Handler:      LoggingMiddleware(AuthMiddleware(s.sessions, s.mux)),
		ReadTimeout:  30 * time.Second,
		WriteTimeout: 30 * time.Second,
		IdleTimeout:  120 * time.Second,
	}

	return srv.ListenAndServe()
}

// --- JSON-RPC ---
// Mirrors: odoo/http.py JsonRPCDispatcher

// JSONRPCRequest is the JSON-RPC 2.0 request format.
type JSONRPCRequest struct {
	JSONRPC string          `json:"jsonrpc"`
	Method  string          `json:"method"`
	ID      interface{}     `json:"id"`
	Params  json.RawMessage `json:"params"`
}

// JSONRPCResponse is the JSON-RPC 2.0 response format.
type JSONRPCResponse struct {
	JSONRPC string      `json:"jsonrpc"`
	ID      interface{} `json:"id"`
	Result  interface{} `json:"result,omitempty"`
	Error   *RPCError   `json:"error,omitempty"`
}

// RPCError represents a JSON-RPC error.
type RPCError struct {
	Code    int         `json:"code"`
	Message string      `json:"message"`
	Data    interface{} `json:"data,omitempty"`
}

// CallKWParams mirrors the /web/dataset/call_kw parameters.
type CallKWParams struct {
	Model  string        `json:"model"`
	Method string        `json:"method"`
	Args   []interface{} `json:"args"`
	KW     Values        `json:"kwargs"`
}

// Values is a generic key-value map for RPC parameters.
type Values = map[string]interface{}

func (s *Server) handleJSONRPC(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req JSONRPCRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		s.writeJSONRPC(w, req.ID, nil, &RPCError{
			Code: -32700, Message: "Parse error",
		})
		return
	}

	// Dispatch based on method
	s.writeJSONRPC(w, req.ID, map[string]string{"status": "ok"}, nil)
}

// handleCallKW handles ORM method calls via JSON-RPC.
// Mirrors: odoo/service/model.py execute_kw()
func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req JSONRPCRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		s.writeJSONRPC(w, req.ID, nil, &RPCError{
			Code: -32700, Message: "Parse error",
		})
		return
	}

	var params CallKWParams
	if err := json.Unmarshal(req.Params, &params); err != nil {
		s.writeJSONRPC(w, req.ID, nil, &RPCError{
			Code: -32602, Message: "Invalid params",
		})
		return
	}

	// Extract UID from session, default to 1 (admin) if no session
	uid := int64(1)
	companyID := int64(1)
	if sess := GetSession(r); sess != nil {
		uid = sess.UID
		companyID = sess.CompanyID
	}

	// Create environment for this request
	env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
		Pool:      s.pool,
		UID:       uid,
		CompanyID: companyID,
	})
	if err != nil {
		s.writeJSONRPC(w, req.ID, nil, &RPCError{
			Code: -32000, Message: err.Error(),
		})
		return
	}
	defer env.Close()

	// Dispatch ORM method
	result, rpcErr := s.dispatchORM(env, params)
	if rpcErr != nil {
		s.writeJSONRPC(w, req.ID, nil, rpcErr)
		return
	}

	if err := env.Commit(); err != nil {
		s.writeJSONRPC(w, req.ID, nil, &RPCError{
			Code: -32000, Message: err.Error(),
		})
		return
	}

	s.writeJSONRPC(w, req.ID, result, nil)
}

// checkAccess verifies the current user has permission for the operation.
// Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check()
func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCError {
	if env.IsSuperuser() || env.UID() == 1 {
		return nil // Superuser bypasses all checks
	}

	perm := "perm_read"
	switch method {
	case "create":
		perm = "perm_create"
	case "write":
		perm = "perm_write"
	case "unlink":
		perm = "perm_unlink"
	}

	// Check if any ACL exists for this model
	var count int64
	err := env.Tx().QueryRow(env.Ctx(),
		`SELECT COUNT(*) FROM ir_model_access a
		 JOIN ir_model m ON m.id = a.model_id
		 WHERE m.model = $1`, model).Scan(&count)
	if err != nil || count == 0 {
		return nil // No ACLs defined → open access (like Odoo superuser mode)
	}

	// Check if user's groups grant permission
	var granted bool
	err = env.Tx().QueryRow(env.Ctx(), fmt.Sprintf(`
		SELECT EXISTS(
			SELECT 1 FROM ir_model_access a
			JOIN ir_model m ON m.id = a.model_id
			LEFT JOIN res_groups_res_users_rel gu ON gu.res_groups_id = a.group_id
			WHERE m.model = $1
			AND a.active = true
			AND a.%s = true
			AND (a.group_id IS NULL OR gu.res_users_id = $2)
		)`, perm), model, env.UID()).Scan(&granted)
	if err != nil {
		return nil // On error, allow (fail-open for now)
	}
	if !granted {
		return &RPCError{
			Code:    403,
			Message: fmt.Sprintf("Access Denied: %s on %s", method, model),
		}
	}
	return nil
}

// dispatchORM dispatches an ORM method call.
// Mirrors: odoo/service/model.py call_kw()
func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interface{}, *RPCError) {
	// Check access control
	if err := s.checkAccess(env, params.Model, params.Method); err != nil {
		return nil, err
	}

	// Handle ir.actions.report RPC calls (e.g., Print button on invoices).
	// Mirrors: odoo/addons/base/models/ir_actions_report.py IrActionsReport.report_action()
	if params.Model == "ir.actions.report" && params.Method == "report_action" {
		if len(params.Args) > 0 {
			reportName, _ := params.Args[0].(string)
			return map[string]interface{}{
				"type":        "ir.actions.report",
				"report_name": reportName,
				"report_type": "qweb-html",
			}, nil
		}
	}

	// If model is "ir.http", handle special routing methods
	if params.Model == "ir.http" {
		switch params.Method {
		case "session_info":
			// Return session info - already handled by session endpoint
			return map[string]interface{}{}, nil
		}
	}

	rs := env.Model(params.Model)

	switch params.Method {
	case "has_group":
		// Always return true for admin user, stub for now
		return true, nil

	case "check_access_rights":
		return true, nil

	case "fields_get":
		return fieldsGetForModel(params.Model), nil

	case "web_read_group", "read_group":
		return s.handleReadGroup(rs, params)

	case "web_search_read":
		return handleWebSearchRead(env, params.Model, params)

	case "web_read":
		return handleWebRead(env, params.Model, params)

	case "web_save":
		// Combined create-or-update used by the Odoo web client.
		// Mirrors: odoo/addons/web/models/models.py web_save()
		ids := parseIDs(params.Args)
		vals := parseValuesAt(params.Args, 1)
		spec, _ := params.KW["specification"].(map[string]interface{})

		if len(ids) > 0 && ids[0] > 0 {
			// Update existing record(s)
			err := rs.Browse(ids...).Write(vals)
			if err != nil {
				return nil, &RPCError{Code: -32000, Message: err.Error()}
			}
		} else {
			// Create new record
			created, err := rs.Create(vals)
			if err != nil {
				return nil, &RPCError{Code: -32000, Message: err.Error()}
			}
			ids = created.IDs()
		}

		// Return the saved record via web_read format
		readParams := CallKWParams{
			Model:  params.Model,
			Method: "web_read",
			Args:   []interface{}{ids},
			KW:     map[string]interface{}{"specification": spec},
		}
		return handleWebRead(env, params.Model, readParams)

	case "get_views":
		return handleGetViews(env, params.Model, params)

	case "onchange":
		// Return default values and run onchange handlers for changed fields.
		// Mirrors: odoo/orm/models.py BaseModel.onchange()
		model := orm.Registry.Get(params.Model)
		defaults := make(orm.Values)
		if model != nil {
			orm.ApplyDefaults(model, defaults)
			// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
			if model.DefaultGet != nil {
				for k, v := range model.DefaultGet(env, nil) {
					if _, exists := defaults[k]; !exists {
						defaults[k] = v
					}
				}
			}
			// Run onchange handlers for changed fields
			// args[1] = current record values, args[2] = list of changed field names
			if len(params.Args) >= 3 {
				if vals, ok := params.Args[1].(map[string]interface{}); ok {
					if fieldNames, ok := params.Args[2].([]interface{}); ok {
						var changed []string
						for _, fn := range fieldNames {
							fname, _ := fn.(string)
							changed = append(changed, fname)
							if handler, exists := model.OnchangeHandlers[fname]; exists {
								updates := handler(env, vals)
								for k, v := range updates {
									defaults[k] = v
								}
							}
						}
						// Run computed fields that depend on changed fields
						computed := orm.RunOnchangeComputes(model, env, vals, changed)
						for k, v := range computed {
							defaults[k] = v
						}
					}
				}
			}
		}
		return map[string]interface{}{
			"value": defaults,
		}, nil

	case "default_get":
		// Return default values for the requested fields.
		// Mirrors: odoo/orm/models.py BaseModel.default_get()
		model := orm.Registry.Get(params.Model)
		defaults := make(orm.Values)
		if model != nil {
			orm.ApplyDefaults(model, defaults)
			// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
			if model.DefaultGet != nil {
				for k, v := range model.DefaultGet(env, nil) {
					if _, exists := defaults[k]; !exists {
						defaults[k] = v
					}
				}
			}
		}
		return defaults, nil

	case "search_read":
		domain := parseDomain(params.Args)
		fields := parseFields(params.KW)
		records, err := rs.SearchRead(domain, fields)
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		return records, nil

	case "read":
		ids := parseIDs(params.Args)
		fields := parseFields(params.KW)
		records, err := rs.Browse(ids...).Read(fields)
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		return records, nil

	case "create":
		vals := parseValues(params.Args)
		record, err := rs.Create(vals)
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		return record.ID(), nil

	case "write":
		ids := parseIDs(params.Args)
		vals := parseValuesAt(params.Args, 1)
		err := rs.Browse(ids...).Write(vals)
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		return true, nil

	case "unlink":
		ids := parseIDs(params.Args)
		err := rs.Browse(ids...).Unlink()
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		return true, nil

	case "search":
		domain := parseDomain(params.Args)
		opts := orm.SearchOpts{}
		if v, ok := params.KW["limit"].(float64); ok {
			opts.Limit = int(v)
		}
		if v, ok := params.KW["offset"].(float64); ok {
			opts.Offset = int(v)
		}
		if v, ok := params.KW["order"].(string); ok {
			opts.Order = v
		}
		found, err := rs.Search(domain, opts)
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		return found.IDs(), nil

	case "search_count":
		domain := parseDomain(params.Args)
		count, err := rs.SearchCount(domain)
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		return count, nil

	case "name_get":
		ids := parseIDs(params.Args)
		names, err := rs.Browse(ids...).NameGet()
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		// Convert map to Odoo format: [[id, "name"], ...]
		var result [][]interface{}
		for id, name := range names {
			result = append(result, []interface{}{id, name})
		}
		return result, nil

	case "name_search":
		// name_search: search by name, return [[id, "name"], ...]
		// Mirrors: odoo/orm/models.py BaseModel._name_search()
		nameStr := ""
		if len(params.Args) > 0 {
			nameStr, _ = params.Args[0].(string)
		}
		// Also accept name from kwargs
		if nameStr == "" {
			if v, ok := params.KW["name"].(string); ok {
				nameStr = v
			}
		}

		limit := defaultNameSearchLimit
		if v, ok := params.KW["limit"].(float64); ok {
			limit = int(v)
		}

		operator := "ilike"
		if v, ok := params.KW["operator"].(string); ok {
			operator = v
		}

		// Build domain: name condition + additional args domain
		var nodes []orm.DomainNode
		if nameStr != "" {
			nodes = append(nodes, orm.Leaf("name", operator, nameStr))
		}
		// Parse extra domain from kwargs "args"
		if extraDomainRaw, ok := params.KW["args"].([]interface{}); ok && len(extraDomainRaw) > 0 {
			extraDomain := parseDomain([]interface{}{extraDomainRaw})
			for _, n := range extraDomain {
				nodes = append(nodes, n)
			}
		}

		domain := orm.And(nodes...)
		found, err := rs.Search(domain, orm.SearchOpts{Limit: limit})
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		names, err := found.NameGet()
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		var nameResult [][]interface{}
		for id, name := range names {
			nameResult = append(nameResult, []interface{}{id, name})
		}
		if nameResult == nil {
			nameResult = [][]interface{}{}
		}
		return nameResult, nil

	case "get_formview_action":
		ids := parseIDs(params.Args)
		if len(ids) == 0 {
			return false, nil
		}
		return map[string]interface{}{
			"type":      "ir.actions.act_window",
			"res_model": params.Model,
			"res_id":    ids[0],
			"view_mode": "form",
			"views":     [][]interface{}{{nil, "form"}},
			"target":    "current",
		}, nil

	case "get_formview_id":
		return false, nil

	case "action_get":
		return false, nil

	case "name_create":
		nameStr := ""
		if len(params.Args) > 0 {
			nameStr, _ = params.Args[0].(string)
		}
		if nameStr == "" {
			return nil, &RPCError{Code: -32000, Message: "name_create requires a name"}
		}
		created, err := rs.Create(orm.Values{"name": nameStr})
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		return []interface{}{created.ID(), nameStr}, nil

	case "read_progress_bar":
		return map[string]interface{}{}, nil

	case "activity_format":
		return []interface{}{}, nil

	case "action_archive":
		ids := parseIDs(params.Args)
		if len(ids) > 0 {
			rs.Browse(ids...).Write(orm.Values{"active": false})
		}
		return true, nil

	case "action_unarchive":
		ids := parseIDs(params.Args)
		if len(ids) > 0 {
			rs.Browse(ids...).Write(orm.Values{"active": true})
		}
		return true, nil

	case "copy":
		ids := parseIDs(params.Args)
		if len(ids) == 0 {
			return nil, &RPCError{Code: -32000, Message: "No record to copy"}
		}
		// Parse optional default overrides from args[1]
		defaults := parseValuesAt(params.Args, 1)
		created, err := rs.Browse(ids[0]).Copy(defaults)
		if err != nil {
			return nil, &RPCError{Code: -32000, Message: err.Error()}
		}
		return created.ID(), nil

	default:
		// Try registered business methods on the model.
		// Mirrors: odoo/service/model.py call_kw() + odoo/addons/web/controllers/dataset.py call_button()
		model := orm.Registry.Get(params.Model)
		if model != nil && model.Methods != nil {
			if method, ok := model.Methods[params.Method]; ok {
				ids := parseIDs(params.Args)
				result, err := method(rs.Browse(ids...), params.Args[1:]...)
				if err != nil {
					return nil, &RPCError{Code: -32000, Message: err.Error()}
				}
				// If the method returns an action dict (map with "type" key),
				// return it directly so the web client can navigate.
				// Mirrors: odoo/addons/web/controllers/dataset.py call_button()
				if actionMap, ok := result.(map[string]interface{}); ok {
					if _, hasType := actionMap["type"]; hasType {
						return actionMap, nil
					}
				}
				// If result is true or nil, return false (meaning "reload current view")
				if result == nil || result == true {
					return false, nil
				}
				return result, nil
			}
		}
		return nil, &RPCError{
			Code:    -32601,
			Message: fmt.Sprintf("Method %q not found on %s", params.Method, params.Model),
		}
	}
}

// --- Session / Auth Endpoints ---

func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req JSONRPCRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
		return
	}

	var params struct {
		DB       string `json:"db"`
		Login    string `json:"login"`
		Password string `json:"password"`
	}
	if err := json.Unmarshal(req.Params, &params); err != nil {
		s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
		return
	}

	// Query user by login
	var uid int64
	var companyID int64
	var partnerID int64
	var hashedPw string
	var userName string

	err := s.pool.QueryRow(r.Context(),
		`SELECT u.id, u.password, u.company_id, u.partner_id, p.name
		 FROM res_users u
		 JOIN res_partner p ON p.id = u.partner_id
		 WHERE u.login = $1 AND u.active = true`,
		params.Login,
	).Scan(&uid, &hashedPw, &companyID, &partnerID, &userName)

	if err != nil {
		s.writeJSONRPC(w, req.ID, nil, &RPCError{
			Code: 100, Message: "Access Denied: invalid login or password",
		})
		return
	}

	// Check password (support both bcrypt and plaintext for migration)
	if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password {
		s.writeJSONRPC(w, req.ID, nil, &RPCError{
			Code: 100, Message: "Access Denied: invalid login or password",
		})
		return
	}

	// Create session
	sess := s.sessions.New(uid, companyID, params.Login)

	// Set session cookie
	http.SetCookie(w, &http.Cookie{
		Name:     "session_id",
		Value:    sess.ID,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	})

	s.writeJSONRPC(w, req.ID, map[string]interface{}{
		"uid":                  uid,
		"session_id":           sess.ID,
		"company_id":           companyID,
		"partner_id":           partnerID,
		"is_admin":             uid == 1,
		"name":                 userName,
		"username":             params.Login,
		"server_version":       "19.0-go",
		"server_version_info":  []interface{}{19, 0, 0, "final", 0, "g"},
		"db":                   s.config.DBName,
	}, nil)
}

func (s *Server) handleSessionInfo(w http.ResponseWriter, r *http.Request) {
	// Try context first, then fall back to cookie lookup
	sess := GetSession(r)
	if sess == nil {
		if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" {
			sess = s.sessions.Get(cookie.Value)
		}
	}
	if sess == nil {
		s.writeJSONRPC(w, nil, nil, &RPCError{
			Code: 100, Message: "Session expired",
		})
		return
	}
	s.writeJSONRPC(w, nil, s.buildSessionInfo(sess), nil)
}

func (s *Server) handleDBList(w http.ResponseWriter, r *http.Request) {
	s.writeJSONRPC(w, nil, []string{s.config.DBName}, nil)
}

func (s *Server) handleVersionInfo(w http.ResponseWriter, r *http.Request) {
	s.writeJSONRPC(w, nil, map[string]interface{}{
		"server_version":      "19.0-go",
		"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
		"server_serie":        "19.0",
		"protocol_version":    1,
	}, nil)
}

// handleSessionAccount returns a local URL instead of odoo.com.
func (s *Server) handleSessionAccount(w http.ResponseWriter, r *http.Request) {
	s.writeJSONRPC(w, nil, "/odoo/action-100", nil)
}

// handleLogout destroys the session and redirects to login.
// Mirrors: odoo/addons/web/controllers/home.py Home.web_logout()
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
	if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" {
		s.sessions.Delete(cookie.Value)
	}
	http.SetCookie(w, &http.Cookie{
		Name:     "session_id",
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
	})
	http.Redirect(w, r, "/web/login", http.StatusFound)
}

func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
	err := s.pool.Ping(context.Background())
	if err != nil {
		http.Error(w, "unhealthy", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
	fmt.Fprint(w, "ok")
}

// --- Helpers ---

func (s *Server) writeJSONRPC(w http.ResponseWriter, id interface{}, result interface{}, rpcErr *RPCError) {
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Content-Type", "application/json")
	resp := JSONRPCResponse{
		JSONRPC: "2.0",
		ID:      id,
		Result:  result,
		Error:   rpcErr,
	}
	json.NewEncoder(w).Encode(resp)
}

// parseDomain converts JSON-RPC domain args to orm.Domain.
// JSON format: [["field", "op", value], ...] or ["&", ["field", "op", value], ...]
func parseDomain(args []interface{}) orm.Domain {
	if len(args) == 0 {
		return nil
	}

	// First arg should be the domain list
	domainRaw, ok := args[0].([]interface{})
	if !ok {
		return nil
	}
	if len(domainRaw) == 0 {
		return nil
	}

	var nodes []orm.DomainNode
	for _, item := range domainRaw {
		switch v := item.(type) {
		case string:
			// Operator: "&", "|", "!"
			nodes = append(nodes, orm.Operator(v))
		case []interface{}:
			// Leaf: ["field", "op", value]
			if len(v) == 3 {
				field, _ := v[0].(string)
				op, _ := v[1].(string)
				nodes = append(nodes, orm.Leaf(field, op, v[2]))
			}
		}
	}

	// If we have multiple leaves without explicit operators, AND them together
	// (Odoo default: implicit AND between leaves)
	var leaves []orm.DomainNode
	for _, n := range nodes {
		leaves = append(leaves, n)
	}

	if len(leaves) == 0 {
		return nil
	}
	return orm.Domain(leaves)
}

func parseIDs(args []interface{}) []int64 {
	if len(args) == 0 {
		return nil
	}
	switch v := args[0].(type) {
	case []interface{}:
		ids := make([]int64, len(v))
		for i, item := range v {
			switch n := item.(type) {
			case float64:
				ids[i] = int64(n)
			case int64:
				ids[i] = n
			case int32:
				ids[i] = int64(n)
			}
		}
		return ids
	case []int64:
		return v
	case float64:
		return []int64{int64(v)}
	case int64:
		return []int64{v}
	}
	return nil
}

func parseFields(kw Values) []string {
	if kw == nil {
		return nil
	}
	fieldsRaw, ok := kw["fields"]
	if !ok {
		return nil
	}
	fieldsSlice, ok := fieldsRaw.([]interface{})
	if !ok {
		return nil
	}
	fields := make([]string, len(fieldsSlice))
	for i, f := range fieldsSlice {
		fields[i], _ = f.(string)
	}
	return fields
}

func parseValues(args []interface{}) orm.Values {
	if len(args) == 0 {
		return nil
	}
	vals, ok := args[0].(map[string]interface{})
	if !ok {
		return nil
	}
	return orm.Values(vals)
}

func parseValuesAt(args []interface{}, idx int) orm.Values {
	if len(args) <= idx {
		return nil
	}
	vals, ok := args[idx].(map[string]interface{})
	if !ok {
		return nil
	}
	return orm.Values(vals)
}
