package services

import (
	"bytes"
	"encoding/csv"
	"encoding/xml"
	"fmt"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"git.mchus.pro/mchus/quoteforge/internal/localdb"
	"git.mchus.pro/mchus/quoteforge/internal/models"
	"git.mchus.pro/mchus/quoteforge/internal/repository"
	"github.com/google/uuid"
	"gorm.io/gorm"
)

type VendorWorkspaceImportResult struct {
	Imported int                             `json:"imported"`
	Project  *models.Project                 `json:"project,omitempty"`
	Configs  []VendorWorkspaceImportedConfig `json:"configs"`
}

type VendorWorkspaceImportedConfig struct {
	UUID        string `json:"uuid"`
	Name        string `json:"name"`
	ServerCount int    `json:"server_count"`
	ServerModel string `json:"server_model,omitempty"`
	Rows        int    `json:"rows"`
}

type importedWorkspace struct {
	SourceFormat   string
	SourceDocID    string
	SourceFileName string
	CurrencyCode   string
	Configurations []importedConfiguration
}

type importedConfiguration struct {
	GroupID      string
	Name         string
	Line         int
	ServerCount  int
	ServerModel  string
	Article      string
	CurrencyCode string
	Rows         []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
	DirectItems  localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
	TotalPrice   *float64
}

type groupedItem struct {
	order int
	row   cfxmlProductLineItem
}

type cfxmlDocument struct {
	XMLName                xml.Name                `xml:"CFXML"`
	ThisDocumentIdentifier cfxmlDocumentIdentifier `xml:"thisDocumentIdentifier"`
	CFData                 cfxmlData               `xml:"CFData"`
}

type cfxmlDocumentIdentifier struct {
	ProprietaryDocumentIdentifier string `xml:"ProprietaryDocumentIdentifier"`
}

type cfxmlData struct {
	ProprietaryInformation []cfxmlProprietaryInformation `xml:"ProprietaryInformation"`
	ProductLineItems       []cfxmlProductLineItem        `xml:"ProductLineItem"`
}

type cfxmlProprietaryInformation struct {
	Name  string `xml:"Name"`
	Value string `xml:"Value"`
}

type cfxmlProductLineItem struct {
	ProductLineNumber                     string                     `xml:"ProductLineNumber"`
	ItemNo                                string                     `xml:"ItemNo"`
	TransactionType                       string                     `xml:"TransactionType"`
	ProprietaryGroupIdentifier            string                     `xml:"ProprietaryGroupIdentifier"`
	ConfigurationGroupLineNumberReference string                     `xml:"ConfigurationGroupLineNumberReference"`
	Quantity                              string                     `xml:"Quantity"`
	ProductIdentification                 cfxmlProductIdentification `xml:"ProductIdentification"`
	UnitListPrice                         cfxmlUnitListPrice         `xml:"UnitListPrice"`
	ProductSubLineItems                   []cfxmlProductSubLineItem  `xml:"ProductSubLineItem"`
}

type cfxmlProductSubLineItem struct {
	LineNumber            string                     `xml:"LineNumber"`
	TransactionType       string                     `xml:"TransactionType"`
	Quantity              string                     `xml:"Quantity"`
	ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
	UnitListPrice         cfxmlUnitListPrice         `xml:"UnitListPrice"`
}

type cfxmlProductIdentification struct {
	PartnerProductIdentification cfxmlPartnerProductIdentification `xml:"PartnerProductIdentification"`
}

type cfxmlPartnerProductIdentification struct {
	ProprietaryProductIdentifier string `xml:"ProprietaryProductIdentifier"`
	ProprietaryProductChar       string `xml:"ProprietaryProductChar"`
	ProductCharacter             string `xml:"ProductCharacter"`
	ProductDescription           string `xml:"ProductDescription"`
	ProductName                  string `xml:"ProductName"`
	ProductTypeCode              string `xml:"ProductTypeCode"`
}

type cfxmlUnitListPrice struct {
	FinancialAmount cfxmlFinancialAmount `xml:"FinancialAmount"`
}

type cfxmlFinancialAmount struct {
	GlobalCurrencyCode string `xml:"GlobalCurrencyCode"`
	MonetaryAmount     string `xml:"MonetaryAmount"`
}

func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID string, sourceFileName string, data []byte, ownerUsername string) (*VendorWorkspaceImportResult, error) {
	project, err := s.localDB.GetProjectByUUID(projectUUID)
	if err != nil {
		return nil, ErrProjectNotFound
	}

	var workspace *importedWorkspace
	switch {
	case IsCFXMLWorkspace(data):
		workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
	case IsQuoteForgeCSV(data):
		workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
	case IsInspurBOM(data):
		workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
	case IsNxBOM(data):
		workspace, err = parseNxBOM(data, filepath.Base(sourceFileName))
	case IsTextBOM(data):
		workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
	default:
		return nil, fmt.Errorf("unsupported vendor export format")
	}
	if err != nil {
		return nil, err
	}

	result := &VendorWorkspaceImportResult{
		Imported: 0,
		Project:  localdb.LocalToProject(project),
		Configs:  make([]VendorWorkspaceImportedConfig, 0, len(workspace.Configurations)),
	}

	err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
		bookRepo := repository.NewPartnumberBookRepository(tx)
		for _, imported := range workspace.Configurations {
			now := time.Now()
			cfgUUID := uuid.NewString()

			var groupRows localdb.VendorSpec
			var items localdb.LocalConfigItems
			var totalPrice *float64
			var estimatePricelistID *uint

			if len(imported.DirectItems) > 0 {
				items = imported.DirectItems
				estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
				if estimatePricelist != nil {
					estimatePricelistID = &estimatePricelist.ServerID
				}
				val := items.Total() * float64(maxInt(imported.ServerCount, 1))
				totalPrice = &val
			} else {
				var prepErr error
				groupRows, items, totalPrice, estimatePricelistID, prepErr = s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
				if prepErr != nil {
					return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, prepErr)
				}
			}

			localCfg := &localdb.LocalConfiguration{
				UUID:             cfgUUID,
				ProjectUUID:      &projectUUID,
				IsActive:         true,
				Name:             imported.Name,
				Items:            items,
				TotalPrice:       totalPrice,
				ServerCount:      imported.ServerCount,
				ServerModel:      imported.ServerModel,
				Article:          imported.Article,
				PricelistID:      estimatePricelistID,
				VendorSpec:       groupRows,
				CreatedAt:        now,
				UpdatedAt:        now,
				SyncStatus:       "pending",
				OriginalUsername: ownerUsername,
			}

			if err := s.createWithVersionTx(tx, localCfg, ownerUsername); err != nil {
				return fmt.Errorf("import configuration group %s: %w", imported.GroupID, err)
			}

			result.Imported++
			result.Configs = append(result.Configs, VendorWorkspaceImportedConfig{
				UUID:        localCfg.UUID,
				Name:        localCfg.Name,
				ServerCount: localCfg.ServerCount,
				ServerModel: localCfg.ServerModel,
				Rows:        len(localCfg.VendorSpec),
			})
		}
		return nil
	})
	if err != nil {
		return nil, err
	}

	return result, nil
}

func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.VendorSpecItem, serverCount int, bookRepo *repository.PartnumberBookRepository) (localdb.VendorSpec, localdb.LocalConfigItems, *float64, *uint, error) {
	resolver := NewVendorSpecResolver(bookRepo)
	resolved, err := resolver.Resolve(append([]localdb.VendorSpecItem(nil), rows...))
	if err != nil {
		return nil, nil, nil, nil, err
	}

	canonical := make(localdb.VendorSpec, 0, len(resolved))
	for _, row := range resolved {
		if len(row.LotMappings) == 0 && strings.TrimSpace(row.ResolvedLotName) != "" {
			row.LotMappings = []localdb.VendorSpecLotMapping{
				{LotName: strings.TrimSpace(row.ResolvedLotName), QuantityPerPN: 1},
			}
		}
		row.LotMappings = normalizeImportedLotMappings(row.LotMappings)
		row.ResolvedLotName = ""
		row.ResolutionSource = ""
		row.ManualLotSuggestion = ""
		row.LotQtyPerPN = 0
		row.LotAllocations = nil
		canonical = append(canonical, row)
	}

	estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
	var serverPricelistID *uint
	if estimatePricelist != nil {
		serverPricelistID = &estimatePricelist.ServerID
	}

	items := aggregateVendorSpecToItems(canonical, estimatePricelist, s.localDB)
	totalValue := items.Total()
	if serverCount > 1 {
		totalValue *= float64(serverCount)
	}
	totalPrice := &totalValue
	return canonical, items, totalPrice, serverPricelistID, nil
}

func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *localdb.LocalPricelist, local *localdb.LocalDB) localdb.LocalConfigItems {
	if len(spec) == 0 {
		return localdb.LocalConfigItems{}
	}

	lotMap := make(map[string]int)
	order := make([]string, 0)
	for _, row := range spec {
		for _, mapping := range normalizeImportedLotMappings(row.LotMappings) {
			if _, exists := lotMap[mapping.LotName]; !exists {
				order = append(order, mapping.LotName)
			}
			lotMap[mapping.LotName] += row.Quantity * mapping.QuantityPerPN
		}
	}

	sort.Strings(order)

	var priceMap map[string]float64
	if estimatePricelist != nil && local != nil && len(order) > 0 {
		priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, order)
	}

	items := make(localdb.LocalConfigItems, 0, len(order))
	for _, lotName := range order {
		unitPrice := 0.0
		if priceMap != nil {
			unitPrice = priceMap[lotName]
		}
		items = append(items, localdb.LocalConfigItem{
			LotName:   lotName,
			Quantity:  lotMap[lotName],
			UnitPrice: unitPrice,
		})
	}
	return items
}

func normalizeImportedLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
	if len(in) == 0 {
		return nil
	}
	merged := make(map[string]int, len(in))
	order := make([]string, 0, len(in))
	for _, mapping := range in {
		lot := strings.TrimSpace(mapping.LotName)
		if lot == "" {
			continue
		}
		qty := mapping.QuantityPerPN
		if qty < 1 {
			qty = 1
		}
		if _, exists := merged[lot]; !exists {
			order = append(order, lot)
		}
		merged[lot] += qty
	}
	out := make([]localdb.VendorSpecLotMapping, 0, len(order))
	for _, lot := range order {
		out = append(out, localdb.VendorSpecLotMapping{
			LotName:       lot,
			QuantityPerPN: merged[lot],
		})
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func parseCFXMLWorkspace(data []byte, sourceFileName string) (*importedWorkspace, error) {
	var doc cfxmlDocument
	if err := xml.Unmarshal(data, &doc); err != nil {
		return nil, fmt.Errorf("parse CFXML workspace: %w", err)
	}
	if doc.XMLName.Local != "CFXML" {
		return nil, fmt.Errorf("unsupported workspace root: %s", doc.XMLName.Local)
	}
	if len(doc.CFData.ProductLineItems) == 0 {
		return nil, fmt.Errorf("CFXML workspace has no ProductLineItem rows")
	}

	workspace := &importedWorkspace{
		SourceFormat:   "CFXML",
		SourceDocID:    strings.TrimSpace(doc.ThisDocumentIdentifier.ProprietaryDocumentIdentifier),
		SourceFileName: sourceFileName,
		CurrencyCode:   detectWorkspaceCurrency(doc.CFData.ProprietaryInformation, doc.CFData.ProductLineItems),
	}

	type groupBucket struct {
		order int
		items []groupedItem
	}

	groupOrder := make([]string, 0)
	groups := make(map[string]*groupBucket)
	for idx, item := range doc.CFData.ProductLineItems {
		groupID := strings.TrimSpace(item.ProprietaryGroupIdentifier)
		if groupID == "" {
			groupID = firstNonEmpty(strings.TrimSpace(item.ProductLineNumber), strings.TrimSpace(item.ItemNo), fmt.Sprintf("group-%d", idx+1))
		}
		bucket := groups[groupID]
		if bucket == nil {
			bucket = &groupBucket{order: idx}
			groups[groupID] = bucket
			groupOrder = append(groupOrder, groupID)
		}
		bucket.items = append(bucket.items, groupedItem{order: idx, row: item})
	}

	for lineIdx, groupID := range groupOrder {
		bucket := groups[groupID]
		if bucket == nil || len(bucket.items) == 0 {
			continue
		}
		primary := pickPrimaryTopLevelRow(bucket.items)
		serverCount := maxInt(parseInt(primary.row.Quantity), 1)
		rows := make([]localdb.VendorSpecItem, 0, len(bucket.items)*4)
		sortOrder := 10

		for _, item := range bucket.items {
			topRow := vendorSpecItemFromTopLevel(item.row, serverCount, sortOrder)
			if topRow != nil {
				rows = append(rows, *topRow)
				sortOrder += 10
			}

			for _, sub := range item.row.ProductSubLineItems {
				subRow := vendorSpecItemFromSubLine(sub, sortOrder)
				if subRow == nil {
					continue
				}
				rows = append(rows, *subRow)
				sortOrder += 10
			}
		}

		total := sumVendorSpecRows(rows, serverCount)
		name := strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductName)
		if name == "" {
			name = strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription)
		}
		if name == "" {
			name = fmt.Sprintf("Imported config %d", lineIdx+1)
		}

		workspace.Configurations = append(workspace.Configurations, importedConfiguration{
			GroupID:      groupID,
			Name:         name,
			Line:         (lineIdx + 1) * 10,
			ServerCount:  serverCount,
			ServerModel:  strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription),
			Article:      strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier),
			CurrencyCode: workspace.CurrencyCode,
			Rows:         rows,
			TotalPrice:   total,
		})
	}

	if len(workspace.Configurations) == 0 {
		return nil, fmt.Errorf("CFXML workspace has no importable configuration groups")
	}

	return workspace, nil
}

func detectWorkspaceCurrency(meta []cfxmlProprietaryInformation, rows []cfxmlProductLineItem) string {
	for _, item := range meta {
		if strings.EqualFold(strings.TrimSpace(item.Name), "Currencies") {
			value := strings.TrimSpace(item.Value)
			if value != "" {
				return value
			}
		}
	}
	for _, row := range rows {
		code := strings.TrimSpace(row.UnitListPrice.FinancialAmount.GlobalCurrencyCode)
		if code != "" {
			return code
		}
	}
	return ""
}

func pickPrimaryTopLevelRow(items []groupedItem) groupedItem {
	best := items[0]
	bestScore := primaryScore(best.row)
	for _, item := range items[1:] {
		score := primaryScore(item.row)
		if score > bestScore {
			best = item
			bestScore = score
			continue
		}
		if score == bestScore && compareLineNumbers(item.row.ProductLineNumber, best.row.ProductLineNumber) < 0 {
			best = item
		}
	}
	return best
}

func primaryScore(row cfxmlProductLineItem) int {
	score := len(row.ProductSubLineItems)
	if strings.EqualFold(strings.TrimSpace(row.ProductIdentification.PartnerProductIdentification.ProductTypeCode), "Hardware") {
		score += 100000
	}
	return score
}

func compareLineNumbers(left, right string) int {
	li := parseInt(left)
	ri := parseInt(right)
	switch {
	case li < ri:
		return -1
	case li > ri:
		return 1
	default:
		return strings.Compare(left, right)
	}
}

func vendorSpecItemFromTopLevel(item cfxmlProductLineItem, serverCount int, sortOrder int) *localdb.VendorSpecItem {
	code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
	desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
	if code == "" && desc == "" {
		return nil
	}
	qty := normalizeTopLevelQuantity(item.Quantity, serverCount)
	unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
	return &localdb.VendorSpecItem{
		SortOrder:        sortOrder,
		VendorPartnumber: code,
		Quantity:         qty,
		Description:      desc,
		UnitPrice:        unitPrice,
		TotalPrice:       totalPrice(unitPrice, qty),
	}
}

func vendorSpecItemFromSubLine(item cfxmlProductSubLineItem, sortOrder int) *localdb.VendorSpecItem {
	code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
	desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
	if code == "" && desc == "" {
		return nil
	}
	qty := maxInt(parseInt(item.Quantity), 1)
	unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
	return &localdb.VendorSpecItem{
		SortOrder:        sortOrder,
		VendorPartnumber: code,
		Quantity:         qty,
		Description:      desc,
		UnitPrice:        unitPrice,
		TotalPrice:       totalPrice(unitPrice, qty),
	}
}

func sumVendorSpecRows(rows []localdb.VendorSpecItem, serverCount int) *float64 {
	total := 0.0
	hasTotal := false
	for _, row := range rows {
		if row.TotalPrice == nil {
			continue
		}
		total += *row.TotalPrice
		hasTotal = true
	}
	if !hasTotal {
		return nil
	}
	if serverCount > 1 {
		total *= float64(serverCount)
	}
	return &total
}

func totalPrice(unitPrice *float64, qty int) *float64 {
	if unitPrice == nil {
		return nil
	}
	total := *unitPrice * float64(qty)
	return &total
}

func parseOptionalFloat(raw string) *float64 {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return nil
	}
	value, err := strconv.ParseFloat(trimmed, 64)
	if err != nil {
		return nil
	}
	return &value
}

func parseInt(raw string) int {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return 0
	}
	value, err := strconv.Atoi(trimmed)
	if err != nil {
		return 0
	}
	return value
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return strings.TrimSpace(value)
		}
	}
	return ""
}

func maxInt(value, floor int) int {
	if value < floor {
		return floor
	}
	return value
}

func normalizeTopLevelQuantity(raw string, serverCount int) int {
	qty := maxInt(parseInt(raw), 1)
	if serverCount <= 1 {
		return qty
	}
	if qty%serverCount == 0 {
		return maxInt(qty/serverCount, 1)
	}
	return qty
}

func IsCFXMLWorkspace(data []byte) bool {
	return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
}

func IsInspurBOM(data []byte) bool {
	for _, line := range bytes.Split(data, []byte("\n")) {
		trimmed := bytes.TrimSpace(line)
		if len(trimmed) == 0 {
			continue
		}
		idx := bytes.LastIndexByte(trimmed, '*')
		if idx <= 0 {
			continue
		}
		suffix := bytes.TrimSpace(trimmed[idx+1:])
		if len(suffix) > 0 && allDigits(suffix) {
			return true
		}
	}
	return false
}

func allDigits(b []byte) bool {
	if len(b) == 0 {
		return false
	}
	for _, c := range b {
		if c < '0' || c > '9' {
			return false
		}
	}
	return true
}

func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
	lines := strings.Split(string(data), "\n")
	rows := make([]localdb.VendorSpecItem, 0, len(lines))
	sortOrder := 10
	for _, raw := range lines {
		line := strings.TrimSpace(raw)
		if line == "" {
			continue
		}
		line = strings.TrimPrefix(line, "|")
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		pn := line
		qty := 1
		if idx := strings.LastIndex(line, "*"); idx > 0 {
			suffix := strings.TrimSpace(line[idx+1:])
			if n, err := strconv.Atoi(suffix); err == nil && n > 0 {
				pn = strings.TrimSpace(line[:idx])
				qty = n
			}
		}
		if pn == "" {
			continue
		}
		rows = append(rows, localdb.VendorSpecItem{
			SortOrder:        sortOrder,
			VendorPartnumber: pn,
			Quantity:         qty,
		})
		sortOrder += 10
	}
	if len(rows) == 0 {
		return nil, fmt.Errorf("Inspur BOM has no importable rows")
	}

	name := strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
	if name == "" {
		name = "Inspur Import"
	}

	return &importedWorkspace{
		SourceFormat:   "Inspur",
		SourceFileName: sourceFileName,
		Configurations: []importedConfiguration{
			{
				GroupID:     "inspur-0",
				Name:        name,
				Line:        10,
				ServerCount: 1,
				Rows:        rows,
			},
		},
	}, nil
}

// nxBOMItemLine matches a quantity-first BOM line: "<qty>x <description>"
// where the quantity prefix is digits followed immediately by "x" (case-insensitive).
// Parentheses, commas, and hyphens inside the description are preserved.
var nxBOMItemLine = regexp.MustCompile(`(?i)^(\d+)[xX]\s+(.+\S)\s*$`)

// IsNxBOM reports whether data looks like a quantity-first "Nx" BOM where each
// item line begins with "<qty>x <description>" (e.g. "2x Intel Xeon 8570 ...").
func IsNxBOM(data []byte) bool {
	for _, raw := range strings.Split(string(data), "\n") {
		if nxBOMItemLine.MatchString(strings.TrimSpace(raw)) {
			return true
		}
	}
	return false
}

// parseNxBOM parses a quantity-first "Nx" BOM into a single configuration.
// An optional header line ending with ", в составе:" supplies server_model and name.
// Each "<qty>x <description>" line becomes one vendor spec row; description is stored
// as both vendor_partnumber and description so rows resolve through the active
// partnumber book when matched and otherwise stay unresolved and editable in the UI.
func parseNxBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
	lines := strings.Split(string(data), "\n")
	rows := make([]localdb.VendorSpecItem, 0, len(lines))
	sortOrder := 10
	serverModel := ""

	for _, raw := range lines {
		line := strings.TrimSpace(raw)
		if line == "" {
			continue
		}
		if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
			if fields := strings.Fields(m[1]); len(fields) > 0 {
				serverModel = fields[len(fields)-1]
			}
			continue
		}
		m := nxBOMItemLine.FindStringSubmatch(line)
		if m == nil {
			continue
		}
		qty, err := strconv.Atoi(m[1])
		if err != nil || qty <= 0 {
			continue
		}
		description := strings.TrimSpace(m[2])
		if description == "" {
			continue
		}
		rows = append(rows, localdb.VendorSpecItem{
			SortOrder:        sortOrder,
			VendorPartnumber: description,
			Quantity:         qty,
			Description:      description,
		})
		sortOrder += 10
	}

	if len(rows) == 0 {
		return nil, fmt.Errorf("Nx BOM has no importable rows")
	}

	name := serverModel
	if name == "" {
		name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
	}
	if name == "" {
		name = "Nx BOM Import"
	}

	return &importedWorkspace{
		SourceFormat:   "Nx",
		SourceFileName: sourceFileName,
		Configurations: []importedConfiguration{
			{
				GroupID:     "nx-0",
				Name:        name,
				Line:        10,
				ServerCount: 1,
				ServerModel: serverModel,
				Rows:        rows,
			},
		},
	}, nil
}

// textBOMItemLine matches a human-readable BOM line of the form
// "<description> - <quantity> шт." where the separator may be a hyphen,
// en-dash or em-dash and the quantity may have an optional space before "шт".
// The quantity anchor at the end keeps internal hyphens/digits in the
// description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)

// textBOMHeaderLine matches a configuration header ending with ", в составе:"
// regardless of the leading words (e.g. "Сервер <model>" or
// "Вычислительный GPU сервер <model>"). The captured group is everything before
// the comma; the model is its last whitespace-separated token.
var textBOMHeaderLine = regexp.MustCompile(`(?i)^(.*?)\s*,\s*в\s+составе`)

// ParsePastedBOMText detects and parses a single-column text BOM (Inspur or
// Russian text BOM) pasted into the configurator. It shares the same detectors
// and parsers as the vendor file-import path, so paste and upload behave
// identically. It returns the parsed vendor spec rows and the detected format,
// or (nil, "") when the text is not a recognized single-column format and the
// caller should fall back to manual column mapping.
func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
	data := []byte(text)
	var ws *importedWorkspace
	var err error
	switch {
	case IsInspurBOM(data):
		ws, err = parseInspurBOM(data, "")
	case IsNxBOM(data):
		ws, err = parseNxBOM(data, "")
	case IsTextBOM(data):
		ws, err = parseTextBOM(data, "")
	default:
		return nil, ""
	}
	if err != nil || ws == nil || len(ws.Configurations) == 0 {
		return nil, ""
	}
	return ws.Configurations[0].Rows, ws.SourceFormat
}

// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
// i.e. it contains at least one "<description> - <quantity> шт." line.
func IsTextBOM(data []byte) bool {
	for _, raw := range strings.Split(string(data), "\n") {
		if textBOMItemLine.MatchString(strings.TrimSpace(raw)) {
			return true
		}
	}
	return false
}

// parseTextBOM parses a human-readable Russian text BOM into a single configuration.
// The optional "Сервер <model>, в составе:" header provides the configuration name and
// server model. Each "<description> - <quantity> шт." line becomes one vendor spec row.
// The format carries no partnumbers, so rows stay unresolved and editable in the UI
// until mapped through the active partnumber book.
func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
	lines := strings.Split(string(data), "\n")
	rows := make([]localdb.VendorSpecItem, 0, len(lines))
	sortOrder := 10
	serverModel := ""

	for _, raw := range lines {
		line := strings.TrimSpace(raw)
		if line == "" {
			continue
		}
		if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
			if fields := strings.Fields(m[1]); len(fields) > 0 {
				serverModel = fields[len(fields)-1]
			}
			continue
		}
		m := textBOMItemLine.FindStringSubmatch(line)
		if m == nil {
			continue
		}
		description := strings.TrimSpace(m[1])
		qty, err := strconv.Atoi(m[2])
		if err != nil || qty <= 0 || description == "" {
			continue
		}
		rows = append(rows, localdb.VendorSpecItem{
			SortOrder:        sortOrder,
			VendorPartnumber: description,
			Quantity:         qty,
			Description:      description,
		})
		sortOrder += 10
	}

	if len(rows) == 0 {
		return nil, fmt.Errorf("text BOM has no importable rows")
	}

	name := serverModel
	if name == "" {
		name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
	}
	if name == "" {
		name = "Text BOM Import"
	}

	return &importedWorkspace{
		SourceFormat:   "Text",
		SourceFileName: sourceFileName,
		Configurations: []importedConfiguration{
			{
				GroupID:     "text-0",
				Name:        name,
				Line:        10,
				ServerCount: 1,
				ServerModel: serverModel,
				Rows:        rows,
			},
		},
	}, nil
}

// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
// The file starts (after optional UTF-8 BOM) with the header line:
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
func IsQuoteForgeCSV(data []byte) bool {
	trimmed := bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
	firstLine := trimmed
	if idx := bytes.IndexByte(trimmed, '\n'); idx >= 0 {
		firstLine = trimmed[:idx]
	}
	return bytes.HasPrefix(bytes.TrimSpace(firstLine), []byte("Line;Type;p/n;"))
}

// parseQuoteForgeCSV parses a QuoteForge own CSV export back into importable configurations.
// Each server block (row where Line column is non-empty) becomes one importedConfiguration
// with DirectItems populated from the component rows that follow it.
func parseQuoteForgeCSV(data []byte, sourceFileName string) (*importedWorkspace, error) {
	data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})

	r := csv.NewReader(bytes.NewReader(data))
	r.Comma = ';'
	r.FieldsPerRecord = -1
	r.LazyQuotes = true

	records, err := r.ReadAll()
	if err != nil {
		return nil, fmt.Errorf("parse QuoteForge CSV: %w", err)
	}
	if len(records) == 0 {
		return nil, fmt.Errorf("QuoteForge CSV is empty")
	}

	// Skip header row (first row whose first cell is "Line")
	startIdx := 0
	if len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "line") {
		startIdx = 1
	}

	var configs []importedConfiguration
	var current *importedConfiguration
	blockIdx := 0

	for _, record := range records[startIdx:] {
		if csvAllEmpty(record) {
			continue
		}
		lineCol := strings.TrimSpace(csvCol(record, 0))
		pn := strings.TrimSpace(csvCol(record, 2))

		if lineCol != "" {
			// New server block
			if current != nil {
				configs = append(configs, *current)
			}
			blockIdx++
			serverCount := maxInt(parseInt(strings.TrimSpace(csvCol(record, 5))), 1)
			article := pn
			name := article
			if name == "" {
				name = fmt.Sprintf("Config %d", blockIdx)
			}
			current = &importedConfiguration{
				GroupID:     fmt.Sprintf("qfcsv-%d", blockIdx),
				Name:        name,
				Line:        blockIdx * 10,
				ServerCount: serverCount,
				Article:     article,
				DirectItems: make(localdb.LocalConfigItems, 0),
			}
		} else if pn != "" && current != nil {
			// Component row
			qty := maxInt(parseInt(strings.TrimSpace(csvCol(record, 4))), 1)
			unitPrice := parseCSVPrice(strings.TrimSpace(csvCol(record, 6)))
			current.DirectItems = append(current.DirectItems, localdb.LocalConfigItem{
				LotName:   pn,
				Quantity:  qty,
				UnitPrice: unitPrice,
			})
		}
	}
	if current != nil {
		configs = append(configs, *current)
	}

	if len(configs) == 0 {
		return nil, fmt.Errorf("QuoteForge CSV has no importable configurations")
	}

	return &importedWorkspace{
		SourceFormat:   "QuoteForgeCSV",
		SourceFileName: sourceFileName,
		Configurations: configs,
	}, nil
}

// csvCol returns record[idx] or "" when idx is out of range.
func csvCol(record []string, idx int) string {
	if idx < len(record) {
		return record[idx]
	}
	return ""
}

// csvAllEmpty reports whether every cell in the record is blank.
func csvAllEmpty(record []string) bool {
	for _, cell := range record {
		if strings.TrimSpace(cell) != "" {
			return false
		}
	}
	return true
}

// parseCSVPrice parses a price string in QuoteForge CSV format:
// comma as decimal separator, optional space as thousands separator.
// Returns 0 on any parse failure.
func parseCSVPrice(s string) float64 {
	if s == "" || s == "—" {
		return 0
	}
	// Remove thousands separators (space, non-breaking space)
	s = strings.ReplaceAll(s, " ", "")
	s = strings.ReplaceAll(s, " ", "")
	s = strings.ReplaceAll(s, " ", "")
	// Replace comma decimal separator with dot
	s = strings.ReplaceAll(s, ",", ".")
	v, err := strconv.ParseFloat(s, 64)
	if err != nil {
		return 0
	}
	return v
}
