package main

import (
	"bytes"
	"embed"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"
)

//go:embed all:static
var static embed.FS

// Endpoints de EasyAppointments de SOLO LECTURA y no sensibles que el frontend
// puede consultar a través del proxy. Las escrituras (crear cliente y cita) y la
// búsqueda de clientes NO están aquí: se orquestan server-side en /api/book, de
// modo que el navegador nunca puede listar/borrar/modificar datos de EA.
var eaAllowedRoutes = []struct {
	method string
	path   *regexp.Regexp
}{
	{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/categories/?$`)},
	{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/service_categories/?$`)},
	{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/services/?$`)},
	{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/availabilities/?$`)},
	{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/providers/\d+/?$`)},
	// Solo este ajuste concreto (horario del negocio); el resto de /settings
	// sigue bloqueado porque contiene datos sensibles.
	{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/settings/company_working_plan/?$`)},
}

func eaRouteAllowed(method, path string) bool {
	for _, route := range eaAllowedRoutes {
		if route.method == method && route.path.MatchString(path) {
			return true
		}
	}
	return false
}

func writeJSONError(w http.ResponseWriter, status int, code string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	fmt.Fprintf(w, `{"error": %q}`, code)
}

// flexInt acepta enteros que llegan como número JSON o como string ("30", 30,
// 30.0). La API de EasyAppointments mezcla ambos formatos según el campo.
type flexInt int

func (f *flexInt) UnmarshalJSON(b []byte) error {
	s := strings.Trim(string(b), `"`)
	if s == "" || s == "null" {
		*f = 0
		return nil
	}
	if n, err := strconv.Atoi(s); err == nil {
		*f = flexInt(n)
		return nil
	}
	ff, err := strconv.ParseFloat(s, 64)
	if err != nil {
		return fmt.Errorf("flexInt: valor no numérico %q", s)
	}
	*f = flexInt(ff)
	return nil
}

// eaClient hace las llamadas server-side a EasyAppointments con las credenciales
// admin. EasyAppointments es la ÚNICA fuente de verdad: este servidor no almacena
// clientes ni citas, solo orquesta llamadas a su API REST.
type eaClient struct {
	base   string
	apiKey string
	user   string
	pass   string
	http   *http.Client
}

func newEAClient() *eaClient {
	base := "http://easyappointments:80"
	if t := os.Getenv("EA_TARGET"); t != "" {
		base = t
	}
	return &eaClient{
		base:   strings.TrimRight(base, "/"),
		apiKey: os.Getenv("EA_API_KEY"),
		user:   os.Getenv("EA_API_USERNAME"),
		pass:   os.Getenv("EA_API_PASSWORD"),
		http:   &http.Client{Timeout: 15 * time.Second},
	}
}

func (c *eaClient) auth(req *http.Request) {
	if c.apiKey != "" {
		req.Header.Set("Authorization", "Bearer "+c.apiKey)
	} else if c.user != "" && c.pass != "" {
		req.SetBasicAuth(c.user, c.pass)
	}
}

// do llama a la API v1 de EA y decodifica la respuesta JSON en out (si no es nil).
// Devuelve error si el status no es 2xx.
func (c *eaClient) do(method, path string, body, out interface{}) error {
	var reader io.Reader
	if body != nil {
		buf, err := json.Marshal(body)
		if err != nil {
			return err
		}
		reader = bytes.NewReader(buf)
	}
	req, err := http.NewRequest(method, c.base+"/index.php/api/v1"+path, reader)
	if err != nil {
		return err
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	c.auth(req)

	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return fmt.Errorf("EA %s %s -> %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(snippet)))
	}
	if out != nil {
		return json.NewDecoder(resp.Body).Decode(out)
	}
	return nil
}

// ---- Tipos de EA usados en la orquestación ----

type eaService struct {
	ID       flexInt `json:"id"`
	Name     string  `json:"name"`
	Duration flexInt `json:"duration"`
}

type eaCustomer struct {
	ID        flexInt `json:"id,omitempty"`
	FirstName string  `json:"firstName"`
	LastName  string  `json:"lastName"`
	Email     string  `json:"email"`
	Phone     string  `json:"phone"`
	Notes     string  `json:"notes"`
}

type eaAppointment struct {
	Start      string `json:"start"`
	End        string `json:"end"`
	ServiceID  int    `json:"serviceId"`
	ProviderID int    `json:"providerId"`
	CustomerID int    `json:"customerId"`
	Notes      string `json:"notes"`
	Status     string `json:"status"`
}

// ---- Petición de reserva desde el navegador ----

type bookRequest struct {
	Nombre     string `json:"nombre"`
	Apellidos  string `json:"apellidos"`
	Email      string `json:"email"`
	Telefono   string `json:"telefono"`
	Mensaje    string `json:"mensaje"`
	ProviderID int    `json:"providerId"`
	ServiceIDs []int  `json:"serviceIds"`
	Date       string `json:"date"` // YYYY-MM-DD
	Time       string `json:"time"` // HH:MM
}

var (
	reDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
	reTime = regexp.MustCompile(`^\d{2}:\d{2}$`)
)

// handleBook orquesta la reserva contra EasyAppointments:
//  1. valida la entrada,
//  2. lee los servicios de EA (fuente de verdad de nombre/duración),
//  3. busca o crea el cliente por email,
//  4. crea UNA cita con el servicio de mayor duración y el bloque total en notas.
func handleBook(ea *eaClient) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed")
			return
		}

		var req bookRequest
		if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
			writeJSONError(w, http.StatusBadRequest, "invalid_json")
			return
		}
		req.Nombre = strings.TrimSpace(req.Nombre)
		req.Apellidos = strings.TrimSpace(req.Apellidos)
		req.Email = strings.TrimSpace(req.Email)
		req.Telefono = strings.TrimSpace(req.Telefono)
		req.Mensaje = strings.TrimSpace(req.Mensaje)

		if req.Nombre == "" || req.Apellidos == "" || req.Email == "" || req.Telefono == "" ||
			len(req.ServiceIDs) == 0 || req.ProviderID <= 0 ||
			!reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) {
			writeJSONError(w, http.StatusBadRequest, "datos_incompletos")
			return
		}

		// 2. Servicios desde EA: no confiamos en nombres/duraciones del cliente.
		var services []eaService
		if err := ea.do(http.MethodGet, "/services", nil, &services); err != nil {
			log.Printf("book: error leyendo servicios: %v", err)
			writeJSONError(w, http.StatusBadGateway, "servicios_no_disponibles")
			return
		}
		byID := make(map[int]eaService, len(services))
		for _, s := range services {
			byID[int(s.ID)] = s
		}

		var (
			chosen  []eaService
			total   int
			primary eaService
			maxDur  = -1
		)
		for _, id := range req.ServiceIDs {
			s, ok := byID[id]
			if !ok {
				writeJSONError(w, http.StatusBadRequest, "servicio_invalido")
				return
			}
			dur := int(s.Duration)
			if dur <= 0 {
				dur = 30
			}
			total += dur
			chosen = append(chosen, s)
			if dur > maxDur {
				maxDur = dur
				primary = s
			}
		}

		// 3. start/end calculados a partir de la duración total REAL de EA.
		start, err := time.Parse("2006-01-02 15:04", req.Date+" "+req.Time)
		if err != nil {
			writeJSONError(w, http.StatusBadRequest, "fecha_invalida")
			return
		}
		const layout = "2006-01-02 15:04:05"
		end := start.Add(time.Duration(total) * time.Minute)

		// 4. notas con el detalle completo (multiservicio).
		names := make([]string, len(chosen))
		for i, s := range chosen {
			names[i] = s.Name
		}
		notesLines := []string{
			"Nombre: " + req.Nombre + " " + req.Apellidos,
			"Email: " + req.Email,
			"Teléfono: " + req.Telefono,
			"Servicios: " + strings.Join(names, " + "),
			fmt.Sprintf("Duración total: %d min", total),
		}
		if req.Mensaje != "" {
			notesLines = append(notesLines, "Mensaje: "+req.Mensaje)
		}
		notes := strings.Join(notesLines, "\n")

		// 5. cliente: buscar por email o crear (en EA).
		customerID, err := ea.findOrCreateCustomer(req)
		if err != nil {
			log.Printf("book: error con cliente: %v", err)
			writeJSONError(w, http.StatusBadGateway, "cliente_no_disponible")
			return
		}

		// 6. crear la cita (en EA).
		appt := eaAppointment{
			Start:      start.Format(layout),
			End:        end.Format(layout),
			ServiceID:  int(primary.ID),
			ProviderID: req.ProviderID,
			CustomerID: customerID,
			Notes:      notes,
			Status:     "booked",
		}
		if err := ea.do(http.MethodPost, "/appointments", appt, nil); err != nil {
			log.Printf("book: error creando cita: %v", err)
			writeJSONError(w, http.StatusBadGateway, "cita_no_creada")
			return
		}

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated)
		fmt.Fprint(w, `{"ok": true}`)
	}
}

// findOrCreateCustomer busca un cliente por email en EA y, si no existe, lo crea.
func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) {
	var found []eaCustomer
	if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(req.Email), nil, &found); err == nil {
		for _, cust := range found {
			if strings.EqualFold(strings.TrimSpace(cust.Email), req.Email) {
				return int(cust.ID), nil
			}
		}
	}

	var created eaCustomer
	body := eaCustomer{
		FirstName: req.Nombre,
		LastName:  req.Apellidos,
		Email:     req.Email,
		Phone:     req.Telefono,
		Notes:     "Creado desde web MAY Studio",
	}
	if err := c.do(http.MethodPost, "/customers", body, &created); err != nil {
		return 0, err
	}
	if int(created.ID) == 0 {
		return 0, fmt.Errorf("EA no devolvió id de cliente")
	}
	return int(created.ID), nil
}

func main() {
	port := flag.Int("port", 8080, "puerto del servidor")
	flag.Parse()

	staticFS, err := fs.Sub(static, "static")
	if err != nil {
		log.Fatal(err)
	}

	ea := newEAClient()

	// Proxy de SOLO LECTURA a EasyAppointments.
	// El frontend llama a /ea-api/v1/... (servicios, categorías, proveedor,
	// disponibilidad) y aquí se reescribe la ruta y se añade la auth admin.
	eaURL, err := url.Parse(ea.base)
	if err != nil {
		log.Fatal(err)
	}
	eaProxy := httputil.NewSingleHostReverseProxy(eaURL)

	// Evita que el navegador muestre el popup de credenciales.
	// Nunca exponemos WWW-Authenticate ni mensajes de auth del backend.
	eaProxy.ModifyResponse = func(resp *http.Response) error {
		resp.Header.Del("Www-Authenticate")
		// Evitamos que cookies de sesión de EA lleguen al navegador del usuario público
		resp.Header.Del("Set-Cookie")
		// Limpiamos cabeceras que revelan el backend
		resp.Header.Del("Server")
		resp.Header.Del("X-Powered-By")

		if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
			resp.StatusCode = http.StatusBadGateway
			resp.Status = http.StatusText(http.StatusBadGateway)
			// Reemplazamos el body para no filtrar detalles del backend
			resp.Body.Close()
			resp.Body = io.NopCloser(strings.NewReader(`{"error": "service_unavailable"}`))
			resp.ContentLength = -1
			resp.Header.Set("Content-Type", "application/json")
			resp.Header.Del("Content-Length")
		}
		return nil
	}

	http.HandleFunc("/ea-api/", func(w http.ResponseWriter, r *http.Request) {
		// Allowlist método+ruta ANTES de inyectar credenciales: solo lecturas.
		if !eaRouteAllowed(r.Method, r.URL.Path) {
			writeJSONError(w, http.StatusNotFound, "not_found")
			return
		}

		// /ea-api/v1/services → /index.php/api/v1/services
		r.URL.Path = strings.Replace(r.URL.Path, "/ea-api", "/index.php/api", 1)

		// Auth (server-side, nunca se expone al navegador)
		if ea.apiKey != "" {
			r.Header.Set("Authorization", "Bearer "+ea.apiKey)
		} else if ea.user != "" && ea.pass != "" {
			r.SetBasicAuth(ea.user, ea.pass)
		}

		eaProxy.ServeHTTP(w, r)
	})

	// Reservas: orquestadas server-side contra EasyAppointments.
	http.HandleFunc("/api/book", handleBook(ea))

	http.Handle("/", http.FileServer(http.FS(staticFS)))

	addr := fmt.Sprintf(":%d", *port)
	log.Printf("Servidor iniciado en http://localhost%s", addr)
	log.Fatal(http.ListenAndServe(addr, nil))
}
