package handlers

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strconv"
	"strings"

	"github.com/go-chi/chi/v5"

	"leadb2b.org/internal/api"
	"leadb2b.org/internal/auth"
	"leadb2b.org/internal/render"
)

type AppHandler struct {
	leads   *api.LeadsService
	product *api.ProductService
	authAPI *api.AuthService
	render  *render.Engine
	siteURL string
}

func NewAppHandler(leads *api.LeadsService, product *api.ProductService, authAPI *api.AuthService, engine *render.Engine, siteURL string) *AppHandler {
	return &AppHandler{leads: leads, product: product, authAPI: authAPI, render: engine, siteURL: siteURL}
}

type AppBuscaPage struct {
	MaskedItems             []api.MaskedCompanyOut
	UFs                     []api.FiltroOpcao
	Situacoes               []api.FiltroOpcao
	Municipios              []api.MunicipioItem
	CNAEOptions             []api.CnaeItem
	PorteOptions            []render.PorteOption
	NaturezaJuridicaOptions []render.NaturezaJuridicaOption
	Params                  render.SearchForm
	ActiveFilters           []render.ActiveFilterTag
	Pagination              render.Pagination
	SearchNotice            string
	ErrorMessage            string
	AwaitingSearch          bool
	RowCNPJs                []string
	RowCNPJsJSON            string
	NextCursor              string
	NextPageURL             string
	HasMore                 bool
}

type AppPageData struct {
	render.SiteData
	Session      *auth.Session
	ActiveNav    string
	PageTitle    string
	Banner       string
	Stats        *api.StatsOverviewOut
	Kanban       map[string][]api.KanbanCardOut
	KanbanStages []render.KanbanStageDef
	Limits       *api.LimitsInfo
	Busca        *AppBuscaPage
	Lead         *api.LeadCardOut
	Detail       *api.EstabelecimentoDetailOut
	LeadHistory  []api.LeadHistoryEntry
	Tags         []api.TagOut
	Templates    []api.TemplateOut
	Plans        []api.AdminPlanOut
	Exports      []api.ExportJobOut
	AdminUsers   []api.AdminUserOut
	AdminMetrics *api.AdminMetricsOut
}

func (h *AppHandler) baseData(sess *auth.Session, nav, pageTitle string) AppPageData {
	return AppPageData{
		SiteData: render.SiteData{
			Meta:          render.DefaultMeta("Painel LeadB2B", "Área logada LeadB2B", "/app", h.siteURL),
			HeaderStyle:   "header-style-light",
			Skin:          "skin-6",
			MinimalAssets: false,
			IsAppPanel:    true,
		},
		Session:   sess,
		ActiveNav: nav,
		PageTitle: pageTitle,
		Limits:    sess.Limits(),
	}
}

func (h *AppHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	stats, _ := h.product.StatsOverview(r.Context(), sess.Token)
	data := h.baseData(sess, "dashboard", "Dashboard")
	data.Stats = stats
	data.SiteData.Meta.Title = "Dashboard | LeadB2B"
	_ = h.render.Render(w, "app-dashboard", data)
}

func (h *AppHandler) Busca(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	query := r.URL.Query()
	if query.Get("clear") == "1" {
		render.ClearAppBuscaState(w)
		http.Redirect(w, r, "/app/busca", http.StatusFound)
		return
	}
	data, redirectURL, err := h.loadBuscaPage(w, r, sess, false)
	if redirectURL != "" {
		http.Redirect(w, r, redirectURL, http.StatusFound)
		return
	}
	if err != nil {
		h.renderAPIError(w, err)
		return
	}
	data.SiteData.Meta.Title = "Buscar empresas | LeadB2B"
	_ = h.render.Render(w, "app-busca", data)
}

func (h *AppHandler) BuscaResults(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	if r.URL.Query().Get("clear") == "1" {
		render.ClearAppBuscaState(w)
	}
	data, redirectURL, err := h.loadBuscaPage(w, r, sess, r.URL.Query().Get("clear") == "1")
	if redirectURL != "" {
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]string{"redirect": redirectURL})
		return
	}
	if err != nil {
		w.WriteHeader(http.StatusBadGateway)
		data := h.baseData(sess, "busca", "Buscar empresas")
		data.Busca = &AppBuscaPage{ErrorMessage: "Não foi possível carregar os resultados. Tente novamente."}
		_ = h.render.RenderBlock(w, "app-busca", "app-busca-results", data)
		return
	}
	_ = h.render.RenderBlock(w, "app-busca", "app-busca-results", data)
}

func (h *AppHandler) loadBuscaPage(w http.ResponseWriter, r *http.Request, sess *auth.Session, cleared bool) (AppPageData, string, error) {
	ctx := r.Context()
	query := r.URL.Query()
	if cleared {
		query = url.Values{}
		r = r.Clone(ctx)
		r.URL.RawQuery = ""
	} else {
		query = render.HydrateAppBuscaQuery(r, query)
	}
	var rawParams render.SearchForm
	if cleared {
		rawParams = render.DefaultAppBuscaParams()
	} else {
		rawParams = render.ParseSearchForm(query)
	}
	if render.IsCNPJLookup(rawParams) {
		if url := h.cnpjLookupRedirectURL(ctx, sess, rawParams.CNPJ); url != "" {
			return AppPageData{}, url, nil
		}
	}
	enrichedParams, cepNotice := render.EnrichSearchFromCEP(ctx, rawParams)
	params := render.ApplyAppSearchDefaults(enrichedParams, query)
	pageNum := render.ParsePage(query)
	if pageNum == 1 {
		params.Cursor = ""
	}
	if lim := sess.Limits(); lim != nil && lim.MaxSearchPageSize > 0 {
		if params.PageSize > lim.MaxSearchPageSize || params.PageSize == 0 {
			params.PageSize = lim.MaxSearchPageSize
		}
	} else if params.PageSize == 0 {
		params.PageSize = 20
	}
	meta, err := h.product.SearchFilters(ctx, sess.Token)
	if err != nil {
		return AppPageData{}, "", err
	}
	searchNotice := cepNotice
	executeSearch := render.ShouldExecuteAppBuscaSearch(query)
	var result *api.MaskedSearchOut
	if !executeSearch {
		result = &api.MaskedSearchOut{}
	} else if render.RequiresUFAndMunicipio(enrichedParams) {
		if searchNotice == "" {
			searchNotice = render.AppBuscaLocationRequiredNotice
		}
		result = &api.MaskedSearchOut{}
	} else {
		result, err = h.product.SearchCompanies(ctx, sess.Token, render.ToProductSearchQuery(params))
		if err != nil {
			return AppPageData{}, "", err
		}
		result.Items = h.enrichBuscaCNPJs(ctx, params, result.Items)
		if render.NormalizeCEPDigits(params.CEP) != "" && len(result.Items) == 0 {
			if searchNotice != "" {
				searchNotice += " "
			}
			searchNotice += "Nenhuma empresa encontrada com este CEP na base consultada."
		}
	}
	var munItems []api.MunicipioItem
	if params.UF != "" {
		munItems, _ = h.product.SearchMunicipios(ctx, sess.Token, params.UF)
	}
	var cnaes []api.CnaeItem
	if len(params.CNAEFiscalPrincipal) > 0 {
		cnaeQ := params.CNAEFiscalPrincipal[0]
		cnaes, _ = h.product.SearchCNAEs(ctx, sess.Token, cnaeQ)
	}
	rowCNPJs := make([]string, len(result.Items))
	for i, item := range result.Items {
		rowCNPJs[i] = render.MaskedCompanyCNPJ(item)
	}
	rowCNPJsJSON, _ := json.Marshal(rowCNPJs)

	data := h.baseData(sess, "busca", "Buscar empresas")
	busca := &AppBuscaPage{
		MaskedItems:             result.Items,
		RowCNPJs:                rowCNPJs,
		RowCNPJsJSON:            string(rowCNPJsJSON),
		UFs:                     h.leads.UFOptions(meta),
		Situacoes:               h.leads.SituacaoOptions(meta),
		Municipios:              munItems,
		CNAEOptions:             cnaes,
		PorteOptions:            render.PorteOptions(),
		NaturezaJuridicaOptions: render.NaturezaJuridicaOptions(),
		Params:                  params,
		ActiveFilters: render.BuildAppActiveFilterTags(params, query, render.FilterTagContext{
			UFs:        h.leads.UFOptions(meta),
			Municipios: munItems,
			Situacoes:  h.leads.SituacaoOptions(meta),
			CNAEs:      cnaes,
		}),
		SearchNotice:   searchNotice,
		AwaitingSearch: !executeSearch,
		NextCursor:     derefString(result.NextCursor),
		HasMore:        result.HasMore,
	}
	pageSize := render.ClampPageSize(params.PageSize)
	listURL := *r.URL
	listURL.RawQuery = query.Encode()
	busca.Pagination = render.BuildPaginationForPath(&listURL, "/app/busca", pageNum, pageSize, len(result.Items), result.Total, result.HasMore, derefString(result.NextCursor))
	if errCode := query.Get("error"); errCode != "" {
		busca.ErrorMessage = ProductErrorMessage(errCode)
		data.Banner = busca.ErrorMessage
	}
	if query.Get("ok") == "export" {
		data.Banner = "Exportação criada. Acesse Exportações para baixar o CSV."
	}
	if result.HasMore && result.NextCursor != nil {
		busca.NextPageURL = busca.Pagination.NextURL
		if busca.NextPageURL == "" {
			busca.NextPageURL = render.AppBuscaNextPageURL(params, *result.NextCursor)
		}
	}
	data.Busca = busca
	if executeSearch && !cleared {
		render.SaveAppBuscaState(w, query)
	}
	if r.URL.Query().Get("welcome") == "1" && sess.Limits() != nil {
		data.Banner = fmt.Sprintf("Bem-vindo! Seu plano permite até %d leads ativos no funil. Finalize ou arquive leads no kanban para liberar vagas.", sess.Limits().MaxActiveLeads)
	}
	return data, "", nil
}

func (h *AppHandler) enrichBuscaCNPJs(ctx context.Context, params render.SearchForm, items []api.MaskedCompanyOut) []api.MaskedCompanyOut {
	if len(items) == 0 || !render.MaskedItemsNeedCNPJ(items) {
		return items
	}
	query := render.ToLeadsSearchQuery(params)
	if query.Get("page_size") == "" && params.PageSize > 0 {
		query.Set("page_size", strconv.Itoa(params.PageSize))
	}
	full, err := searchEstabelecimentosQuery(ctx, h.leads, query)
	if err != nil || full == nil || len(full.Items) == 0 {
		return items
	}
	return render.EnrichMaskedSearchCNPJs(items, full.Items)
}

func (h *AppHandler) cnpjLookupRedirectURL(ctx context.Context, sess *auth.Session, rawCNPJ string) string {
	digits := render.ExtractCNPJDigitsOnly(rawCNPJ)
	if len(digits) != 14 {
		return ""
	}
	out, err := h.product.WorkLead(ctx, sess.Token, digits)
	if err == nil {
		return fmt.Sprintf("/app/leads/%d", out.Lead.ID)
	}
	if apiErr, ok := err.(*api.APIError); ok && apiErr.Code == "ALREADY_WORKING" {
		if leads, listErr := h.product.ListLeads(ctx, sess.Token, ""); listErr == nil {
			for _, l := range leads.Items {
				if l.CNPJ == digits {
					return fmt.Sprintf("/app/leads/%d", l.ID)
				}
			}
		}
	}
	return ""
}

func (h *AppHandler) Kanban(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	data := h.baseData(sess, "kanban", "Kanban")
	data.KanbanStages = render.KanbanStages()
	data.Kanban = emptyKanbanColumns()

	board, err := h.product.Kanban(r.Context(), sess.Token)
	if err != nil {
		data.Banner = formatAPIError(err)
		data.SiteData.Meta.Title = "Kanban | LeadB2B"
		_ = h.render.Render(w, "app-kanban", data)
		return
	}
	for stage, cards := range board.Columns {
		items := make([]api.KanbanCardOut, 0, len(cards))
		for _, c := range cards {
			c.Stage = stage
			items = append(items, c)
		}
		data.Kanban[stage] = items
	}
	if board.Meta.ActiveCount > 0 || board.Meta.SlotsRemaining >= 0 {
		limits := &api.LimitsInfo{
			SlotsRemaining:   board.Meta.SlotsRemaining,
			ActiveLeadsCount: board.Meta.ActiveCount,
		}
		if sess.Limits() != nil {
			limits.MaxActiveLeads = sess.Limits().MaxActiveLeads
			limits.CanExport = sess.Limits().CanExport
		}
		data.Limits = limits
	}
	data.SiteData.Meta.Title = "Kanban | LeadB2B"
	_ = h.render.Render(w, "app-kanban", data)
}

func emptyKanbanColumns() map[string][]api.KanbanCardOut {
	cols := make(map[string][]api.KanbanCardOut, len(render.KanbanStages()))
	for _, st := range render.KanbanStages() {
		cols[st.Code] = []api.KanbanCardOut{}
	}
	return cols
}

func (h *AppHandler) LeadDetail(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if r.Method == http.MethodPost {
		_ = r.ParseForm()
		switch r.FormValue("action") {
		case "update", "notes":
			_, _ = h.product.UpdateLead(r.Context(), sess.Token, id, leadUpdateFromForm(r))
		case "archive":
			_, _ = h.product.UpdateStage(r.Context(), sess.Token, id, "arquivado")
		case "delete":
			_ = h.product.DeleteLead(r.Context(), sess.Token, id)
			http.Redirect(w, r, "/app/kanban", http.StatusFound)
			return
		}
		http.Redirect(w, r, fmt.Sprintf("/app/leads/%d?ok=1", id), http.StatusFound)
		return
	}
	detail, err := h.product.GetLead(r.Context(), sess.Token, id)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	templates, _ := h.product.ListTemplates(r.Context(), sess.Token)
	tags, _ := h.product.ListTags(r.Context(), sess.Token)
	history, _ := h.product.GetLeadHistory(r.Context(), sess.Token, id)
	lead := detail.Lead
	name := "Lead"
	if lead.CompanyName != nil {
		name = *lead.CompanyName
	}
	data := h.baseData(sess, "kanban", name)
	data.Lead = &lead
	data.Detail = &detail.Estabelecimento
	data.Templates = templates
	data.Tags = tags
	if history != nil {
		data.LeadHistory = history.Items
	}
	if r.URL.Query().Get("ok") == "1" {
		data.Banner = "Lead atualizado."
	}
	data.SiteData.Meta.Title = name + " | LeadB2B"
	_ = h.render.Render(w, "app-lead", data)
}

func (h *AppHandler) Tags(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	if r.Method == http.MethodPost {
		_ = r.ParseForm()
		switch r.FormValue("action") {
		case "create":
			name := strings.TrimSpace(r.FormValue("name"))
			color := strings.TrimSpace(r.FormValue("color"))
			if color == "" {
				color = "#1967d2"
			}
			if name != "" {
				_, _ = h.product.CreateTag(r.Context(), sess.Token, api.TagCreate{Name: name, Color: color})
			}
		case "delete":
			if tagID, err := strconv.ParseInt(r.FormValue("tag_id"), 10, 64); err == nil {
				_ = h.product.DeleteTag(r.Context(), sess.Token, tagID)
			}
		}
		http.Redirect(w, r, "/app/tags", http.StatusFound)
		return
	}
	tags, _ := h.product.ListTags(r.Context(), sess.Token)
	data := h.baseData(sess, "kanban", "Tags")
	data.Tags = tags
	data.SiteData.Meta.Title = "Tags | LeadB2B"
	_ = h.render.Render(w, "app-tags", data)
}

func leadUpdateFromForm(r *http.Request) map[string]any {
	update := map[string]any{}
	if v := strings.TrimSpace(r.FormValue("notes")); v != "" || r.Form.Has("notes") {
		update["notes"] = v
	}
	if v := strings.TrimSpace(r.FormValue("company_name")); v != "" {
		update["company_name"] = v
	}
	if v := strings.TrimSpace(r.FormValue("city")); v != "" {
		update["city"] = v
	}
	if v := strings.TrimSpace(strings.ToUpper(r.FormValue("uf"))); v != "" {
		update["uf"] = v
	}
	if v := strings.TrimSpace(r.FormValue("responsible_name")); v != "" || r.Form.Has("responsible_name") {
		update["responsible_name"] = v
	}
	if v := strings.TrimSpace(r.FormValue("temperature")); v != "" {
		if t, err := strconv.Atoi(v); err == nil && t >= 0 && t <= 100 {
			update["temperature"] = t
		}
	}
	if r.FormValue("use_rfb_contacts") == "on" {
		update["phones_override"] = nil
		update["emails_override"] = nil
	} else {
		if r.Form.Has("phones_override") {
			update["phones_override"] = parseContactLines(r.FormValue("phones_override"))
		}
		if r.Form.Has("emails_override") {
			update["emails_override"] = parseContactLines(r.FormValue("emails_override"))
		}
	}
	if r.FormValue("tag_ids_sent") == "1" {
		var tagIDs []int64
		for _, v := range r.Form["tag_ids"] {
			if id, err := strconv.ParseInt(v, 10, 64); err == nil {
				tagIDs = append(tagIDs, id)
			}
		}
		update["tag_ids"] = tagIDs
	}
	return update
}

func parseContactLines(raw string) []string {
	lines := strings.Split(raw, "\n")
	out := make([]string, 0, len(lines))
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if line != "" {
			out = append(out, line)
		}
	}
	return out
}

func (h *AppHandler) Mensagens(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	if r.Method == http.MethodPost {
		_ = r.ParseForm()
		name := r.FormValue("name")
		body := r.FormValue("body")
		isDefault := r.FormValue("is_default") == "on"
		_, _ = h.product.CreateTemplate(r.Context(), sess.Token, api.TemplateCreate{
			Name:      name,
			Body:      body,
			IsDefault: isDefault,
		})
		http.Redirect(w, r, "/app/mensagens", http.StatusFound)
		return
	}
	templates, _ := h.product.ListTemplates(r.Context(), sess.Token)
	data := h.baseData(sess, "mensagens", "Mensagens padrão")
	data.Templates = templates
	_ = h.render.Render(w, "app-mensagens", data)
}

func (h *AppHandler) Plano(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	plans, err := h.product.AdminListPlans(r.Context(), sess.Token)
	if err != nil || len(plans) == 0 {
		plans = DefaultPlanTiers()
	}
	data := h.baseData(sess, "plano", "Meu plano")
	data.Plans = plans
	_ = h.render.Render(w, "app-plano", data)
}

func (h *AppHandler) Exportacoes(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	if r.Method == http.MethodPost {
		_ = r.ParseForm()
		var ids []int64
		for _, v := range r.Form["lead_ids"] {
			if id, err := strconv.ParseInt(v, 10, 64); err == nil {
				ids = append(ids, id)
			}
		}
		if _, err := h.product.CreateExport(r.Context(), sess.Token, ids); err != nil {
			redirectWithProductError(w, r, "/app/exportacoes", err)
			return
		}
		http.Redirect(w, r, "/app/exportacoes?ok=1", http.StatusFound)
		return
	}
	exports, _ := h.product.ListExports(r.Context(), sess.Token)
	leads, _ := h.product.ListLeads(r.Context(), sess.Token, "")
	data := h.baseData(sess, "exportacoes", "Exportações")
	if errCode := r.URL.Query().Get("error"); errCode != "" {
		data.Banner = ProductErrorMessage(errCode)
	}
	if r.URL.Query().Get("ok") == "1" {
		data.Banner = "Exportação criada com sucesso."
	}
	if exports != nil {
		data.Exports = exports.Items
	}
	if leads != nil {
		items := make([]api.KanbanCardOut, 0, len(leads.Items))
		for _, l := range leads.Items {
			items = append(items, api.KanbanCardOut{
				ID:              l.ID,
				CNPJ:            l.CNPJ,
				CompanyName:     l.CompanyName,
				City:            l.City,
				UF:              l.UF,
				HasPhone:        l.HasPhone,
				HasEmail:        l.HasEmail,
				ResponsibleName: l.ResponsibleName,
				Temperature:     l.Temperature,
				Phones:          l.Phones,
				Emails:          l.Emails,
				Tags:            l.Tags,
				Notes:           l.Notes,
				WorkedAt:        l.WorkedAt,
				StageChangedAt:  l.StageChangedAt,
				Stage:           l.Stage,
			})
		}
		data.Kanban = map[string][]api.KanbanCardOut{"all": items}
	}
	_ = h.render.Render(w, "app-exportacoes", data)
}

func (h *AppHandler) Perfil(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	if r.Method == http.MethodPost {
		_ = r.ParseForm()
		name := strings.TrimSpace(r.FormValue("name"))
		if name != "" {
			if updated, err := h.authAPI.UpdateMe(r.Context(), sess.Token, api.UpdateMeRequest{Name: &name}); err == nil {
				sess.Me = updated
			}
		}
		http.Redirect(w, r, "/app/perfil?ok=1", http.StatusFound)
		return
	}
	data := h.baseData(sess, "perfil", "Perfil")
	_ = h.render.Render(w, "app-perfil", data)
}

type bulkCNPJRequest struct {
	CNPJs []string `json:"cnpjs"`
}

type bulkActionResponse struct {
	OK       bool     `json:"ok"`
	Added    int      `json:"added,omitempty"`
	Skipped  int      `json:"skipped,omitempty"`
	Failed   int      `json:"failed,omitempty"`
	Exported int      `json:"exported,omitempty"`
	Message  string   `json:"message"`
	Errors   []string `json:"errors,omitempty"`
	ExportID *int64   `json:"export_id,omitempty"`
	CNPJs    []string `json:"cnpjs,omitempty"`
}

type buscaActionResponse struct {
	OK         bool     `json:"ok"`
	Message    string   `json:"message"`
	LeadID     *int64   `json:"lead_id,omitempty"`
	CNPJ       string   `json:"cnpj,omitempty"`
	InFunnel   bool     `json:"in_funnel,omitempty"`
	ExportID   *int64   `json:"export_id,omitempty"`
	SlotsLeft  *int     `json:"slots_left,omitempty"`
	ExportsDay *int     `json:"exports_today,omitempty"`
	Errors     []string `json:"errors,omitempty"`
}

func (h *AppHandler) PostBulkKanban(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	var body bulkCNPJRequest
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeAppBulkJSON(w, http.StatusUnprocessableEntity, bulkActionResponse{
			OK: false, Message: "Requisição inválida.",
		})
		return
	}
	cnpjs := uniqueCNPJs(body.CNPJs)
	if len(cnpjs) == 0 {
		writeAppBulkJSON(w, http.StatusUnprocessableEntity, bulkActionResponse{
			OK: false, Message: "Selecione ao menos uma empresa.",
		})
		return
	}
	ctx := r.Context()
	var added, skipped, failed int
	var errs []string
	var addedCNPJs []string
	for _, cnpj := range cnpjs {
		_, err := h.product.WorkLead(ctx, sess.Token, cnpj)
		if err == nil {
			added++
			addedCNPJs = append(addedCNPJs, cnpj)
			continue
		}
		if apiErr, ok := err.(*api.APIError); ok {
			switch apiErr.Code {
			case "ALREADY_WORKING":
				skipped++
				addedCNPJs = append(addedCNPJs, cnpj)
				continue
			case "ACTIVE_LIMIT_REACHED", "DAILY_ADD_LIMIT":
				writeAppBulkJSON(w, http.StatusConflict, bulkActionResponse{
					OK:      false,
					Added:   added,
					Skipped: skipped,
					Failed:  failed,
					Message: formatAPIError(apiErr),
					Errors:  errs,
					CNPJs:   addedCNPJs,
				})
				return
			default:
				failed++
				errs = append(errs, formatAPIError(apiErr))
				continue
			}
		}
		failed++
		errs = append(errs, formatAPIError(err))
	}
	if added == 0 && skipped == 0 {
		msg := "Não foi possível adicionar as empresas selecionadas."
		if len(errs) > 0 {
			msg = errs[0]
		}
		writeAppBulkJSON(w, http.StatusUnprocessableEntity, bulkActionResponse{
			OK: false, Failed: failed, Message: msg, Errors: errs,
		})
		return
	}
	msg := fmt.Sprintf("%d empresa(s) adicionada(s) ao kanban.", added)
	if skipped > 0 {
		msg += fmt.Sprintf(" %d já estava(m) no funil.", skipped)
	}
	if failed > 0 {
		msg += fmt.Sprintf(" %d falhou(ram).", failed)
	}
	writeAppBulkJSON(w, http.StatusOK, bulkActionResponse{
		OK: true, Added: added, Skipped: skipped, Failed: failed, Message: msg, Errors: errs, CNPJs: addedCNPJs,
	})
}

func (h *AppHandler) PostBulkExport(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	var body bulkCNPJRequest
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeAppBulkJSON(w, http.StatusUnprocessableEntity, bulkActionResponse{
			OK: false, Message: "Requisição inválida.",
		})
		return
	}
	cnpjs := uniqueCNPJs(body.CNPJs)
	if len(cnpjs) == 0 {
		writeAppBulkJSON(w, http.StatusUnprocessableEntity, bulkActionResponse{
			OK: false, Message: "Selecione ao menos uma empresa.",
		})
		return
	}
	if len(cnpjs) > 100 {
		writeAppBulkJSON(w, http.StatusUnprocessableEntity, bulkActionResponse{
			OK: false, Message: "Máximo de 100 empresas por exportação.",
		})
		return
	}
	lim := sess.Limits()
	if lim == nil || !lim.CanExport {
		writeAppBulkJSON(w, http.StatusForbidden, bulkActionResponse{
			OK: false, Message: ProductErrorMessage("FEATURE_DISABLED"),
		})
		return
	}
	if lim.ExportsToday >= lim.MaxExportPerDay {
		writeAppBulkJSON(w, http.StatusConflict, bulkActionResponse{
			OK: false, Message: ProductErrorMessage("EXPORT_LIMIT"),
		})
		return
	}
	ctx := r.Context()
	var leadIDs []int64
	var errs []string
	for _, cnpj := range cnpjs {
		id, err := h.leadIDForCNPJ(ctx, sess, cnpj)
		if err != nil {
			errs = append(errs, formatAPIError(err))
			continue
		}
		leadIDs = append(leadIDs, id)
	}
	if len(leadIDs) == 0 {
		writeAppBulkJSON(w, http.StatusUnprocessableEntity, bulkActionResponse{
			OK: false, Message: "Nenhuma empresa pôde ser preparada para exportação.", Errors: errs,
		})
		return
	}
	out, err := h.product.CreateExport(ctx, sess.Token, leadIDs)
	if err != nil {
		writeAppBulkJSON(w, http.StatusConflict, bulkActionResponse{
			OK: false, Message: formatAPIError(err), Errors: errs,
		})
		return
	}
	msg := fmt.Sprintf("Exportação criada com %d empresa(s). Acesse Exportações para baixar o CSV.", len(leadIDs))
	if len(errs) > 0 {
		msg += fmt.Sprintf(" %d não incluída(s).", len(errs))
	}
	writeAppBulkJSON(w, http.StatusCreated, bulkActionResponse{
		OK: true, Exported: len(leadIDs), ExportID: &out.ID, Message: msg, Errors: errs,
	})
}

func uniqueCNPJs(items []string) []string {
	seen := make(map[string]struct{}, len(items))
	out := make([]string, 0, len(items))
	for _, raw := range items {
		digits := render.FullCNPJDigits(strings.TrimSpace(raw))
		if digits == "" {
			continue
		}
		if _, ok := seen[digits]; ok {
			continue
		}
		seen[digits] = struct{}{}
		out = append(out, digits)
	}
	return out
}

func writeAppBulkJSON(w http.ResponseWriter, status int, payload bulkActionResponse) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

func writeBuscaActionJSON(w http.ResponseWriter, status int, payload buscaActionResponse) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

func (h *AppHandler) PostSearchExport(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	_ = r.ParseForm()
	cnpj := render.FullCNPJDigits(strings.TrimSpace(r.FormValue("cnpj")))
	if cnpj == "" {
		if isAppAjax(r) {
			writeBuscaActionJSON(w, http.StatusUnprocessableEntity, buscaActionResponse{
				OK: false, Message: "CNPJ inválido.",
			})
			return
		}
		http.Redirect(w, r, "/app/busca?error=unknown", http.StatusFound)
		return
	}
	leadID, err := h.leadIDForCNPJ(r.Context(), sess, cnpj)
	if err != nil {
		if isAppAjax(r) {
			writeBuscaActionJSON(w, http.StatusConflict, buscaActionResponse{
				OK: false, Message: formatAPIError(err), CNPJ: cnpj,
			})
			return
		}
		redirectWithProductError(w, r, "/app/busca", err)
		return
	}
	out, err := h.product.CreateExport(r.Context(), sess.Token, []int64{leadID})
	if err != nil {
		if isAppAjax(r) {
			writeBuscaActionJSON(w, http.StatusConflict, buscaActionResponse{
				OK: false, Message: formatAPIError(err), CNPJ: cnpj,
			})
			return
		}
		redirectWithProductError(w, r, "/app/busca", err)
		return
	}
	if isAppAjax(r) {
		resp := buscaActionResponse{
			OK: true, Message: "Exportação criada. Acesse Exportações para baixar o CSV.", CNPJ: cnpj, ExportID: &out.ID,
		}
		if lim := sess.Limits(); lim != nil {
			today := lim.ExportsToday + 1
			resp.ExportsDay = &today
		}
		writeBuscaActionJSON(w, http.StatusCreated, resp)
		return
	}
	http.Redirect(w, r, "/app/busca?ok=export", http.StatusFound)
}

func (h *AppHandler) leadIDForCNPJ(ctx context.Context, sess *auth.Session, cnpj string) (int64, error) {
	out, err := h.product.WorkLead(ctx, sess.Token, cnpj)
	if err == nil {
		return out.Lead.ID, nil
	}
	if apiErr, ok := err.(*api.APIError); ok && apiErr.Code == "ALREADY_WORKING" {
		digits := render.ExtractCNPJDigitsOnly(cnpj)
		leads, listErr := h.product.ListLeads(ctx, sess.Token, "")
		if listErr == nil {
			for _, l := range leads.Items {
				if l.CNPJ == digits || l.CNPJ == cnpj {
					return l.ID, nil
				}
			}
		}
	}
	return 0, err
}

func (h *AppHandler) PostWorkLead(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	_ = r.ParseForm()
	cnpj := render.FullCNPJDigits(strings.TrimSpace(r.FormValue("cnpj")))
	if cnpj == "" {
		if isAppAjax(r) {
			writeBuscaActionJSON(w, http.StatusUnprocessableEntity, buscaActionResponse{
				OK: false, Message: "CNPJ inválido.",
			})
			return
		}
		redirectWithProductError(w, r, "/app/busca", &api.APIError{Code: "INVALID_CNPJ"})
		return
	}
	out, err := h.product.WorkLead(r.Context(), sess.Token, cnpj)
	if err != nil {
		if apiErr, ok := err.(*api.APIError); ok && apiErr.Code == "ALREADY_WORKING" {
			if leads, listErr := h.product.ListLeads(r.Context(), sess.Token, ""); listErr == nil {
				for _, l := range leads.Items {
					if l.CNPJ == cnpj {
						if isAppAjax(r) {
							id := l.ID
							resp := buscaActionResponse{
								OK: true, Message: "Esta empresa já está no funil.", LeadID: &id, CNPJ: cnpj, InFunnel: true,
							}
							if lim := sess.Limits(); lim != nil {
								s := lim.SlotsRemaining
								resp.SlotsLeft = &s
							}
							writeBuscaActionJSON(w, http.StatusOK, resp)
							return
						}
						http.Redirect(w, r, fmt.Sprintf("/app/leads/%d", l.ID), http.StatusFound)
						return
					}
				}
			}
		}
		if isAppAjax(r) {
			status := http.StatusConflict
			if apiErr, ok := err.(*api.APIError); ok && apiErr.Status >= 400 {
				status = apiErr.Status
			}
			writeBuscaActionJSON(w, status, buscaActionResponse{
				OK: false, Message: formatAPIError(err), CNPJ: cnpj,
			})
			return
		}
		redirectWithProductError(w, r, "/app/busca", err)
		return
	}
	if isAppAjax(r) {
		id := out.Lead.ID
		resp := buscaActionResponse{
			OK: true, Message: "Empresa adicionada ao kanban.", LeadID: &id, CNPJ: cnpj, InFunnel: true,
		}
		if lim := sess.Limits(); lim != nil {
			s := lim.SlotsRemaining - 1
			if s < 0 {
				s = 0
			}
			resp.SlotsLeft = &s
		}
		writeBuscaActionJSON(w, http.StatusCreated, resp)
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/app/leads/%d", out.Lead.ID), http.StatusFound)
}

func (h *AppHandler) PostMoveStage(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	_ = r.ParseForm()
	id, _ := strconv.ParseInt(r.FormValue("lead_id"), 10, 64)
	stage := r.FormValue("stage")
	_, err := h.product.UpdateStage(r.Context(), sess.Token, id, stage)
	if err != nil {
		if isAppAjax(r) {
			status := http.StatusBadRequest
			if apiErr, ok := err.(*api.APIError); ok && apiErr.Status >= 400 {
				status = apiErr.Status
			}
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(status)
			_ = json.NewEncoder(w).Encode(map[string]any{
				"ok": false, "message": formatAPIError(err),
			})
			return
		}
		redirectWithProductError(w, r, "/app/kanban", err)
		return
	}
	if isAppAjax(r) {
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]any{
			"ok": true, "lead_id": id, "stage": stage,
		})
		return
	}
	http.Redirect(w, r, "/app/kanban", http.StatusFound)
}

func (h *AppHandler) AdminDashboard(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	users, _ := h.product.AdminListUsers(r.Context(), sess.Token, 50, 0)
	metrics, _ := h.product.AdminMetrics(r.Context(), sess.Token)
	data := h.baseData(sess, "admin", "Admin")
	if users != nil {
		data.AdminUsers = users.Items
	}
	data.AdminMetrics = metrics
	_ = h.render.Render(w, "admin-dashboard", data)
}

func (h *AppHandler) AdminPatchUser(w http.ResponseWriter, r *http.Request) {
	sess := auth.SessionFromContext(r.Context())
	_ = r.ParseForm()
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	status := r.FormValue("status")
	planSlug := r.FormValue("plan_slug")
	if status != "" {
		_ = h.product.AdminUpdateUserStatus(r.Context(), sess.Token, id, status)
	}
	if planSlug != "" {
		_ = h.product.AdminUpdateSubscription(r.Context(), sess.Token, id, planSlug, "active")
	}
	http.Redirect(w, r, "/admin", http.StatusFound)
}

func (h *AppHandler) renderAPIError(w http.ResponseWriter, err error) {
	if apiErr, ok := err.(*api.APIError); ok {
		switch apiErr.Status {
		case http.StatusTooManyRequests:
			http.Error(w, "Muitas requisições. Tente novamente.", http.StatusTooManyRequests)
			return
		case http.StatusUnauthorized:
			http.Error(w, "Sessão expirada. Faça login novamente.", http.StatusUnauthorized)
			return
		}
	}
	http.Error(w, "Não foi possível carregar os dados.", http.StatusBadGateway)
}

func (h *AppHandler) redirectCNPJLookupApp(w http.ResponseWriter, r *http.Request, rawCNPJ string) bool {
	digits := render.ExtractCNPJDigitsOnly(rawCNPJ)
	if len(digits) != 14 {
		return false
	}
	sess := auth.SessionFromContext(r.Context())
	out, err := h.product.WorkLead(r.Context(), sess.Token, digits)
	if err != nil {
		if apiErr, ok := err.(*api.APIError); ok && apiErr.Code == "ALREADY_WORKING" {
			if leads, listErr := h.product.ListLeads(r.Context(), sess.Token, ""); listErr == nil {
				for _, l := range leads.Items {
					if l.CNPJ == digits {
						http.Redirect(w, r, fmt.Sprintf("/app/leads/%d", l.ID), http.StatusFound)
						return true
					}
				}
			}
		}
		return false
	}
	http.Redirect(w, r, fmt.Sprintf("/app/leads/%d", out.Lead.ID), http.StatusFound)
	return true
}
