package legacyreport

import (
	"os"
	"path/filepath"
	"strings"
	"testing"

	"galaxy/model/report"
)

// TestParseHeaderAndSize covers the standalone single-line preamble.
func TestParseHeaderAndSize(t *testing.T) {
	in := strings.Join([]string{
		"       KnightErrants Report for Galaxy PLUS dg283 Turn 39 Thu Jul 06 09:01:16 2000",
		"",
		"                    Size: 800  Planets: 700  Players: 91",
		"",
	}, "\n")

	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := rep.Race, "KnightErrants"; got != want {
		t.Errorf("Race = %q, want %q", got, want)
	}
	if got, want := rep.Turn, uint(39); got != want {
		t.Errorf("Turn = %d, want %d", got, want)
	}
	if got, want := rep.Width, uint32(800); got != want {
		t.Errorf("Width = %d, want %d", got, want)
	}
	if got, want := rep.Height, uint32(800); got != want {
		t.Errorf("Height = %d, want %d", got, want)
	}
	if got, want := rep.PlanetCount, uint32(700); got != want {
		t.Errorf("PlanetCount = %d, want %d", got, want)
	}
}

// TestParseStatusOfPlayers exercises the alive / extinct distinction
// that drives the races view.
func TestParseStatusOfPlayers(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Status of Players (total 10.00 votes)",
		"",
		"N             D     W     S     C       P       I  #  R     V",
		"Alpha      4.51  2.24  1.80  1.00  100.00   80.00  3  War   3.00",
		"Bravo      9.03  5.62  2.16  1.53  200.00  150.00  5  Peace 5.00",
		"Gone_RIP   1.00  1.00  1.00  1.00    0.00    0.00  0  War   0.00",
		"",
	}, "\n")

	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.Player), 3; got != want {
		t.Fatalf("len(Player) = %d, want %d", got, want)
	}
	alpha := rep.Player[0]
	if alpha.Name != "Alpha" || alpha.Extinct {
		t.Errorf("Alpha = %+v, want Name=Alpha extinct=false", alpha)
	}
	if alpha.Planets != 3 || alpha.Relation != "War" {
		t.Errorf("Alpha planets/relation = %d/%q, want 3/War", alpha.Planets, alpha.Relation)
	}
	if got, want := float64(alpha.Drive), 4.51; got != want {
		t.Errorf("Alpha.Drive = %v, want %v", got, want)
	}
	gone := rep.Player[2]
	if gone.Name != "Gone" || !gone.Extinct {
		t.Errorf("Gone = %+v, want Name=Gone extinct=true (suffix _RIP stripped)", gone)
	}
}

func TestParseYourVote(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Your vote:",
		"",
		"R              V",
		"KnightErrants  16.02",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if rep.VoteFor != "KnightErrants" {
		t.Errorf("VoteFor = %q, want KnightErrants", rep.VoteFor)
	}
	if got, want := float64(rep.Votes), 16.02; got != want {
		t.Errorf("Votes = %v, want %v", got, want)
	}
}

func TestParseLocalAndOtherPlanets(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Your Planets",
		"",
		"  #       X       Y  N         S        P        I      R  P                $       M       C        L",
		" 17  171.05  700.24  Castle  1000.00  1000.00  1000.00  10.00  Drive_Research  0.00    0.68   88.78  1000.00",
		" 87  169.59  694.49  North    500.00   500.00   500.00  10.00  Capital         0.00    0.52   35.76   500.00",
		"",
		"Monstrai Planets",
		"",
		"  #       X       Y  N             S       P       I      R  P              $       M      C       L",
		" 12  303.84  579.23  Skarabei  500.00  500.00  500.00  10.00  Capital     0.00   70.99  20.03  341.78",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.LocalPlanet), 2; got != want {
		t.Fatalf("len(LocalPlanet) = %d, want %d", got, want)
	}
	castle := rep.LocalPlanet[0]
	if castle.Number != 17 || castle.Name != "Castle" {
		t.Errorf("Castle = (%d, %q), want (17, Castle)", castle.Number, castle.Name)
	}
	if castle.Production != "Drive_Research" {
		t.Errorf("Castle.Production = %q, want Drive_Research", castle.Production)
	}
	if got, want := float64(castle.Size), 1000.0; got != want {
		t.Errorf("Castle.Size = %v, want %v", got, want)
	}
	if got, want := len(rep.OtherPlanet), 1; got != want {
		t.Fatalf("len(OtherPlanet) = %d, want %d", got, want)
	}
	skarabei := rep.OtherPlanet[0]
	if skarabei.Owner != "Monstrai" {
		t.Errorf("Skarabei.Owner = %q, want Monstrai", skarabei.Owner)
	}
	if skarabei.Number != 12 || skarabei.Name != "Skarabei" {
		t.Errorf("Skarabei = (%d, %q), want (12, Skarabei)", skarabei.Number, skarabei.Name)
	}
}

func TestParseUninhabitedAndUnidentified(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Uninhabited Planets",
		"",
		"  #       X       Y  N            S      R     $        M",
		"  9  117.87  795.21  Dw2     500.00  10.00  0.00   500.00",
		"",
		"Unidentified Planets",
		"",
		"  #       X       Y",
		"  0  738.08  600.26",
		"  1  579.12  489.37",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.UninhabitedPlanet), 1; got != want {
		t.Fatalf("len(UninhabitedPlanet) = %d, want %d", got, want)
	}
	dw2 := rep.UninhabitedPlanet[0]
	if dw2.Number != 9 || dw2.Name != "Dw2" {
		t.Errorf("Dw2 = (%d, %q), want (9, Dw2)", dw2.Number, dw2.Name)
	}
	if got, want := len(rep.UnidentifiedPlanet), 2; got != want {
		t.Fatalf("len(UnidentifiedPlanet) = %d, want %d", got, want)
	}
	first := rep.UnidentifiedPlanet[0]
	if first.Number != 0 || float64(first.X) != 738.08 || float64(first.Y) != 600.26 {
		t.Errorf("Unidentified[0] = %+v, want (0, 738.08, 600.26)", first)
	}
}

func TestParseShipClasses(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Your Ship Types",
		"",
		"N             D    A      W      S      C       M",
		"Frontier  11.37    0   0.00   0.00   1.00   12.37",
		"Bow105    74.77  105   1.00  19.72   1.00  148.49",
		"",
		"Monstrai Ship Types",
		"",
		"N         D    A      W      S  C       M",
		"Dragon  16.70   1   1.10   1.00  1   19.80",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.LocalShipClass), 2; got != want {
		t.Fatalf("len(LocalShipClass) = %d, want %d", got, want)
	}
	bow := rep.LocalShipClass[1]
	if bow.Name != "Bow105" || bow.Armament != 105 {
		t.Errorf("Bow105 name/armament = %q/%d, want Bow105/105", bow.Name, bow.Armament)
	}
	if got, want := float64(bow.Drive), 74.77; got != want {
		t.Errorf("Bow105.Drive = %v, want %v", got, want)
	}
	if got, want := len(rep.OtherShipClass), 1; got != want {
		t.Fatalf("len(OtherShipClass) = %d, want %d", got, want)
	}
	dragon := rep.OtherShipClass[0]
	if dragon.Race != "Monstrai" || dragon.Name != "Dragon" || dragon.Armament != 1 {
		t.Errorf("Dragon = (%q, %q, %d), want (Monstrai, Dragon, 1)",
			dragon.Race, dragon.Name, dragon.Armament)
	}
	if got, want := float64(dragon.Mass), 19.80; got != want {
		t.Errorf("Dragon.Mass = %v, want %v", got, want)
	}
}

// TestParseSciences covers both "Your Sciences" and "<Race> Sciences"
// in one fixture. The five-column layout (N D W S C) is shared.
func TestParseSciences(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Your Sciences",
		"",
		"N              D    W    S    C",
		"_TerraForm     1    0    0    0",
		"BalancedMix  0.5  0.2  0.2  0.1",
		"",
		"Pahanchiks Sciences",
		"",
		"N              D    W    S    C",
		"_Drift         1    0    0    0",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.LocalScience), 2; got != want {
		t.Fatalf("len(LocalScience) = %d, want %d", got, want)
	}
	mix := rep.LocalScience[1]
	if mix.Name != "BalancedMix" {
		t.Errorf("LocalScience[1].Name = %q, want BalancedMix", mix.Name)
	}
	if float64(mix.Drive) != 0.5 || float64(mix.Cargo) != 0.1 {
		t.Errorf("BalancedMix (Drive, Cargo) = (%v, %v), want (0.5, 0.1)",
			float64(mix.Drive), float64(mix.Cargo))
	}
	if got, want := len(rep.OtherScience), 1; got != want {
		t.Fatalf("len(OtherScience) = %d, want %d", got, want)
	}
	drift := rep.OtherScience[0]
	if drift.Race != "Pahanchiks" || drift.Name != "_Drift" {
		t.Errorf("OtherScience[0] = (%q, %q), want (Pahanchiks, _Drift)",
			drift.Race, drift.Name)
	}
}

// TestParseBombings covers a wiped row + a damaged row + the duplicate
// `P` column header (population vs production string) — assertions
// hit every wire field so a positional-index slip is caught.
func TestParseBombings(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Bombings",
		"",
		"W              O          #  N            P      I  P        $       M     C        A",
		"Knights        Ricksha   20  DW-1207   1.56   0.00  Dron   0.00   0.00  0.00     7.62  Wiped",
		"Knights        Ricksha  332  PEHKE   500.00 258.64  Dron 184.39   0.00  6.42   331.93  Damaged",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.Bombing), 2; got != want {
		t.Fatalf("len(Bombing) = %d, want %d", got, want)
	}
	wiped := rep.Bombing[0]
	if !wiped.Wiped {
		t.Errorf("Bombing[0].Wiped = false, want true")
	}
	if wiped.Attacker != "Knights" || wiped.Owner != "Ricksha" || wiped.Number != 20 {
		t.Errorf("Bombing[0] head = (%q, %q, %d), want (Knights, Ricksha, 20)",
			wiped.Attacker, wiped.Owner, wiped.Number)
	}
	if wiped.Planet != "DW-1207" || wiped.Production != "Dron" {
		t.Errorf("Bombing[0] (planet, production) = (%q, %q), want (DW-1207, Dron)",
			wiped.Planet, wiped.Production)
	}
	if float64(wiped.AttackPower) != 7.62 {
		t.Errorf("Bombing[0].AttackPower = %v, want 7.62", float64(wiped.AttackPower))
	}
	damaged := rep.Bombing[1]
	if damaged.Wiped {
		t.Errorf("Bombing[1].Wiped = true, want false (Damaged)")
	}
	if float64(damaged.Capital) != 184.39 || float64(damaged.Colonists) != 6.42 {
		t.Errorf("Bombing[1] (capital, colonists) = (%v, %v), want (184.39, 6.42)",
			float64(damaged.Capital), float64(damaged.Colonists))
	}
}

// TestParseShipsInProduction covers the prod_used derivation through
// [calc.ShipBuildCost]. The producing planet is mounted first with a
// non-zero material stockpile so the farming term contributes a
// non-trivial slice of totalCost; the expected prod_used number is
// derived from totalCost * percent with the same formula the parser
// uses.
func TestParseShipsInProduction(t *testing.T) {
	// Planet: Material=0.68, Resources=10.00.
	// Ship: cost=990.10 -> shipMass=99.01.
	// totalCost = ShipProductionCost(99.01) + max(0, 99.01-0.68)/10
	//          = 990.10 + 9.833
	//          = 999.933
	// prod_used = 999.933 * 0.07 (percent) ≈ 69.99531
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Your Planets",
		"",
		"  #       X       Y  N        S        P        I      R  P                $       M       C        L",
		" 17  171.05  700.24  Castle  1000.00  1000.00  1000.00  10.00  CombatFlame  0.00    0.68   88.78  1000.00",
		"",
		"Ships In Production",
		"",
		"  #  N        S                C       P     L",
		" 17  Castle   CombatFlame  990.10  0.07  1000.00",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.ShipProduction), 1; got != want {
		t.Fatalf("len(ShipProduction) = %d, want %d", got, want)
	}
	sp := rep.ShipProduction[0]
	if sp.Planet != 17 || sp.Class != "CombatFlame" {
		t.Errorf("ShipProduction[0] = (planet=%d, class=%q), want (17, CombatFlame)",
			sp.Planet, sp.Class)
	}
	if got := float64(sp.Cost); got != 990.10 {
		t.Errorf("ShipProduction[0].Cost = %v, want 990.10", got)
	}
	if got := float64(sp.Percent); got != 0.07 {
		t.Errorf("ShipProduction[0].Percent = %v, want 0.07", got)
	}
	if got := float64(sp.Free); got != 1000.0 {
		t.Errorf("ShipProduction[0].Free = %v, want 1000", got)
	}
	wantProdUsed := 69.995
	if got := float64(sp.ProdUsed); got < wantProdUsed-0.01 || got > wantProdUsed+0.01 {
		t.Errorf("ShipProduction[0].ProdUsed = %v, want ~%v (totalCost * percent)",
			got, wantProdUsed)
	}
}

// TestParseShipsInProductionDropsUnknownPlanet exercises the safety
// net: a ships-in-production row referencing a planet not seen in
// "Your Planets" is dropped, because the prod_used derivation needs
// the planet's material and resources.
func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Ships In Production",
		"",
		"  #  N      S            C      P     L",
		" 99  Lost   Frigate  100.00  0.05  500.00",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.ShipProduction), 0; got != want {
		t.Errorf("len(ShipProduction) = %d, want %d (planet #99 missing → drop)",
			got, want)
	}
}

// TestParseBattles exercises the battle-block parser end-to-end:
// two battles with two races each, full rosters, and protocols. The
// inline fixture mirrors the KNNTS-style layout (race-named roster
// sub-headers, 10-column roster rows, 8-token shot lines) so any
// drift from the real engine format breaks this test before a smoke
// regression. Asserts:
//   - report.Battle carries one BattleSummary per "Battle at"
//   - BattleReport slice mirrors that with full Races/Ships/Protocol
//   - Battle Protocol "Foo fires on Bar : <Destroyed|Shields>" lines
//     map to BattleActionReport entries with the correct destroyed flag
//   - Roster column 8 (the "L" column) populates NumberLeft
//   - Top-level sections after a battle (Your Planets) still parse
//     — battle state must close cleanly without leaking rows.
func TestParseBattles(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Battle at (#7) B-007",
		"",
		"Foo Groups",
		"",
		"#  T            D  W  S  C  T  Q  L",
		"1  PeaceShip  4.0  0  0  0  -  0  1  In_Battle",
		"2  Drone      0.0  1  1  0  -  0  0  In_Battle",
		"",
		"Bar Groups",
		"",
		"#  T          D     W  S  C  T  Q  L",
		"1  Pistolet  1.0  1.0  0  0  -  0  1  In_Battle",
		"",
		"Battle Protocol",
		"",
		"Foo  PeaceShip  fires  on  Bar  Pistolet  :  Shields",
		"Bar  Pistolet  fires  on  Foo  Drone  :  Destroyed",
		"Bar  Pistolet  fires  on  Foo  Drone  :  Destroyed",
		"",
		"Battle at (#11) X-011",
		"",
		"Foo Groups",
		"",
		"#  T          D  W  S  C  T  Q  L",
		"1  Scout    1.0  0  0  0  -  0  1  In_Battle",
		"",
		"Bar Groups",
		"",
		"#  T          D  W  S  C  T  Q  L",
		"1  Sniper   2.0  1  0  0  -  0  0  In_Battle",
		"",
		"Battle Protocol",
		"",
		"Foo  Scout  fires  on  Bar  Sniper  :  Destroyed",
		"",
		"Your Planets",
		"",
		"  #       X       Y  N         S        P        I      R  P              $       M       C        L",
		" 17  171.05  700.24  Castle  1000.00  1000.00  1000.00  10.00  Capital  0.00    0.68   88.78  1000.00",
		"",
	}, "\n")
	rep, battles, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}

	// The trailing Your Planets section must still parse — battle
	// state must close before the next top-level header.
	if got, want := len(rep.LocalPlanet), 1; got != want {
		t.Fatalf("len(LocalPlanet) = %d, want %d (battle state did not close)", got, want)
	}

	if got, want := len(rep.Battle), 2; got != want {
		t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
	}
	if got, want := len(battles), 2; got != want {
		t.Fatalf("len(battles) = %d, want %d", got, want)
	}

	// First battle: planet 7, 3 shots; protocol shape with one
	// shielded shot and two destroyed shots.
	b0 := battles[0]
	if b0.Planet != 7 || b0.PlanetName != "B-007" {
		t.Errorf("battle[0] = (planet=%d, name=%q), want (7, %q)",
			b0.Planet, b0.PlanetName, "B-007")
	}
	if got, want := len(b0.Protocol), 3; got != want {
		t.Fatalf("battle[0].Protocol = %d shots, want %d", got, want)
	}
	if b0.Protocol[0].Destroyed {
		t.Errorf("battle[0].Protocol[0].Destroyed = true (Shields hit), want false")
	}
	if !b0.Protocol[1].Destroyed || !b0.Protocol[2].Destroyed {
		t.Errorf("battle[0].Protocol[1..2].Destroyed must be true (Destroyed hits)")
	}

	// First battle: roster size and NumberLeft mapping.
	if got, want := len(b0.Ships), 3; got != want {
		t.Fatalf("battle[0].Ships = %d groups, want %d", got, want)
	}
	// 'Drone' has NumberLeft=0 in the roster (column 8 = 0). The
	// protocol corroborates: Pistolet destroyed Drone twice.
	dronePresent := false
	for _, ship := range b0.Ships {
		if ship.ClassName == "Drone" {
			dronePresent = true
			if ship.NumberLeft != 0 {
				t.Errorf("Drone.NumberLeft = %d, want 0", ship.NumberLeft)
			}
			if ship.Number != 2 {
				t.Errorf("Drone.Number = %d, want 2", ship.Number)
			}
		}
	}
	if !dronePresent {
		t.Errorf("Drone roster row not parsed into battle[0].Ships")
	}

	// Summary mirrors the BattleReport ID and shot count.
	if rep.Battle[0].ID != b0.ID {
		t.Errorf("rep.Battle[0].ID = %s, want %s", rep.Battle[0].ID, b0.ID)
	}
	if rep.Battle[0].Shots != 3 {
		t.Errorf("rep.Battle[0].Shots = %d, want 3", rep.Battle[0].Shots)
	}
	if rep.Battle[0].Planet != 7 {
		t.Errorf("rep.Battle[0].Planet = %d, want 7", rep.Battle[0].Planet)
	}

	// Second battle: planet 11, 1 shot.
	if rep.Battle[1].Planet != 11 || rep.Battle[1].Shots != 1 {
		t.Errorf("rep.Battle[1] = (planet=%d, shots=%d), want (11, 1)",
			rep.Battle[1].Planet, rep.Battle[1].Shots)
	}

	// Battle IDs are stable across re-parses.
	rep2, battles2, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse (second pass): %v", err)
	}
	if rep.Battle[0].ID != rep2.Battle[0].ID || battles[0].ID != battles2[0].ID {
		t.Errorf("battle id must be deterministic across re-parses")
	}
}

// TestParseBattleAggregatesDuplicateClasses guards against the
// regression that produced the original "phantom destroys" symptom:
// the same `(race, className)` pair appearing on multiple roster
// rows must collapse into a single BattleReportGroup whose `Number`
// (the "#" column, initial ship count) and `NumberLeft` (the "L"
// column, survivors) are the sums across rows. Without the
// aggregation only the last row's counts survived and the protocol's
// destroy count dwarfed the recorded initial count (e.g. KNNTS041
// turn-41 planet #7 lists `pup` seven separate times: 99 + 105 + 291 +
// 287 + 166 + 132 + 88 = 1168 ships, 86 survivors, 1082 destroys).
func TestParseBattleAggregatesDuplicateClasses(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Battle at (#7) B-007",
		"",
		"Foo Groups",
		"",
		" #  T          D    W  S  C  T  Q  L",
		" 3  Drone    1.0  1.0  0  0  -  0  1  In_Battle",
		" 4  Drone    1.2  1.0  0  0  -  0  2  In_Battle",
		"10  Cruiser  3.0  2.0  0  0  -  0  9  In_Battle",
		"",
		"Bar Groups",
		"",
		"#  T          D    W  S  C  T  Q  L",
		"5  Pistolet 1.0  1.0  0  0  -  0  3  In_Battle",
		"",
		"Battle Protocol",
		"",
		"Bar  Pistolet  fires  on  Foo  Drone  :  Destroyed",
		"Bar  Pistolet  fires  on  Foo  Drone  :  Destroyed",
		"Bar  Pistolet  fires  on  Foo  Drone  :  Destroyed",
		"Bar  Pistolet  fires  on  Foo  Drone  :  Destroyed",
		"",
	}, "\n")

	rep, battles, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(battles), 1; got != want {
		t.Fatalf("len(battles) = %d, want %d", got, want)
	}
	b := battles[0]

	// The three Foo roster rows collapse into two BattleReportGroup
	// entries: one Foo:Drone (rows 1+2) and one Foo:Cruiser (row 3),
	// plus one Bar:Pistolet. Total 3 groups, NOT 4.
	if got, want := len(b.Ships), 3; got != want {
		t.Fatalf("battle.Ships = %d groups, want %d (duplicate class rows must merge)", got, want)
	}

	var drone, cruiser, pistolet *report.BattleReportGroup
	for i := range b.Ships {
		g := b.Ships[i]
		switch g.ClassName {
		case "Drone":
			drone = &g
		case "Cruiser":
			cruiser = &g
		case "Pistolet":
			pistolet = &g
		}
	}
	if drone == nil || cruiser == nil || pistolet == nil {
		t.Fatalf("missing class: drone=%v cruiser=%v pistolet=%v", drone, cruiser, pistolet)
	}

	// Drone rows sum to Number = 3 + 4 = 7 and NumberLeft = 1 + 2 = 3.
	// Protocol corroborates: four Destroyed shots against Drone, so
	// 7 - 3 = 4 — the protocol's destroy count reconciles with the
	// recorded delta only when both rows are summed.
	if drone.Number != 7 {
		t.Errorf("Drone.Number = %d, want 7 (3+4)", drone.Number)
	}
	if drone.NumberLeft != 3 {
		t.Errorf("Drone.NumberLeft = %d, want 3 (1+2)", drone.NumberLeft)
	}
	// Cruiser and Pistolet are single-row classes — counts must match
	// the file verbatim with no spurious merging across classes.
	if cruiser.Number != 10 || cruiser.NumberLeft != 9 {
		t.Errorf("Cruiser = (Number=%d, NumberLeft=%d), want (10, 9)",
			cruiser.Number, cruiser.NumberLeft)
	}
	if pistolet.Number != 5 || pistolet.NumberLeft != 3 {
		t.Errorf("Pistolet = (Number=%d, NumberLeft=%d), want (5, 3)",
			pistolet.Number, pistolet.NumberLeft)
	}

	// rep-level summary must reflect the merged shape: 4 shots, one
	// battle, no crash or spurious extra battles.
	if got, want := len(rep.Battle), 1; got != want {
		t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
	}
	if rep.Battle[0].Shots != 4 {
		t.Errorf("rep.Battle[0].Shots = %d, want 4", rep.Battle[0].Shots)
	}
}

// TestParseYourGroups exercises the local-group section. Two rows
// cover the on-planet ("In_Orbit", origin "-") and in-space ("In_Space",
// origin name + range) variants, plus a cargo-loaded row to assert the
// load-type / load-quantity columns are wired through. A planet
// table is mounted upfront so destination/origin name resolution
// has something to bind against.
func TestParseYourGroups(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Your Planets",
		"",
		"  #       X       Y  N         S        P        I      R  P                $       M       C        L",
		" 17  171.05  700.24  Castle  1000.00  1000.00  1000.00  10.00  Capital         0.00    0.68   88.78  1000.00",
		" 87  169.59  694.49  North    500.00   500.00   500.00  10.00  Capital         0.00    0.52   35.76   500.00",
		"",
		"Your Groups",
		"",
		"  G   #  T            D     W     S    C  T    Q  D       F      R       P       M  L",
		"  0   2  Frontier   5.05  0.00  0.00  1.0  -    0  Castle  -      -    92.84   12.37  -    In_Orbit",
		"  1   1  Bow105    11.19  4.76  7.09  1.0  COL  1  Castle  -      -    111.9  149.54  -    In_Orbit",
		"  2   1  Tormoz    11.19  0.00  0.00  1.0  CAP  4  North   Castle 7.5  60.66   49.50  -    In_Space",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.LocalGroup), 3; got != want {
		t.Fatalf("len(LocalGroup) = %d, want %d", got, want)
	}
	first := rep.LocalGroup[0]
	if first.Number != 2 || first.Class != "Frontier" {
		t.Errorf("first group = (%d, %q), want (2, Frontier)", first.Number, first.Class)
	}
	if got, want := float64(first.Tech["drive"]), 5.05; got != want {
		t.Errorf("first.Tech[drive] = %v, want %v", got, want)
	}
	if first.Destination != 17 {
		t.Errorf("first.Destination = %d, want 17 (Castle resolved)", first.Destination)
	}
	if first.Origin != nil || first.Range != nil {
		t.Errorf("first.{Origin,Range} = (%v, %v), want both nil for In_Orbit", first.Origin, first.Range)
	}
	if first.State != "In_Orbit" {
		t.Errorf("first.State = %q, want In_Orbit", first.State)
	}
	if first.Fleet != nil {
		t.Errorf("first.Fleet = %v, want nil for `-`", first.Fleet)
	}

	loaded := rep.LocalGroup[1]
	if loaded.Cargo != "COL" || float64(loaded.Load) != 1.0 {
		t.Errorf("loaded cargo/load = (%q, %v), want (COL, 1.0)", loaded.Cargo, float64(loaded.Load))
	}

	flying := rep.LocalGroup[2]
	if flying.State != "In_Space" {
		t.Errorf("flying.State = %q, want In_Space", flying.State)
	}
	if flying.Origin == nil || *flying.Origin != 17 {
		t.Errorf("flying.Origin = %v, want 17 (Castle)", flying.Origin)
	}
	if flying.Range == nil || float64(*flying.Range) != 7.5 {
		t.Errorf("flying.Range = %v, want 7.5", flying.Range)
	}
	if flying.Destination != 87 {
		t.Errorf("flying.Destination = %d, want 87 (North)", flying.Destination)
	}
}

func TestParseYourFleets(t *testing.T) {
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Your Planets",
		"",
		"  #       X       Y  N         S        P        I      R  P              $       M       C        L",
		" 17  171.05  700.24  Castle  1000.00  1000.00  1000.00  10.00  Capital  0.00    0.68   88.78  1000.00",
		" 87  169.59  694.49  North    500.00   500.00   500.00  10.00  Capital  0.00    0.52   35.76   500.00",
		"",
		"Your Fleets",
		"",
		" #  N      G  D       F       R     P",
		" 0  Fast   3  Castle  -       -     45  In_Orbit",
		" 1  Far    2  North   Castle  4.50  20  In_Space",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.LocalFleet), 2; got != want {
		t.Fatalf("len(LocalFleet) = %d, want %d", got, want)
	}
	fast := rep.LocalFleet[0]
	if fast.Name != "Fast" || fast.Groups != 3 || fast.Destination != 17 {
		t.Errorf("fast = %+v, want Name=Fast Groups=3 Destination=17", fast)
	}
	if fast.State != "In_Orbit" {
		t.Errorf("fast.State = %q, want In_Orbit", fast.State)
	}
	if fast.Origin != nil || fast.Range != nil {
		t.Errorf("fast.{Origin,Range} = (%v, %v), want both nil", fast.Origin, fast.Range)
	}
	far := rep.LocalFleet[1]
	if far.Origin == nil || *far.Origin != 17 {
		t.Errorf("far.Origin = %v, want 17 (Castle)", far.Origin)
	}
	if far.Range == nil || float64(*far.Range) != 4.5 {
		t.Errorf("far.Range = %v, want 4.5", far.Range)
	}
}

func TestParseIncomingGroups(t *testing.T) {
	// Origin is a `#NN` by-id reference; destination resolves
	// against the local planet table that was parsed earlier in
	// this synthetic file. The order is intentionally swapped — in
	// real legacy reports "Incoming Groups" can land before
	// "Your Planets", which is why the parser buffers rows for
	// post-processing.
	in := strings.Join([]string{
		"Race Report for Galaxy PLUS Turn 1",
		"",
		"Incoming Groups",
		"",
		"O     D       R      S    M",
		"#98   Castle  136.16 190  1",
		"North Castle   42.12  99  2",
		"",
		"Your Planets",
		"",
		"  #       X       Y  N         S        P        I      R  P              $       M       C        L",
		" 17  171.05  700.24  Castle  1000.00  1000.00  1000.00  10.00  Capital  0.00    0.68   88.78  1000.00",
		" 87  169.59  694.49  North    500.00   500.00   500.00  10.00  Capital  0.00    0.52   35.76   500.00",
		"",
	}, "\n")
	rep, _, err := Parse(strings.NewReader(in))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if got, want := len(rep.IncomingGroup), 2; got != want {
		t.Fatalf("len(IncomingGroup) = %d, want %d", got, want)
	}
	a := rep.IncomingGroup[0]
	if a.Origin != 98 || a.Destination != 17 {
		t.Errorf("a (Origin, Destination) = (%d, %d), want (98, 17)", a.Origin, a.Destination)
	}
	if float64(a.Distance) != 136.16 || float64(a.Speed) != 190 {
		t.Errorf("a (Distance, Speed) = (%v, %v), want (136.16, 190)", float64(a.Distance), float64(a.Speed))
	}
	b := rep.IncomingGroup[1]
	if b.Origin != 87 || b.Destination != 17 {
		t.Errorf("b (Origin, Destination) = (%d, %d), want (87, 17)", b.Origin, b.Destination)
	}
}

// --- smoke tests -----------------------------------------------------

type smokeWant struct {
	race                                          string
	turn                                          uint
	mapW, mapH, planetCount                       uint32
	voteFor                                       string
	votes                                         float64
	players, extinct, local, other                int
	uninhabited, unidentified, shipClasses        int
	localGroups, localFleets, incomingGroups      int
	localScience, otherScience, otherShipClass    int
	bombings, shipProductions                     int
	battles                                       int
}

func runSmoke(t *testing.T, path string, want smokeWant) {
	t.Helper()
	rep, battles, err := parseFile(t, path)
	if err != nil {
		if os.IsNotExist(err) {
			t.Skipf("legacy report fixture missing: %s", path)
		}
		t.Fatalf("Parse %s: %v", path, err)
	}
	if rep.Race != want.race || rep.Turn != want.turn {
		t.Errorf("(race, turn) = (%q, %d), want (%q, %d)", rep.Race, rep.Turn, want.race, want.turn)
	}
	if rep.Width != want.mapW || rep.Height != want.mapH || rep.PlanetCount != want.planetCount {
		t.Errorf("(W, H, planets) = (%d, %d, %d), want (%d, %d, %d)",
			rep.Width, rep.Height, rep.PlanetCount, want.mapW, want.mapH, want.planetCount)
	}
	if want.voteFor != "" {
		if rep.VoteFor != want.voteFor || float64(rep.Votes) != want.votes {
			t.Errorf("(voteFor, votes) = (%q, %v), want (%q, %v)",
				rep.VoteFor, float64(rep.Votes), want.voteFor, want.votes)
		}
	}
	extinct := 0
	for _, pl := range rep.Player {
		if pl.Extinct {
			extinct++
		}
	}
	checks := []struct {
		name string
		got  int
		want int
	}{
		{"Player", len(rep.Player), want.players},
		{"extinct", extinct, want.extinct},
		{"LocalPlanet", len(rep.LocalPlanet), want.local},
		{"OtherPlanet", len(rep.OtherPlanet), want.other},
		{"UninhabitedPlanet", len(rep.UninhabitedPlanet), want.uninhabited},
		{"UnidentifiedPlanet", len(rep.UnidentifiedPlanet), want.unidentified},
		{"LocalShipClass", len(rep.LocalShipClass), want.shipClasses},
		{"LocalGroup", len(rep.LocalGroup), want.localGroups},
		{"LocalFleet", len(rep.LocalFleet), want.localFleets},
		{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
		{"LocalScience", len(rep.LocalScience), want.localScience},
		{"OtherScience", len(rep.OtherScience), want.otherScience},
		{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
		{"Bombing", len(rep.Bombing), want.bombings},
		{"ShipProduction", len(rep.ShipProduction), want.shipProductions},
		{"Battle (summary)", len(rep.Battle), want.battles},
		{"BattleReport", len(battles), want.battles},
	}
	for _, c := range checks {
		if c.got != c.want {
			t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
		}
	}
	for i, summary := range rep.Battle {
		if i >= len(battles) {
			break
		}
		if summary.ID != battles[i].ID {
			t.Errorf("battle[%d].ID summary=%s vs report=%s",
				i, summary.ID, battles[i].ID)
		}
		if summary.Shots != uint(len(battles[i].Protocol)) {
			t.Errorf("battle[%d].Shots = %d, want %d (len(Protocol))",
				i, summary.Shots, len(battles[i].Protocol))
		}
		if summary.Planet != battles[i].Planet {
			t.Errorf("battle[%d].Planet summary=%d vs report=%d",
				i, summary.Planet, battles[i].Planet)
		}
	}
}

// TestParseDgKNNTS039 is a smoke test: the parser must produce
// stable top-line counts from the real dg/KNNTS039.REP fixture.
// Field-level fidelity is asserted in the unit tests above; this
// test catches regressions where a section-classifier change
// silently drops half the data.
func TestParseDgKNNTS039(t *testing.T) {
	runSmoke(t, "../reports/dg/KNNTS039.REP", smokeWant{
		race: "KnightErrants", turn: 39,
		mapW: 800, mapH: 800, planetCount: 700,
		voteFor: "KnightErrants", votes: 16.02,
		players: 91, extinct: 49,
		local: 22, other: 89, uninhabited: 17, unidentified: 572,
		shipClasses:     24,
		localGroups:     171,
		localFleets:     0,
		incomingGroups:  0,
		localScience:    1,
		otherScience:    1,
		otherShipClass:  170,
		bombings:        16,
		shipProductions: 6,
		battles:         28,
	})
}

func TestParseDgKNNTS040(t *testing.T) {
	runSmoke(t, "../reports/dg/KNNTS040.REP", smokeWant{
		race: "KnightErrants", turn: 40,
		mapW: 800, mapH: 800, planetCount: 700,
		players: 91, extinct: 49,
		local: 22, other: 93, uninhabited: 27, unidentified: 558,
		shipClasses:     34,
		localGroups:     207,
		localFleets:     0,
		incomingGroups:  0,
		localScience:    1,
		otherScience:    1,
		otherShipClass:  160,
		bombings:        24,
		shipProductions: 16,
		battles:         79,
	})
}

// TestParseDgKNNTS041 covers a turn with active "Incoming Groups"
// entries (12 rows) appearing before the "Your Planets" table —
// exercises the deferred name-resolution path in [parser.finish].
func TestParseDgKNNTS041(t *testing.T) {
	runSmoke(t, "../reports/dg/KNNTS041.REP", smokeWant{
		race: "KnightErrants", turn: 41,
		mapW: 800, mapH: 800, planetCount: 700,
		players: 91, extinct: 50,
		local: 29, other: 103, uninhabited: 23, unidentified: 545,
		shipClasses:     36,
		localGroups:     285,
		localFleets:     0,
		incomingGroups:  12,
		localScience:    1,
		otherScience:    1,
		otherShipClass:  218,
		bombings:        12,
		shipProductions: 22,
		battles:         56,
	})
}

// TestParseGplus40 exercises the gplus engine variant (tabs in
// section headers, pseudo-cyrillic ASCII names) and a single fleet.
// gplus also sneaks "Incoming Groups" between sections.
func TestParseGplus40(t *testing.T) {
	runSmoke(t, "../reports/gplus/40.REP", smokeWant{
		race: "MbI", turn: 40,
		mapW: 350, mapH: 350, planetCount: 300,
		players: 26, extinct: 0,
		local: 26, other: 116, uninhabited: 7, unidentified: 151,
		shipClasses:     56,
		localGroups:     255,
		localFleets:     1,
		incomingGroups:  10,
		localScience:    0,
		otherScience:    0,
		otherShipClass:  183,
		bombings:        4,
		shipProductions: 8,
		battles:         30,
	})
}

// TestParseDgKiller031 exercises the Killer engine variant which
// ships "Your Fleets" + "Your Groups" with the Fl1/F2 fleet
// membership shape (no "Incoming Groups" this turn).
func TestParseDgKiller031(t *testing.T) {
	runSmoke(t, "../reports/dg/Killer031.rep", smokeWant{
		race: "Killer", turn: 31,
		mapW: 250, mapH: 250, planetCount: 175,
		players: 25, extinct: 12,
		local: 18, other: 127, uninhabited: 20, unidentified: 10,
		shipClasses:     11,
		localGroups:     175,
		localFleets:     2,
		incomingGroups:  0,
		localScience:    0,
		otherScience:    0,
		otherShipClass:  161,
		bombings:        18,
		shipProductions: 0,
		battles:         83,
	})
}

// TestParseDgTancordia037 is the richest smoke fixture: it carries
// 311 local groups across 30 fleets, two incoming groups, and the
// "Incoming Groups" section appears before "Your Planets" (so the
// deferred name resolution is exercised in production conditions).
func TestParseDgTancordia037(t *testing.T) {
	runSmoke(t, "../reports/dg/Tancordia037.rep", smokeWant{
		race: "Tancordia", turn: 37,
		mapW: 210, mapH: 210, planetCount: 140,
		players: 18, extinct: 7,
		local: 23, other: 62, uninhabited: 26, unidentified: 29,
		shipClasses:     40,
		localGroups:     311,
		localFleets:     30,
		incomingGroups:  2,
		localScience:    1,
		otherScience:    1,
		otherShipClass:  123,
		bombings:        22,
		shipProductions: 20,
		battles:         57,
	})
}

func parseFile(t *testing.T, rel string) (report.Report, []report.BattleReport, error) {
	t.Helper()
	abs, err := filepath.Abs(rel)
	if err != nil {
		return report.Report{}, nil, err
	}
	f, err := os.Open(abs)
	if err != nil {
		return report.Report{}, nil, err
	}
	defer func() { _ = f.Close() }()
	return Parse(f)
}
