package services

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
	"golang.org/x/net/html"
)

var umindCrawlerHTTPClient = &http.Client{Timeout: 20 * time.Second}

const (
	umindChunkTamano = 900 // caracteres por chunk, aprox
	umindChunkSolape = 150 // caracteres de solape entre chunks consecutivos
)

// paginaCrawleada es el resultado de bajar y parsear una URL.
type paginaCrawleada struct {
	URL    string
	Titulo string
	Texto  string
	Links  []string
}

// crawlearPagina descarga una URL y extrae su texto visible, título y los
// enlaces internos que encuentre (para poder seguir crawleando).
func crawlearPagina(pageURL string) (*paginaCrawleada, error) {
	req, err := http.NewRequest("GET", pageURL, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "uMind-Crawler/1.0 (+https://u-site.app)")

	resp, err := umindCrawlerHTTPClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("no se pudo descargar %s: %w", pageURL, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("%s respondió %d", pageURL, resp.StatusCode)
	}
	ct := resp.Header.Get("Content-Type")
	esHTML := ct == "" || strings.Contains(ct, "text/html")
	// Además de HTML, se acepta texto plano/Markdown servido tal cual — es un
	// patrón cada vez más común (ej. este mismo caso: un .md pensado para que
	// lo consuma una IA, sin ruido de HTML que limpiar).
	esTexto := strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/markdown") ||
		strings.HasSuffix(strings.ToLower(strings.Split(pageURL, "?")[0]), ".md")
	if !esHTML && !esTexto {
		return nil, fmt.Errorf("%s no es HTML ni texto plano (%s)", pageURL, ct)
	}

	if !esHTML {
		body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
		if err != nil {
			return nil, fmt.Errorf("no se pudo leer %s: %w", pageURL, err)
		}
		pagina := &paginaCrawleada{URL: pageURL, Texto: normalizarEspacios(string(body))}
		primeraLinea := strings.TrimSpace(strings.SplitN(string(body), "\n", 2)[0])
		if strings.HasPrefix(primeraLinea, "#") {
			pagina.Titulo = strings.TrimSpace(strings.TrimLeft(primeraLinea, "# "))
		}
		return pagina, nil
	}

	base, err := url.Parse(pageURL)
	if err != nil {
		return nil, err
	}

	doc, err := html.Parse(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("no se pudo parsear el HTML de %s: %w", pageURL, err)
	}

	pagina := &paginaCrawleada{URL: pageURL}
	var textoBuf strings.Builder
	var caminar func(n *html.Node)
	caminar = func(n *html.Node) {
		if n.Type == html.ElementNode {
			switch strings.ToLower(n.Data) {
			case "script", "style", "noscript", "svg", "nav", "footer":
				return // no bajar a estos subárboles
			case "title":
				if n.FirstChild != nil && n.FirstChild.Type == html.TextNode {
					pagina.Titulo = strings.TrimSpace(n.FirstChild.Data)
				}
				return
			case "a":
				for _, attr := range n.Attr {
					if attr.Key == "href" && attr.Val != "" {
						if abs, err := base.Parse(attr.Val); err == nil {
							abs.Fragment = ""
							pagina.Links = append(pagina.Links, abs.String())
						}
					}
				}
			}
		}
		if n.Type == html.TextNode {
			t := strings.TrimSpace(n.Data)
			if t != "" {
				textoBuf.WriteString(t)
				textoBuf.WriteString(" ")
			}
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			caminar(c)
		}
	}
	caminar(doc)
	pagina.Texto = normalizarEspacios(textoBuf.String())
	return pagina, nil
}

func normalizarEspacios(s string) string {
	campos := strings.Fields(s)
	return strings.Join(campos, " ")
}

// CrawlearSitio recorre un sitio en BFS a partir de urlInicial, sin salir del
// mismo host, hasta maxPaginas páginas. Es deliberadamente simple para un
// piloto (no respeta robots.txt ni sitemap.xml todavía).
func CrawlearSitio(urlInicial string, maxPaginas int) ([]paginaCrawleada, error) {
	inicio, err := url.Parse(urlInicial)
	if err != nil {
		return nil, fmt.Errorf("URL inicial inválida: %w", err)
	}
	host := inicio.Hostname()

	visitadas := map[string]bool{}
	cola := []string{urlInicial}
	var resultado []paginaCrawleada

	for len(cola) > 0 && len(resultado) < maxPaginas {
		actual := cola[0]
		cola = cola[1:]
		if visitadas[actual] {
			continue
		}
		visitadas[actual] = true

		pagina, err := crawlearPagina(actual)
		if err != nil {
			log.Printf("[UMIND] Crawler: error en %s: %v", actual, err)
			continue
		}
		if pagina.Texto != "" {
			resultado = append(resultado, *pagina)
		}
		for _, link := range pagina.Links {
			u, err := url.Parse(link)
			if err != nil || u.Hostname() != host {
				continue
			}
			if !visitadas[link] {
				cola = append(cola, link)
			}
		}
	}
	return resultado, nil
}

// trocearTexto parte un texto largo en fragmentos de ~umindChunkTamano
// caracteres con solape, intentando cortar en un espacio para no partir
// palabras a la mitad.
func trocearTexto(texto string) []string {
	texto = strings.TrimSpace(texto)
	if texto == "" {
		return nil
	}
	if len(texto) <= umindChunkTamano {
		return []string{texto}
	}

	var chunks []string
	inicio := 0
	for inicio < len(texto) {
		fin := inicio + umindChunkTamano
		if fin >= len(texto) {
			chunks = append(chunks, strings.TrimSpace(texto[inicio:]))
			break
		}
		// buscar el último espacio antes de "fin" para no cortar palabras
		corte := strings.LastIndex(texto[inicio:fin], " ")
		if corte <= 0 {
			corte = fin - inicio
		}
		chunks = append(chunks, strings.TrimSpace(texto[inicio:inicio+corte]))
		siguiente := inicio + corte - umindChunkSolape
		if siguiente <= inicio {
			siguiente = inicio + corte
		}
		inicio = siguiente
	}
	return chunks
}

// IngestarAgente crawlea el sitio indicado, trocea el contenido, genera los
// embeddings y los guarda como UmindChunk para ese agente. Se ejecuta en
// segundo plano desde el panel admin porque puede tardar (varias páginas +
// llamadas al API de embeddings). El UmindDocumento va reflejando el
// progreso/estado.
func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPaginas int) {
	if _, err := models.GetUmindAgenteByID(agenteID); err != nil {
		models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("agente no encontrado: %v", err), 0)
		return
	}
	// Los embeddings usan una config global (módulo "umind_embeddings"), no la
	// del agente: todos los chunks de todos los agentes deben salir del mismo
	// modelo de embeddings para que la similitud coseno entre vectores tenga
	// sentido. La config del agente (AiConfigID) es solo para el chat.
	ai, err := models.GetUmindEmbeddingsConfig()
	if err != nil {
		models.UpdateUmindDocumentoEstado(documentoID, "error", err.Error(), 0)
		return
	}

	if maxPaginas <= 0 {
		maxPaginas = 30
	}
	paginas, err := CrawlearSitio(urlInicial, maxPaginas)
	if err != nil {
		models.UpdateUmindDocumentoEstado(documentoID, "error", err.Error(), 0)
		return
	}
	if len(paginas) == 0 {
		models.UpdateUmindDocumentoEstado(documentoID, "error", "no se pudo extraer texto de ninguna página", 0)
		return
	}

	// Trocear todo el contenido crawleado en chunks de texto plano.
	var textos []string
	for _, p := range paginas {
		for _, c := range trocearTexto(p.Texto) {
			if len(strings.TrimSpace(c)) < 40 {
				continue // fragmentos demasiado cortos no aportan al RAG
			}
			textos = append(textos, c)
		}
	}
	if len(textos) == 0 {
		models.UpdateUmindDocumentoEstado(documentoID, "error", "no se generó ningún fragmento de texto aprovechable", 0)
		return
	}

	// Generar embeddings en tandas para no mandar un solo request gigante.
	const tandaTam = 50
	var chunks []models.UmindChunk
	for i := 0; i < len(textos); i += tandaTam {
		fin := i + tandaTam
		if fin > len(textos) {
			fin = len(textos)
		}
		tanda := textos[i:fin]
		vectores, err := GenerarEmbeddings(ai, tanda)
		if err != nil {
			models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error generando embeddings: %v", err), len(chunks))
			return
		}
		for j, texto := range tanda {
			embJSON, err := models.EmbeddingToJSON(vectores[j])
			if err != nil {
				continue
			}
			chunks = append(chunks, models.UmindChunk{
				AgenteID:      agenteID,
				DocumentoID:   documentoID,
				Contenido:     texto,
				EmbeddingJSON: embJSON,
			})
		}
	}

	if err := models.CreateUmindChunks(chunks); err != nil {
		models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error guardando fragmentos: %v", err), 0)
		return
	}

	models.UpdateUmindDocumentoEstado(documentoID, "listo", "", len(chunks))
	log.Printf("[UMIND] Ingesta de agente %d completada: %d páginas, %d chunks", agenteID, len(paginas), len(chunks))
}
