package contact

import (
	"context"
	"encoding/csv"
	"encoding/json"
	"errors"
	"io"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/warmbly/warmbly/internal/errx"
	"github.com/warmbly/warmbly/internal/models"
	"github.com/xuri/excelize/v2"
)

// Export streams contacts in the requested format directly to w. The
// caller is responsible for setting response headers based on the
// returned content type + filename.
func (s *contactService) Export(
	ctx context.Context,
	orgID string,
	req *models.ContactExportRequest,
	w io.Writer,
) (string, string, int, *errx.Error) {
	if req == nil {
		return "", "", 0, errx.New(errx.BadRequest, "missing export request body")
	}

	format, xerr := normalizeExportFormat(req.Format)
	if xerr != nil {
		return "", "", 0, xerr
	}
	scope, xerr := normalizeExportScope(req.Scope)
	if xerr != nil {
		return "", "", 0, xerr
	}

	// Resolve the column set up front so we can render the header row
	// (CSV/XLSX) before walking contacts.
	fields := req.Fields
	if len(fields) == 0 {
		fields = models.DefaultExportFields
	}
	if err := validateFieldList(fields); err != nil {
		return "", "", 0, err
	}

	var (
		searchFilters *models.SearchContacts
		contactIDs    []string
	)
	switch scope {
	case models.ContactExportScopeAll:
		// nothing to do; ExportAll handles "no filters" as "everyone".
	case models.ContactExportScopeFiltered:
		if req.Filters == nil {
			return "", "", 0, errx.New(errx.BadRequest, "filters required when scope=filtered")
		}
		searchFilters = req.Filters
	case models.ContactExportScopeSelected:
		if len(req.ContactIDs) == 0 {
			return "", "", 0, errx.New(errx.BadRequest, "contact_ids required when scope=selected")
		}
		contactIDs = req.ContactIDs
		// Filters are optional here: a campaign Leads export passes its
		// campaign so the lead_* columns are populated for the selected rows.
		searchFilters = req.Filters
	}
	if searchFilters != nil {
		if err := validateLeadFilters(*searchFilters); err != nil {
			return "", "", 0, err
		}
	}

	rows, xerr := s.contactRepository.ExportAll(ctx, orgID, searchFilters, contactIDs, models.MaxContactExportRows)
	if xerr != nil {
		return "", "", 0, xerr
	}

	filename := sanitizeFilename(req.Filename)
	if filename == "" {
		filename = "contacts-" + time.Now().UTC().Format("2006-01-02")
	}

	switch format {
	case models.ContactExportFormatCSV:
		if err := writeCSV(w, rows, fields); err != nil {
			return "", "", 0, errx.InternalError()
		}
		return filename + ".csv", "text/csv; charset=utf-8", len(rows), nil
	case models.ContactExportFormatJSON:
		if err := writeJSON(w, rows, fields); err != nil {
			return "", "", 0, errx.InternalError()
		}
		return filename + ".json", "application/json", len(rows), nil
	case models.ContactExportFormatXLSX:
		if err := writeXLSX(w, rows, fields); err != nil {
			return "", "", 0, errx.InternalError()
		}
		return filename + ".xlsx",
			"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
			len(rows), nil
	}
	return "", "", 0, errx.New(errx.BadRequest, "unsupported export format")
}

func normalizeExportFormat(f models.ContactExportFormat) (models.ContactExportFormat, *errx.Error) {
	switch strings.ToLower(string(f)) {
	case "", "csv":
		return models.ContactExportFormatCSV, nil
	case "xlsx":
		return models.ContactExportFormatXLSX, nil
	case "json":
		return models.ContactExportFormatJSON, nil
	}
	return "", errx.New(errx.BadRequest, "unsupported format: "+string(f))
}

func normalizeExportScope(s models.ContactExportScope) (models.ContactExportScope, *errx.Error) {
	switch strings.ToLower(string(s)) {
	case "", "all":
		return models.ContactExportScopeAll, nil
	case "filtered":
		return models.ContactExportScopeFiltered, nil
	case "selected":
		return models.ContactExportScopeSelected, nil
	}
	return "", errx.New(errx.BadRequest, "unsupported scope: "+string(s))
}

// validateFieldList catches typo'd field identifiers early so the user
// sees an explicit error instead of a column full of blanks.
func validateFieldList(fields []string) *errx.Error {
	for _, f := range fields {
		if strings.HasPrefix(f, "custom:") {
			key := strings.TrimPrefix(f, "custom:")
			if key == "" {
				return errx.New(errx.BadRequest, "custom: prefix without a key")
			}
			continue
		}
		switch f {
		case models.ContactExportFieldID,
			models.ContactExportFieldEmail,
			models.ContactExportFieldFirstName,
			models.ContactExportFieldLastName,
			models.ContactExportFieldCompany,
			models.ContactExportFieldPhone,
			models.ContactExportFieldSubscribed,
			models.ContactExportFieldCategories,
			models.ContactExportFieldCampaigns,
			models.ContactExportFieldCreatedAt,
			models.ContactExportFieldUpdatedAt,
			models.ContactExportFieldLeadStatus,
			models.ContactExportFieldLeadOpened,
			models.ContactExportFieldLeadClicked,
			models.ContactExportFieldLeadReplied:
		default:
			return errx.New(errx.BadRequest, "unknown export field: "+f)
		}
	}
	return nil
}

// fieldHeader is the user-visible column label. We keep the export
// header human-readable (capitalised, no underscores) since the file
// is meant to be opened in Excel/Sheets and stared at.
func fieldHeader(f string) string {
	if strings.HasPrefix(f, "custom:") {
		return strings.TrimPrefix(f, "custom:")
	}
	switch f {
	case models.ContactExportFieldID:
		return "ID"
	case models.ContactExportFieldEmail:
		return "Email"
	case models.ContactExportFieldFirstName:
		return "First Name"
	case models.ContactExportFieldLastName:
		return "Last Name"
	case models.ContactExportFieldCompany:
		return "Company"
	case models.ContactExportFieldPhone:
		return "Phone"
	case models.ContactExportFieldSubscribed:
		return "Subscribed"
	case models.ContactExportFieldCategories:
		return "Categories"
	case models.ContactExportFieldCampaigns:
		return "Campaigns"
	case models.ContactExportFieldCreatedAt:
		return "Created At"
	case models.ContactExportFieldUpdatedAt:
		return "Updated At"
	case models.ContactExportFieldLeadStatus:
		return "Lead Status"
	case models.ContactExportFieldLeadOpened:
		return "Opened"
	case models.ContactExportFieldLeadClicked:
		return "Clicked"
	case models.ContactExportFieldLeadReplied:
		return "Replied"
	}
	return f
}

// fieldValue is the cell text for a given contact + field. Concrete
// types are stringified consistently so the same exported file produces
// the same hash if exported twice with no data changes (helps QA).
func fieldValue(c *models.Contact, f string) string {
	if strings.HasPrefix(f, "custom:") {
		key := strings.TrimPrefix(f, "custom:")
		if c.CustomFields != nil {
			return c.CustomFields[key]
		}
		return ""
	}
	switch f {
	case models.ContactExportFieldID:
		return c.ID.String()
	case models.ContactExportFieldEmail:
		return c.Email
	case models.ContactExportFieldFirstName:
		return c.FirstName
	case models.ContactExportFieldLastName:
		return c.LastName
	case models.ContactExportFieldCompany:
		return c.Company
	case models.ContactExportFieldPhone:
		return c.Phone
	case models.ContactExportFieldSubscribed:
		return strconv.FormatBool(c.Subscribed)
	case models.ContactExportFieldCategories:
		titles := make([]string, len(c.Categories))
		for i, cat := range c.Categories {
			titles[i] = cat.Title
		}
		return strings.Join(titles, ", ")
	case models.ContactExportFieldCampaigns:
		names := make([]string, len(c.Campaigns))
		for i, cam := range c.Campaigns {
			names[i] = cam.Name
		}
		return strings.Join(names, ", ")
	case models.ContactExportFieldCreatedAt:
		if c.CreatedAt.IsZero() {
			return ""
		}
		return c.CreatedAt.UTC().Format(time.RFC3339)
	case models.ContactExportFieldUpdatedAt:
		if c.UpdatedAt.IsZero() {
			return ""
		}
		return c.UpdatedAt.UTC().Format(time.RFC3339)
	case models.ContactExportFieldLeadStatus:
		if c.CampaignLead == nil {
			return ""
		}
		return c.CampaignLead.Status
	case models.ContactExportFieldLeadOpened, models.ContactExportFieldLeadClicked, models.ContactExportFieldLeadReplied:
		if c.CampaignLead == nil {
			return ""
		}
		return strconv.FormatBool(leadEngaged(c.CampaignLead, f))
	}
	return ""
}

// leadEngaged is the yes/no engagement cell: any human open, click or reply on
// any step of the campaign.
func leadEngaged(lead *models.ContactCampaignProgress, f string) bool {
	switch f {
	case models.ContactExportFieldLeadOpened:
		return lead.Opened > 0
	case models.ContactExportFieldLeadClicked:
		return lead.Clicked > 0
	case models.ContactExportFieldLeadReplied:
		return lead.Replied > 0
	}
	return false
}

// writeCSV uses encoding/csv from the stdlib. The leading UTF-8 BOM is
// what Excel-on-Windows looks for to render non-ASCII characters
// correctly when opening a CSV directly — without it, names like "Söre"
// become "SÃ¶re". Annoying but real.
func writeCSV(w io.Writer, rows []models.Contact, fields []string) error {
	if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
		return err
	}
	cw := csv.NewWriter(w)
	headers := make([]string, len(fields))
	for i, f := range fields {
		headers[i] = fieldHeader(f)
	}
	if err := cw.Write(headers); err != nil {
		return err
	}
	rec := make([]string, len(fields))
	for i := range rows {
		c := &rows[i]
		for j, f := range fields {
			rec[j] = fieldValue(c, f)
		}
		if err := cw.Write(rec); err != nil {
			return err
		}
	}
	cw.Flush()
	return cw.Error()
}

// writeJSON emits a flat array of objects keyed by field identifier so
// the file round-trips through the import endpoint unmodified.
func writeJSON(w io.Writer, rows []models.Contact, fields []string) error {
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	out := make([]map[string]any, len(rows))
	for i := range rows {
		c := &rows[i]
		obj := make(map[string]any, len(fields))
		for _, f := range fields {
			if f == models.ContactExportFieldSubscribed {
				obj[f] = c.Subscribed
			} else if f == models.ContactExportFieldCategories {
				obj[f] = c.Categories
			} else if f == models.ContactExportFieldCampaigns {
				obj[f] = c.Campaigns
			} else if c.CampaignLead != nil && (f == models.ContactExportFieldLeadOpened || f == models.ContactExportFieldLeadClicked || f == models.ContactExportFieldLeadReplied) {
				obj[f] = leadEngaged(c.CampaignLead, f)
			} else {
				obj[f] = fieldValue(c, f)
			}
		}
		out[i] = obj
	}
	return enc.Encode(out)
}

// writeXLSX writes a single-sheet xlsx file using excelize. We freeze
// the header row + bold it so the file feels native to anyone who's
// ever opened an Excel template.
func writeXLSX(w io.Writer, rows []models.Contact, fields []string) error {
	f := excelize.NewFile()
	defer f.Close()

	sheet := "Contacts"
	idx, err := f.NewSheet(sheet)
	if err != nil {
		return err
	}
	f.SetActiveSheet(idx)
	if err := f.DeleteSheet("Sheet1"); err != nil {
		// Best-effort: this is the default sheet excelize creates;
		// if it's already gone we don't care.
		var notFoundErr *excelize.ErrSheetNotExist
		if !errors.As(err, &notFoundErr) {
			return err
		}
	}

	headerStyle, _ := f.NewStyle(&excelize.Style{
		Font: &excelize.Font{Bold: true},
		Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"#F1F5F9"}},
	})

	for i, fld := range fields {
		cell, _ := excelize.CoordinatesToCellName(i+1, 1)
		if err := f.SetCellValue(sheet, cell, fieldHeader(fld)); err != nil {
			return err
		}
	}
	if headerStyle != 0 && len(fields) > 0 {
		end, _ := excelize.CoordinatesToCellName(len(fields), 1)
		_ = f.SetCellStyle(sheet, "A1", end, headerStyle)
	}
	if err := f.SetPanes(sheet, &excelize.Panes{
		Freeze:      true,
		Split:       false,
		XSplit:      0,
		YSplit:      1,
		TopLeftCell: "A2",
		ActivePane:  "bottomLeft",
	}); err != nil {
		// Freeze pane is decorative; don't fail the whole export over it.
		_ = err
	}

	for r := 0; r < len(rows); r++ {
		c := &rows[r]
		for i, fld := range fields {
			cell, _ := excelize.CoordinatesToCellName(i+1, r+2)
			var v any
			if fld == models.ContactExportFieldSubscribed {
				v = c.Subscribed
			} else {
				v = fieldValue(c, fld)
			}
			if err := f.SetCellValue(sheet, cell, v); err != nil {
				return err
			}
		}
	}

	// Reasonable column widths so the file looks usable on first open.
	if len(fields) > 0 {
		startCol, _ := excelize.ColumnNumberToName(1)
		endCol, _ := excelize.ColumnNumberToName(len(fields))
		_ = f.SetColWidth(sheet, startCol, endCol, 20)
	}

	return f.Write(w)
}

var filenameSafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)

// sanitizeFilename strips path separators and weird unicode so the
// Content-Disposition header is well-formed without quoting tricks.
func sanitizeFilename(name string) string {
	name = strings.TrimSpace(name)
	if name == "" {
		return ""
	}
	name = filenameSafe.ReplaceAllString(name, "_")
	name = strings.Trim(name, "._-")
	if len(name) > 80 {
		name = name[:80]
	}
	return name
}
