// Package legacyreport parses legacy text-format Galaxy turn reports
// (the "dg" / "gplus" engines) into [report.Report] values.
//
// Scope is intentionally narrow: only the fields the UI client decodes
// from server reports today (planets, players, own ship classes,
// header data, plus — added in Phase 19 — own ship groups, own fleets
// and incoming groups). Everything else in the legacy file is silently
// skipped. The synthetic-report parity rule in ui/PLAN.md is the
// source of truth for when to extend this parser; the package's
// README.md tracks every legacy section that could be wired up later
// when the corresponding UI decoder lands.
package legacyreport

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"strconv"
	"strings"

	"github.com/google/uuid"

	"galaxy/calc"
	"galaxy/model/report"
)

// Parse reads a legacy text report and returns a [report.Report]
// carrying the in-scope subset of fields, plus the per-battle
// [report.BattleReport] payloads parsed out of the "Battle at (#N)"
// blocks. The Width and Height of the returned report are both set
// to the legacy "Size" value (galaxies are square in the legacy
// engines). The battle slice is empty when the legacy file carries
// no combat events.
func Parse(r io.Reader) (report.Report, []report.BattleReport, error) {
	p := newParser()
	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
	for sc.Scan() {
		if err := p.handle(sc.Text()); err != nil {
			return report.Report{}, nil, err
		}
	}
	if err := sc.Err(); err != nil {
		return report.Report{}, nil, fmt.Errorf("legacyreport: scan: %w", err)
	}
	battles, err := p.finish()
	if err != nil {
		return report.Report{}, nil, err
	}
	return p.rep, battles, nil
}

type section int

const (
	sectionNone section = iota
	sectionStatusOfPlayers
	sectionYourVote
	sectionYourPlanets
	sectionOtherPlanets
	sectionUninhabitedPlanets
	sectionUnidentifiedPlanets
	sectionYourShipTypes
	sectionYourGroups
	sectionYourFleets
	sectionIncomingGroups
	sectionYourSciences
	sectionOtherSciences
	sectionOtherShipTypes
	sectionBombings
	sectionShipsInProduction
	sectionBattle
	sectionBattleProtocol
)

type parser struct {
	rep        report.Report
	sec        section
	otherOwner string
	skipHeader bool
	sawHeader  bool
	sawSize    bool

	// Group/fleet/incoming rows are buffered during the scan because
	// they carry destination/origin planet names that may resolve
	// against the planet tables only after the whole file has been
	// read — "Incoming Groups" can appear before "Your Planets" in
	// some engine variants. Ships-in-production rows are buffered
	// because their prod_used derivation needs the producing planet's
	// material and resources (read from "Your Planets") to call
	// [calc.ShipBuildCost], and the section order is not guaranteed.
	pendingGroups       []pendingGroup
	pendingFleets       []pendingFleet
	pendingIncomings    []pendingIncoming
	pendingShipProducts []pendingShipProduction

	// Battle accumulator. `battles` collects every parsed BattleReport;
	// `pendingBattle` carries the in-flight battle until its block
	// ends (next "Battle at " header, a top-level section header, or
	// end-of-file). `battleIndex` is the per-report 0-based index used
	// to derive a stable synthetic UUID through `syntheticBattleID`.
	// `pendingBattleRace` holds the race name currently being
	// rostered, set by the "<Race> Groups" sub-header that opens each
	// race's roster table inside the battle block.
	battles           []report.BattleReport
	pendingBattle     *pendingBattle
	battleIndex       uint
	pendingBattleRace string
}

type pendingBattle struct {
	id         uuid.UUID
	planet     uint
	planetName string
	// Race name → race index used in Protocol.{a,d}. Indices are
	// 0-based and assigned in first-seen order across the battle.
	raceIndex map[string]int
	// (race name, class name) → ship-group index used in
	// Protocol.{sa,sd}. Indices are 0-based and assigned in
	// first-seen order across the battle, across all races.
	shipIndex map[shipKey]int
	races     map[int]uuid.UUID
	ships     map[int]report.BattleReportGroup
	protocol  []report.BattleActionReport
}

type shipKey struct {
	race  string
	class string
}

type pendingGroup struct {
	g               uint
	number          uint
	class           string
	drive           float64
	weapons         float64
	shields         float64
	cargoTech       float64
	cargoType       string
	load            float64
	destinationName string
	originName      string // empty when "-"
	rangeStr        string // empty when "-"
	mass            float64
	fleet           string // empty when "-"
	state           string
}

type pendingFleet struct {
	name            string
	groups          uint
	destinationName string
	originName      string // empty when "-"
	rangeStr        string // empty when "-"
	state           string
}

type pendingIncoming struct {
	originName      string
	destinationName string
	distance        float64
	speed           float64
	mass            float64
}

type pendingShipProduction struct {
	planetNumber uint
	class        string
	cost         float64
	percent      float64
	free         float64
}

func newParser() *parser {
	return &parser{sec: sectionNone}
}

func (p *parser) handle(line string) error {
	trimmed := strings.TrimSpace(line)

	if !p.sawHeader && trimmed != "" {
		if race, turn, ok := parseHeader(trimmed); ok {
			p.rep.Race = race
			p.rep.Turn = turn
			p.sawHeader = true
			return nil
		}
	}
	if !p.sawSize && strings.HasPrefix(trimmed, "Size:") {
		if w, planets, ok := parseSize(trimmed); ok {
			p.rep.Width = w
			p.rep.Height = w
			p.rep.PlanetCount = planets
			p.sawSize = true
		}
		return nil
	}

	// Inside a battle block, "<Race> Groups" lines open a per-race
	// roster sub-table. The line matches singleTokenPrefix(_, " Groups")
	// and would otherwise be treated as a top-level section transition
	// by classifySection. Trap it here so the battle state stays open.
	if (p.sec == sectionBattle || p.sec == sectionBattleProtocol) && p.pendingBattle != nil {
		if race, ok := singleTokenPrefix(trimmed, " Groups"); ok {
			// New roster — the protocol block, if it had started,
			// cannot reopen; but the engine never emits "<Race> Groups"
			// after "Battle Protocol" inside the same battle.
			p.sec = sectionBattle
			p.pendingBattleRace = race
			p.skipHeader = true
			return nil
		}
	}

	if newSec, owner, isHeader := classifySection(trimmed); isHeader {
		// Flush the previous battle on any header transition that
		// moves us out of the battle block. Sub-transitions
		// (sectionBattle → sectionBattleProtocol or vice-versa)
		// inside the same battle do not flush.
		switch {
		case newSec == sectionBattle:
			p.flushPendingBattle()
			planet, planetName, ok := parseBattleHeader(trimmed)
			if ok {
				p.pendingBattle = &pendingBattle{
					id:         syntheticBattleID(p.battleIndex),
					planet:     planet,
					planetName: planetName,
					raceIndex:  make(map[string]int),
					shipIndex:  make(map[shipKey]int),
					races:      make(map[int]uuid.UUID),
					ships:      make(map[int]report.BattleReportGroup),
				}
				p.battleIndex++
			}
			p.pendingBattleRace = ""
		case newSec == sectionBattleProtocol:
			// Stay in the same battle; the protocol header itself
			// has no column header to skip — `Battle Protocol` is
			// followed by the shot lines directly. Reset
			// pendingBattleRace because the roster phase ended.
			p.pendingBattleRace = ""
		default:
			// Any other section transition closes the battle.
			p.flushPendingBattle()
		}
		p.sec = newSec
		p.otherOwner = owner
		// `Battle Protocol` has no column header to skip; ditto for
		// the per-race `<Race> Groups` sub-header trapped above (we
		// handle that branch separately). For sectionBattle the
		// header line is "Battle at (#N) Name" with no following
		// column row, so skipHeader stays false there as well.
		p.skipHeader = newSec != sectionNone && newSec != sectionBattle && newSec != sectionBattleProtocol
		return nil
	}

	if trimmed == "" {
		return nil
	}
	if p.sec == sectionNone {
		return nil
	}
	if p.skipHeader {
		p.skipHeader = false
		return nil
	}

	fields := strings.Fields(trimmed)
	switch p.sec {
	case sectionStatusOfPlayers:
		p.parsePlayer(fields)
	case sectionYourVote:
		p.parseYourVote(fields)
	case sectionYourPlanets:
		p.parseLocalPlanet(fields)
	case sectionOtherPlanets:
		p.parseOtherPlanet(fields)
	case sectionUninhabitedPlanets:
		p.parseUninhabitedPlanet(fields)
	case sectionUnidentifiedPlanets:
		p.parseUnidentifiedPlanet(fields)
	case sectionYourShipTypes:
		p.parseShipClass(fields)
	case sectionYourGroups:
		p.parseYourGroup(fields)
	case sectionYourFleets:
		p.parseYourFleet(fields)
	case sectionIncomingGroups:
		p.parseIncomingGroup(fields)
	case sectionYourSciences:
		p.parseYourScience(fields)
	case sectionOtherSciences:
		p.parseOtherScience(fields)
	case sectionOtherShipTypes:
		p.parseOtherShipClass(fields)
	case sectionBombings:
		p.parseBombing(fields)
	case sectionShipsInProduction:
		p.parseShipProductionRow(fields)
	case sectionBattle:
		p.parseBattleRosterRow(fields)
	case sectionBattleProtocol:
		p.parseBattleProtocolLine(fields)
	}
	return nil
}

func (p *parser) finish() ([]report.BattleReport, error) {
	if !p.sawHeader {
		return nil, errors.New("legacyreport: missing report header line")
	}
	p.flushPendingBattle()
	p.resolvePending()
	return p.battles, nil
}

// parseHeader extracts (race, turn) from
// "<Race> Report for Galaxy ... Turn N ...".
func parseHeader(line string) (string, uint, bool) {
	race, rest, ok := strings.Cut(line, " Report for Galaxy ")
	if !ok {
		return "", 0, false
	}
	_, afterTurn, ok := strings.Cut(rest, " Turn ")
	if !ok {
		return "", 0, false
	}
	after := strings.Fields(afterTurn)
	if len(after) == 0 {
		return "", 0, false
	}
	n, err := strconv.ParseUint(after[0], 10, 32)
	if err != nil {
		return "", 0, false
	}
	return race, uint(n), true
}

// parseSize extracts (size, planets) from
// "Size: W  Planets: P  Players: N". Players is intentionally
// dropped: report.Report has no field for it.
func parseSize(line string) (uint32, uint32, bool) {
	fields := strings.Fields(line)
	var size, planets uint32
	var ok bool
	for i := 0; i+1 < len(fields); i++ {
		key := strings.TrimRight(fields[i], ":")
		switch key {
		case "Size":
			if n, err := strconv.ParseUint(fields[i+1], 10, 32); err == nil {
				size = uint32(n)
				ok = true
			}
		case "Planets":
			if n, err := strconv.ParseUint(fields[i+1], 10, 32); err == nil {
				planets = uint32(n)
			}
		}
	}
	return size, planets, ok
}

// classifySection returns the section the trimmed line opens. When
// isHeader is true the caller transitions state — sectionNone there
// means "switch into skip mode" (an uninteresting section like
// Bombings, Battle at ..., or a foreign Ship Types block).
func classifySection(line string) (sec section, owner string, isHeader bool) {
	switch line {
	case "":
		return sectionNone, "", false
	case "Your Planets":
		return sectionYourPlanets, "", true
	case "Your Ship Types":
		return sectionYourShipTypes, "", true
	case "Your Groups":
		return sectionYourGroups, "", true
	case "Your Fleets":
		return sectionYourFleets, "", true
	case "Incoming Groups":
		return sectionIncomingGroups, "", true
	case "Uninhabited Planets":
		return sectionUninhabitedPlanets, "", true
	case "Unidentified Planets":
		return sectionUnidentifiedPlanets, "", true
	case "Your vote:":
		return sectionYourVote, "", true
	case "Your Sciences":
		return sectionYourSciences, "", true
	case "Bombings":
		return sectionBombings, "", true
	case "Ships In Production":
		return sectionShipsInProduction, "", true
	case "Approaching Groups",
		"Broadcast Message":
		return sectionNone, "", true
	case "Battle Protocol":
		return sectionBattleProtocol, "", true
	}
	if strings.HasPrefix(line, "Status of Players") {
		return sectionStatusOfPlayers, "", true
	}
	if strings.HasPrefix(line, "Battle at ") {
		return sectionBattle, "", true
	}
	if strings.HasPrefix(line, "=== ATTENTION") {
		return sectionNone, "", true
	}
	if owner, ok := singleTokenPrefix(line, " Planets"); ok {
		return sectionOtherPlanets, owner, true
	}
	if owner, ok := singleTokenPrefix(line, " Ship Types"); ok {
		return sectionOtherShipTypes, owner, true
	}
	if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
		return sectionOtherSciences, owner, true
	}
	if _, ok := singleTokenPrefix(line, " Groups"); ok {
		return sectionNone, "", true
	}
	return sectionNone, "", false
}

func singleTokenPrefix(line, suffix string) (string, bool) {
	if !strings.HasSuffix(line, suffix) {
		return "", false
	}
	prefix := strings.TrimSuffix(line, suffix)
	if prefix == "" || strings.ContainsAny(prefix, " \t") {
		return "", false
	}
	return prefix, true
}

// parsePlayer expects 10 columns:
//
//	N D W S C P I # R V
func (p *parser) parsePlayer(fields []string) {
	if len(fields) < 10 {
		return
	}
	name := fields[0]
	drive, err := parseFloat(fields[1])
	if err != nil {
		return
	}
	weapons, _ := parseFloat(fields[2])
	shields, _ := parseFloat(fields[3])
	cargo, _ := parseFloat(fields[4])
	population, _ := parseFloat(fields[5])
	industry, _ := parseFloat(fields[6])
	plCount, err := strconv.ParseUint(fields[7], 10, 16)
	if err != nil {
		return
	}
	relation := fields[8]
	votes, _ := parseFloat(fields[9])

	extinct := strings.HasSuffix(name, "_RIP")
	if extinct {
		name = strings.TrimSuffix(name, "_RIP")
	}
	p.rep.Player = append(p.rep.Player, report.Player{
		Name:       name,
		Drive:      report.F(drive),
		Weapons:    report.F(weapons),
		Shields:    report.F(shields),
		Cargo:      report.F(cargo),
		Population: report.F(population),
		Industry:   report.F(industry),
		Planets:    uint16(plCount),
		Relation:   relation,
		Votes:      report.F(votes),
		Extinct:    extinct,
	})
}

func (p *parser) parseYourVote(fields []string) {
	if len(fields) < 2 {
		return
	}
	p.rep.VoteFor = fields[0]
	if v, err := parseFloat(fields[1]); err == nil {
		p.rep.Votes = report.F(v)
	}
	p.sec = sectionNone
}

// parseLocalPlanet expects 13 columns:
//
//	# X Y N S P I R Production $ M C L
func (p *parser) parseLocalPlanet(fields []string) {
	lp, ok := decodeLocalPlanetRow(fields)
	if !ok {
		return
	}
	p.rep.LocalPlanet = append(p.rep.LocalPlanet, lp)
}

func (p *parser) parseOtherPlanet(fields []string) {
	lp, ok := decodeLocalPlanetRow(fields)
	if !ok {
		return
	}
	p.rep.OtherPlanet = append(p.rep.OtherPlanet, report.OtherPlanet{
		Owner:       p.otherOwner,
		LocalPlanet: lp,
	})
}

func decodeLocalPlanetRow(fields []string) (report.LocalPlanet, bool) {
	var lp report.LocalPlanet
	if len(fields) < 13 {
		return lp, false
	}
	number, err := strconv.ParseUint(fields[0], 10, 32)
	if err != nil {
		return lp, false
	}
	x, _ := parseFloat(fields[1])
	y, _ := parseFloat(fields[2])
	size, _ := parseFloat(fields[4])
	population, _ := parseFloat(fields[5])
	industry, _ := parseFloat(fields[6])
	resources, _ := parseFloat(fields[7])
	capital, _ := parseFloat(fields[9])
	material, _ := parseFloat(fields[10])
	colonists, _ := parseFloat(fields[11])
	free, _ := parseFloat(fields[12])

	lp.Number = uint(number)
	lp.X = report.F(x)
	lp.Y = report.F(y)
	lp.Name = fields[3]
	lp.Size = report.F(size)
	lp.Resources = report.F(resources)
	lp.Capital = report.F(capital)
	lp.Material = report.F(material)
	lp.Industry = report.F(industry)
	lp.Population = report.F(population)
	lp.Colonists = report.F(colonists)
	lp.Production = fields[8]
	lp.FreeIndustry = report.F(free)
	return lp, true
}

// parseUninhabitedPlanet expects 8 columns:
//
//	# X Y N S R $ M
func (p *parser) parseUninhabitedPlanet(fields []string) {
	if len(fields) < 8 {
		return
	}
	number, err := strconv.ParseUint(fields[0], 10, 32)
	if err != nil {
		return
	}
	x, _ := parseFloat(fields[1])
	y, _ := parseFloat(fields[2])
	size, _ := parseFloat(fields[4])
	resources, _ := parseFloat(fields[5])
	capital, _ := parseFloat(fields[6])
	material, _ := parseFloat(fields[7])

	var u report.UninhabitedPlanet
	u.Number = uint(number)
	u.X = report.F(x)
	u.Y = report.F(y)
	u.Name = fields[3]
	u.Size = report.F(size)
	u.Resources = report.F(resources)
	u.Capital = report.F(capital)
	u.Material = report.F(material)
	p.rep.UninhabitedPlanet = append(p.rep.UninhabitedPlanet, u)
}

// parseUnidentifiedPlanet expects 3 columns:
//
//	# X Y
func (p *parser) parseUnidentifiedPlanet(fields []string) {
	if len(fields) < 3 {
		return
	}
	number, err := strconv.ParseUint(fields[0], 10, 32)
	if err != nil {
		return
	}
	x, _ := parseFloat(fields[1])
	y, _ := parseFloat(fields[2])
	p.rep.UnidentifiedPlanet = append(p.rep.UnidentifiedPlanet, report.UnidentifiedPlanet{
		Number: uint(number),
		X:      report.F(x),
		Y:      report.F(y),
	})
}

// parseShipClass expects 7 columns:
//
//	N D A W S C M
func (p *parser) parseShipClass(fields []string) {
	sc, ok := decodeShipClassRow(fields)
	if !ok {
		return
	}
	p.rep.LocalShipClass = append(p.rep.LocalShipClass, sc)
}

// parseOtherShipClass parses one row of a "<Race> Ship Types" block.
// Same 7-column layout as [parser.parseShipClass]; the owning race is
// captured into [parser.otherOwner] when the section header is
// classified by [classifySection].
func (p *parser) parseOtherShipClass(fields []string) {
	sc, ok := decodeShipClassRow(fields)
	if !ok {
		return
	}
	p.rep.OtherShipClass = append(p.rep.OtherShipClass, report.OthersShipClass{
		Race:      p.otherOwner,
		ShipClass: sc,
	})
}

func decodeShipClassRow(fields []string) (report.ShipClass, bool) {
	var sc report.ShipClass
	if len(fields) < 7 {
		return sc, false
	}
	drive, err := parseFloat(fields[1])
	if err != nil {
		return sc, false
	}
	armament, err := strconv.ParseUint(fields[2], 10, 32)
	if err != nil {
		return sc, false
	}
	weapons, _ := parseFloat(fields[3])
	shields, _ := parseFloat(fields[4])
	cargo, _ := parseFloat(fields[5])
	mass, _ := parseFloat(fields[6])
	sc.Name = fields[0]
	sc.Drive = report.F(drive)
	sc.Armament = uint(armament)
	sc.Weapons = report.F(weapons)
	sc.Shields = report.F(shields)
	sc.Cargo = report.F(cargo)
	sc.Mass = report.F(mass)
	return sc, true
}

// parseYourScience parses one row of the "Your Sciences" block.
// Columns:
//
//	N D W S C
//
// where D/W/S/C are the four tech proportions as fractions summing
// to 1.0 (`pkg/calc/validator.go.ValidateScienceValues`).
func (p *parser) parseYourScience(fields []string) {
	sc, ok := decodeScienceRow(fields)
	if !ok {
		return
	}
	p.rep.LocalScience = append(p.rep.LocalScience, sc)
}

// parseOtherScience parses one row of a "<Race> Sciences" block.
// Same 5-column layout as [parser.parseYourScience]; the owning race
// is captured into [parser.otherOwner] by [classifySection].
func (p *parser) parseOtherScience(fields []string) {
	sc, ok := decodeScienceRow(fields)
	if !ok {
		return
	}
	p.rep.OtherScience = append(p.rep.OtherScience, report.OtherScience{
		Race:    p.otherOwner,
		Science: sc,
	})
}

func decodeScienceRow(fields []string) (report.Science, bool) {
	var sc report.Science
	if len(fields) < 5 {
		return sc, false
	}
	drive, err := parseFloat(fields[1])
	if err != nil {
		return sc, false
	}
	weapons, _ := parseFloat(fields[2])
	shields, _ := parseFloat(fields[3])
	cargo, _ := parseFloat(fields[4])
	sc.Name = fields[0]
	sc.Drive = report.F(drive)
	sc.Weapons = report.F(weapons)
	sc.Shields = report.F(shields)
	sc.Cargo = report.F(cargo)
	return sc, true
}

// parseBombing parses one row of the "Bombings" block. Columns
// (12 tokens, last is the wiped/damaged status word):
//
//	W O # N P I P $ M C A status
//
// where the first P is the post-bombing population and the second
// P is the production string left on the planet. Status is parsed
// positionally — the header has a duplicate P, so a header-name
// lookup is not safe.
func (p *parser) parseBombing(fields []string) {
	if len(fields) < 12 {
		return
	}
	number, err := strconv.ParseUint(fields[2], 10, 32)
	if err != nil {
		return
	}
	population, _ := parseFloat(fields[4])
	industry, _ := parseFloat(fields[5])
	capital, _ := parseFloat(fields[7])
	material, _ := parseFloat(fields[8])
	colonists, _ := parseFloat(fields[9])
	attack, _ := parseFloat(fields[10])
	wiped := fields[11] == "Wiped"
	p.rep.Bombing = append(p.rep.Bombing, &report.Bombing{
		Attacker:    fields[0],
		Owner:       fields[1],
		Number:      uint(number),
		Planet:      fields[3],
		Population:  report.F(population),
		Industry:    report.F(industry),
		Production:  fields[6],
		Capital:     report.F(capital),
		Material:    report.F(material),
		Colonists:   report.F(colonists),
		AttackPower: report.F(attack),
		Wiped:       wiped,
	})
}

// parseBattleHeader extracts (planet, planetName) from a
// "Battle at (#N) <PlanetName>" line. The planet number is the
// integer between "(#" and ")"; the planet name is the rest of the
// line after the closing parenthesis (trimmed).
func parseBattleHeader(line string) (uint, string, bool) {
	const prefix = "Battle at "
	if !strings.HasPrefix(line, prefix) {
		return 0, "", false
	}
	rest := strings.TrimSpace(line[len(prefix):])
	if !strings.HasPrefix(rest, "(#") {
		return 0, "", false
	}
	closing := strings.IndexByte(rest, ')')
	if closing < 0 {
		return 0, "", false
	}
	num, err := strconv.ParseUint(rest[2:closing], 10, 32)
	if err != nil {
		return 0, "", false
	}
	name := strings.TrimSpace(rest[closing+1:])
	return uint(num), name, true
}

// parseBattleRosterRow consumes one ship-group line from a battle
// roster sub-table. Columns (10 tokens; the last is the per-group
// state word):
//
//	#  T          D     W     S    C  T  Q   L  state
//	1  Pistolet  1.6  1.00  1.00  0  -  0   1  In_Battle
//
// where column "L" carries the number of ships remaining after the
// battle (confirmed against KNNTS fixtures). Rows are appended to
// `pendingBattle.ships` under the race name currently held in
// `pendingBattleRace`.
func (p *parser) parseBattleRosterRow(fields []string) {
	if p.pendingBattle == nil || p.pendingBattleRace == "" {
		return
	}
	if len(fields) < 10 {
		return
	}
	number, err := strconv.ParseUint(fields[0], 10, 32)
	if err != nil {
		return
	}
	className := fields[1]
	drive, _ := parseFloat(fields[2])
	weapons, _ := parseFloat(fields[3])
	shields, _ := parseFloat(fields[4])
	cargo, _ := parseFloat(fields[5])
	loadQuantity, _ := parseFloat(fields[7])
	numLeft, err := strconv.ParseUint(fields[8], 10, 32)
	if err != nil {
		return
	}
	state := fields[9]
	tech := make(map[string]report.Float, 4)
	if drive != 0 {
		tech["DRIVE"] = report.F(drive)
	}
	if weapons != 0 {
		tech["WEAPONS"] = report.F(weapons)
	}
	if shields != 0 {
		tech["SHIELDS"] = report.F(shields)
	}
	if cargo != 0 {
		tech["CARGO"] = report.F(cargo)
	}

	p.assignRaceIndex(p.pendingBattleRace)
	key := shipKey{race: p.pendingBattleRace, class: className}
	idx := p.assignShipIndex(key)

	// Legacy battle rosters may list the same `(race, className)`
	// across multiple rows — different tech variants, ships pulled
	// from several stacks / planets, etc. We collapse those rows
	// into one BattleReportGroup keyed by `(race, className)` (the
	// viewer aggregates per class anyway) by SUMMING Number and
	// NumberLeft instead of overwriting; otherwise only the last
	// row's counts survive and the battle protocol's destroy count
	// would dwarf the recorded initial count (the original
	// motivation for the now-removed "phantom destroy" workaround).
	if existing, found := p.pendingBattle.ships[idx]; found {
		existing.Number += uint(number)
		existing.NumberLeft += uint(numLeft)
		// LoadQuantity is per-ship cargo — average is a fair fallback
		// when several stacks of the same class merge into one bucket.
		existing.LoadQuantity = report.F(
			(existing.LoadQuantity.F() + loadQuantity) / 2,
		)
		// Tech / LoadType / InBattle keep their first-seen values:
		// the viewer treats them as bucket-wide attributes and the
		// first row is normally the most representative tech variant.
		p.pendingBattle.ships[idx] = existing
		return
	}

	p.pendingBattle.ships[idx] = report.BattleReportGroup{
		Race:         p.pendingBattleRace,
		ClassName:    className,
		Tech:         tech,
		Number:       uint(number),
		NumberLeft:   uint(numLeft),
		LoadType:     dashOrEmpty(fields[6]),
		LoadQuantity: report.F(loadQuantity),
		InBattle:     state == "In_Battle",
	}
}

// parseBattleProtocolLine consumes one shot line of the
// "Battle Protocol" sub-block. Required shape (8 tokens):
//
//	<atkRace> <atkClass> fires on <defRace> <defClass> : <Destroyed|Shields>
//
// Anything else (including the empty line separating the protocol
// from the preceding rosters) is silently skipped — the engine never
// emits other text inside this block.
func (p *parser) parseBattleProtocolLine(fields []string) {
	if p.pendingBattle == nil {
		return
	}
	if len(fields) != 8 {
		return
	}
	if fields[2] != "fires" || fields[3] != "on" || fields[6] != ":" {
		return
	}
	atkRace, atkClass := fields[0], fields[1]
	defRace, defClass := fields[4], fields[5]
	destroyed := fields[7] == "Destroyed"

	aRace := p.assignRaceIndex(atkRace)
	dRace := p.assignRaceIndex(defRace)
	sa := p.assignShipIndex(shipKey{race: atkRace, class: atkClass})
	sd := p.assignShipIndex(shipKey{race: defRace, class: defClass})

	// Synthesise a minimal BattleReportGroup entry when the shot
	// references a (race, class) pair that the roster did not
	// declare. This happens when the legacy emitter trims a roster
	// row but the engine logged a shot for that group.
	if _, ok := p.pendingBattle.ships[sa]; !ok {
		p.pendingBattle.ships[sa] = report.BattleReportGroup{
			Race: atkRace, ClassName: atkClass, InBattle: true,
			Tech: map[string]report.Float{},
		}
	}
	if _, ok := p.pendingBattle.ships[sd]; !ok {
		p.pendingBattle.ships[sd] = report.BattleReportGroup{
			Race: defRace, ClassName: defClass, InBattle: true,
			Tech: map[string]report.Float{},
		}
	}

	p.pendingBattle.protocol = append(p.pendingBattle.protocol, report.BattleActionReport{
		Attacker:          aRace,
		AttackerShipClass: sa,
		Defender:          dRace,
		DefenderShipClass: sd,
		Destroyed:         destroyed,
	})
}

// assignRaceIndex returns the in-battle race index for raceName,
// creating a new entry on first sight. Race indices are 0-based and
// monotonically increasing in first-seen order. The synthetic race
// UUID is derived from the race name through
// `syntheticBattleRaceNamespace`.
func (p *parser) assignRaceIndex(raceName string) int {
	if idx, ok := p.pendingBattle.raceIndex[raceName]; ok {
		return idx
	}
	idx := len(p.pendingBattle.raceIndex)
	p.pendingBattle.raceIndex[raceName] = idx
	p.pendingBattle.races[idx] = syntheticBattleRaceID(raceName)
	return idx
}

// assignShipIndex returns the in-battle ship-group index for
// (race, class), creating a new entry on first sight. Indices are
// 0-based and monotonically increasing in first-seen order across
// all races.
func (p *parser) assignShipIndex(key shipKey) int {
	if idx, ok := p.pendingBattle.shipIndex[key]; ok {
		return idx
	}
	idx := len(p.pendingBattle.shipIndex)
	p.pendingBattle.shipIndex[key] = idx
	return idx
}

// flushPendingBattle finalises the in-flight battle: appends the
// BattleReport to `p.battles` and a matching BattleSummary
// (id/planet/shots) to `p.rep.Battle`. No-op when no battle is
// pending. Idempotent — clears `pendingBattle` on completion.
func (p *parser) flushPendingBattle() {
	if p.pendingBattle == nil {
		return
	}
	pb := p.pendingBattle
	p.pendingBattle = nil
	p.pendingBattleRace = ""

	br := report.BattleReport{
		ID:         pb.id,
		Planet:     pb.planet,
		PlanetName: pb.planetName,
		Races:      pb.races,
		Ships:      pb.ships,
		Protocol:   pb.protocol,
	}
	p.battles = append(p.battles, br)
	p.rep.Battle = append(p.rep.Battle, report.BattleSummary{
		ID:     pb.id,
		Planet: pb.planet,
		Shots:  uint(len(pb.protocol)),
	})
}

// parseShipProductionRow buffers a "Ships In Production" row for
// post-processing in [parser.finish]. Columns:
//
//	# N S C P L
//
// where # is the planet number, N is the planet name (decorative —
// resolution uses #), S is the building ship class, C is the cost
// (== shipMass * 10), P is the build progress as a fraction in
// [0, 1], and L is the producing planet's free industry. The wire
// shape's `prod_used` field is not carried by the legacy text; it is
// derived during [parser.resolvePending] from the planet's material
// and resources via [calc.ShipBuildCost].
func (p *parser) parseShipProductionRow(fields []string) {
	if len(fields) < 6 {
		return
	}
	number, err := strconv.ParseUint(fields[0], 10, 32)
	if err != nil {
		return
	}
	cost, _ := parseFloat(fields[3])
	percent, _ := parseFloat(fields[4])
	free, _ := parseFloat(fields[5])
	p.pendingShipProducts = append(p.pendingShipProducts, pendingShipProduction{
		planetNumber: uint(number),
		class:        fields[2],
		cost:         cost,
		percent:      percent,
		free:         free,
	})
}

// parseYourGroup buffers a "Your Groups" row for post-processing in
// [parser.finish]. Columns (16 fields, last is state):
//
//	G # T D W S C T Q D F R P M L state
//
// where the second D is the destination planet name, F is the origin
// planet name (or "-" for on-planet groups), R is the remaining
// distance, and L is the fleet membership (or "-").
func (p *parser) parseYourGroup(fields []string) {
	if len(fields) < 16 {
		return
	}
	g, err := strconv.ParseUint(fields[0], 10, 32)
	if err != nil {
		return
	}
	number, err := strconv.ParseUint(fields[1], 10, 32)
	if err != nil {
		return
	}
	drive, _ := parseFloat(fields[3])
	weapons, _ := parseFloat(fields[4])
	shields, _ := parseFloat(fields[5])
	cargoTech, _ := parseFloat(fields[6])
	load, _ := parseFloat(fields[8])
	mass, _ := parseFloat(fields[13])

	p.pendingGroups = append(p.pendingGroups, pendingGroup{
		g:               uint(g),
		number:          uint(number),
		class:           fields[2],
		drive:           drive,
		weapons:         weapons,
		shields:         shields,
		cargoTech:       cargoTech,
		cargoType:       fields[7],
		load:            load,
		destinationName: fields[9],
		originName:      dashOrEmpty(fields[10]),
		rangeStr:        dashOrEmpty(fields[11]),
		mass:            mass,
		fleet:           dashOrEmpty(fields[14]),
		state:           fields[15],
	})
}

// parseYourFleet buffers a "Your Fleets" row. Columns vary by engine
// — Killer/Tancordia ship 8 fields including a trailing state token,
// gplus emits 7 (no state). Layout:
//
//	# N G D F R P [state]
//
// where D is the destination planet name, F is the origin planet
// name (or "-"), and R is the remaining distance.
func (p *parser) parseYourFleet(fields []string) {
	if len(fields) < 7 {
		return
	}
	groups, err := strconv.ParseUint(fields[2], 10, 32)
	if err != nil {
		return
	}
	state := ""
	if len(fields) >= 8 {
		state = fields[7]
	}
	p.pendingFleets = append(p.pendingFleets, pendingFleet{
		name:            fields[1],
		groups:          uint(groups),
		destinationName: fields[3],
		originName:      dashOrEmpty(fields[4]),
		rangeStr:        dashOrEmpty(fields[5]),
		state:           state,
	})
}

// parseIncomingGroup buffers an "Incoming Groups" row. Columns:
//
//	O D R S M
func (p *parser) parseIncomingGroup(fields []string) {
	if len(fields) < 5 {
		return
	}
	distance, err := parseFloat(fields[2])
	if err != nil {
		return
	}
	speed, _ := parseFloat(fields[3])
	mass, _ := parseFloat(fields[4])
	p.pendingIncomings = append(p.pendingIncomings, pendingIncoming{
		originName:      fields[0],
		destinationName: fields[1],
		distance:        distance,
		speed:           speed,
		mass:            mass,
	})
}

// resolvePending walks the buffered group/fleet/incoming rows and
// emits the typed entries on the report. Names that resolve neither
// against the parsed planet tables nor the "#NN" id syntax are
// skipped silently — they typically point at planets not visible to
// the local player. Stable LocalGroup IDs are derived from the
// per-report group index so repeated conversions of the same file
// produce byte-identical JSON.
func (p *parser) resolvePending() {
	for _, pg := range p.pendingGroups {
		dest, ok := p.lookupPlanetNumber(pg.destinationName)
		if !ok {
			continue
		}
		var origin *uint
		if pg.originName != "" {
			if n, ok := p.lookupPlanetNumber(pg.originName); ok {
				v := n
				origin = &v
			}
		}
		var rng *report.Float
		if pg.rangeStr != "" {
			if r, err := parseFloat(pg.rangeStr); err == nil {
				v := report.F(r)
				rng = &v
			}
		}
		var fleet *string
		if pg.fleet != "" {
			f := pg.fleet
			fleet = &f
		}
		tech := map[string]report.Float{
			"drive":   report.F(pg.drive),
			"weapons": report.F(pg.weapons),
			"shields": report.F(pg.shields),
			"cargo":   report.F(pg.cargoTech),
		}
		p.rep.LocalGroup = append(p.rep.LocalGroup, report.LocalGroup{
			OtherGroup: report.OtherGroup{
				Number:      pg.number,
				Class:       pg.class,
				Tech:        tech,
				Cargo:       pg.cargoType,
				Load:        report.F(pg.load),
				Destination: dest,
				Origin:      origin,
				Range:       rng,
				Mass:        report.F(pg.mass),
			},
			ID:    syntheticGroupID(pg.g),
			State: pg.state,
			Fleet: fleet,
		})
	}

	for _, pf := range p.pendingFleets {
		dest, ok := p.lookupPlanetNumber(pf.destinationName)
		if !ok {
			continue
		}
		var origin *uint
		if pf.originName != "" {
			if n, ok := p.lookupPlanetNumber(pf.originName); ok {
				v := n
				origin = &v
			}
		}
		var rng *report.Float
		if pf.rangeStr != "" {
			if r, err := parseFloat(pf.rangeStr); err == nil {
				v := report.F(r)
				rng = &v
			}
		}
		p.rep.LocalFleet = append(p.rep.LocalFleet, report.LocalFleet{
			Name:        pf.name,
			Groups:      pf.groups,
			Destination: dest,
			Origin:      origin,
			Range:       rng,
			State:       pf.state,
		})
	}

	for _, pi := range p.pendingIncomings {
		origin, ok := p.lookupPlanetNumber(pi.originName)
		if !ok {
			continue
		}
		dest, ok := p.lookupPlanetNumber(pi.destinationName)
		if !ok {
			continue
		}
		p.rep.IncomingGroup = append(p.rep.IncomingGroup, report.IncomingGroup{
			Origin:      origin,
			Destination: dest,
			Distance:    report.F(pi.distance),
			Speed:       report.F(pi.speed),
			Mass:        report.F(pi.mass),
		})
	}

	for _, ps := range p.pendingShipProducts {
		lp, ok := p.findLocalPlanet(ps.planetNumber)
		if !ok {
			continue
		}
		shipMass := ps.cost / 10
		totalCost := calc.ShipBuildCost(
			shipMass,
			float64(lp.Material),
			float64(lp.Resources),
		)
		// `ProdUsed` is the cumulative production-equivalent of the
		// build progress so far. The real engine's `Progress` field
		// accumulates across turns and the per-turn `ProdUsed` is a
		// transient residual — neither of those is recoverable from a
		// single legacy report. The derivation here keeps the value in
		// the same units (production points) and in the right ballpark
		// for synthetic-mode UI rendering; live engine reports do not
		// flow through this parser, so the approximation never reaches
		// production traffic. README.md skips section explains.
		prodUsed := totalCost * ps.percent
		p.rep.ShipProduction = append(p.rep.ShipProduction, report.ShipProduction{
			Planet:   ps.planetNumber,
			Class:    ps.class,
			Cost:     report.F(ps.cost),
			ProdUsed: report.F(prodUsed),
			Percent:  report.F(ps.percent),
			Free:     report.F(ps.free),
		})
	}
}

// findLocalPlanet returns the parsed "Your Planets" entry with the
// given number, used by the ships-in-production resolver to read
// material / resources for the [calc.ShipBuildCost] derivation.
// Ships-in-production only lists own ships, so the lookup against
// `LocalPlanet` is correct.
func (p *parser) findLocalPlanet(number uint) (report.LocalPlanet, bool) {
	for _, lp := range p.rep.LocalPlanet {
		if lp.Number == number {
			return lp, true
		}
	}
	return report.LocalPlanet{}, false
}

// lookupPlanetNumber resolves a legacy planet reference — either a
// "#NN" by-id form or a planet name from one of the parsed planet
// tables. Returns false when the planet is not visible to the local
// player (the caller drops the row).
func (p *parser) lookupPlanetNumber(s string) (uint, bool) {
	if strings.HasPrefix(s, "#") {
		n, err := strconv.ParseUint(s[1:], 10, 32)
		if err != nil {
			return 0, false
		}
		return uint(n), true
	}
	for _, lp := range p.rep.LocalPlanet {
		if lp.Name == s {
			return lp.Number, true
		}
	}
	for _, op := range p.rep.OtherPlanet {
		if op.Name == s {
			return op.Number, true
		}
	}
	for _, up := range p.rep.UninhabitedPlanet {
		if up.Name == s {
			return up.Number, true
		}
	}
	return 0, false
}

// syntheticGroupNamespace seeds [uuid.NewSHA1] for the per-report
// group-index → UUID derivation. The constant value is arbitrary;
// any UUID would work as long as it stays stable across releases so
// re-running the converter on the same input file yields the same
// LocalGroup IDs.
var syntheticGroupNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000001")

func syntheticGroupID(g uint) uuid.UUID {
	return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g))
}

// syntheticBattleNamespace seeds [uuid.NewSHA1] for the per-report
// battle-index → UUID derivation used by `Report.Battle[i].ID` and
// `BattleReport.ID`. Distinct from `syntheticGroupNamespace` so a
// per-report battle index can never collide with a ship-group id.
// Mirrors the rationale in `syntheticGroupNamespace`: arbitrary
// value, stable across releases.
var syntheticBattleNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000002")

// syntheticBattleRaceNamespace seeds [uuid.NewSHA1] for the
// per-battle race name → race UUID derivation that fills
// `BattleReport.Races`. Engine-side reports carry the real race
// UUID; the legacy text only carries the race name, so we derive a
// stable identifier from the name. The constant is independent of
// `syntheticBattleNamespace` so race UUIDs can never collide with
// battle UUIDs.
var syntheticBattleRaceNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000003")

func syntheticBattleID(idx uint) uuid.UUID {
	return uuid.NewSHA1(syntheticBattleNamespace, fmt.Appendf(nil, "legacy-battle-%d", idx))
}

func syntheticBattleRaceID(name string) uuid.UUID {
	return uuid.NewSHA1(syntheticBattleRaceNamespace, fmt.Appendf(nil, "legacy-battle-race-%s", name))
}

func dashOrEmpty(s string) string {
	if s == "-" {
		return ""
	}
	return s
}

func parseFloat(s string) (float64, error) {
	return strconv.ParseFloat(s, 64)
}
