using System.Text;
using System.Text.Json;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Time;

namespace Theriapolis.Core.Persistence;

/// <summary>
/// Read/write the on-disk save format:
///
///   [4 bytes: headerLen (uint32 LE)]
///   [headerLen bytes: header JSON (UTF-8)]
///   [remaining: SaveBody binary blob]
///
/// The body is hand-rolled binary rather than MessagePack so we don't pull a
/// new nuget into Core. The format is purely additive — any new field becomes
/// a new tagged section, which keeps Phase 5/6 expansion smooth.
/// </summary>
public static class SaveCodec
{
    private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };

    // Body section tags. New sections in later phases get new tags >= 100.
    private const byte TAG_END       = 0;
    private const byte TAG_PLAYER    = 1;
    private const byte TAG_CLOCK     = 2;
    private const byte TAG_CHUNKS    = 3;
    private const byte TAG_WTDELTA   = 4;
    private const byte TAG_FLAGS     = 5;
    // Phase 5 additions:
    private const byte TAG_CHARACTER  = 100;
    private const byte TAG_NPC_ROSTER = 101;
    private const byte TAG_ENCOUNTER  = 102;
    // Phase 6 additions:
    private const byte TAG_FACTION_STANDINGS = 110;
    private const byte TAG_QUESTS            = 111;
    private const byte TAG_REPUTATION        = 112;
    // 113 reserved for TAG_ANCHORS (Phase 6 M5/save-anywhere if needed).
    // 114 reserved for TAG_BUILDINGS (Phase 6 M5).

    public static byte[] Serialize(SaveHeader header, SaveBody body)
    {
        // Header JSON
        string headerJson = JsonSerializer.Serialize(header, JsonOptions);
        byte[] headerBytes = Encoding.UTF8.GetBytes(headerJson);

        using var ms = new MemoryStream();
        using var w  = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);

        w.Write((uint)headerBytes.Length);
        w.Write(headerBytes);

        // Body
        WriteBody(w, body);

        return ms.ToArray();
    }

    public static (SaveHeader header, SaveBody body) Deserialize(byte[] data)
    {
        using var ms = new MemoryStream(data);
        using var r  = new BinaryReader(ms, Encoding.UTF8, leaveOpen: true);

        uint headerLen = r.ReadUInt32();
        byte[] headerBytes = r.ReadBytes((int)headerLen);
        if (headerBytes.Length != headerLen)
            throw new InvalidDataException("Save header truncated.");

        string headerJson = Encoding.UTF8.GetString(headerBytes);
        var header = JsonSerializer.Deserialize<SaveHeader>(headerJson, JsonOptions)
                     ?? throw new InvalidDataException("Save header empty.");

        var body = ReadBody(r);
        return (header, body);
    }

    /// <summary>Reads ONLY the JSON header, leaving the body unread. Used by the slot picker.</summary>
    public static SaveHeader DeserializeHeaderOnly(byte[] data)
    {
        if (data.Length < 4) throw new InvalidDataException("Save file too short.");
        uint headerLen = BitConverter.ToUInt32(data, 0);
        if (headerLen + 4 > data.Length) throw new InvalidDataException("Save header truncated.");
        string headerJson = Encoding.UTF8.GetString(data, 4, (int)headerLen);
        return JsonSerializer.Deserialize<SaveHeader>(headerJson, JsonOptions)
               ?? throw new InvalidDataException("Save header empty.");
    }

    // ── Body writer ───────────────────────────────────────────────────────

    private static void WriteBody(BinaryWriter w, SaveBody body)
    {
        WriteSection(w, TAG_PLAYER,  bw => WritePlayer(bw, body.Player));
        WriteSection(w, TAG_CLOCK,   bw => bw.Write(body.Clock.InGameSeconds));
        WriteSection(w, TAG_CHUNKS,  bw => WriteChunkDeltas(bw, body.ModifiedChunks));
        WriteSection(w, TAG_WTDELTA, bw => WriteWorldTileDeltas(bw, body.ModifiedWorldTiles));
        WriteSection(w, TAG_FLAGS,   bw => WriteFlags(bw, body.Flags));
        if (body.PlayerCharacter is not null)
            WriteSection(w, TAG_CHARACTER, bw => WriteCharacter(bw, body.PlayerCharacter));
        if (body.NpcRoster.ChunkDeltas.Count > 0)
            WriteSection(w, TAG_NPC_ROSTER, bw => WriteNpcRoster(bw, body.NpcRoster));
        if (body.ActiveEncounter is not null)
            WriteSection(w, TAG_ENCOUNTER, bw => WriteEncounter(bw, body.ActiveEncounter));
        // Phase 6 M2 — reputation. Faction standings ride a separate tag from
        // the personal/ledger payload so a "no personal records yet" save
        // doesn't pay the cost of an empty section.
        if (body.ReputationState.FactionStandings.Count > 0)
            WriteSection(w, TAG_FACTION_STANDINGS, bw => WriteFactionStandings(bw, body.ReputationState.FactionStandings));
        if (body.ReputationState.Personal.Count > 0 || body.ReputationState.Ledger.Count > 0)
            WriteSection(w, TAG_REPUTATION, bw => WriteReputation(bw, body.ReputationState));
        // Phase 6 M4 — quest engine snapshot.
        if (body.QuestEngineState.Active.Count > 0 || body.QuestEngineState.Completed.Count > 0 || body.QuestEngineState.Journal.Count > 0)
            WriteSection(w, TAG_QUESTS, bw => WriteQuests(bw, body.QuestEngineState));
        w.Write(TAG_END);
    }

    private static void WriteSection(BinaryWriter w, byte tag, Action<BinaryWriter> body)
    {
        w.Write(tag);
        // Length-prefix the section so unknown tags can be skipped on read.
        using var ms = new MemoryStream();
        using (var inner = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true))
            body(inner);
        var bytes = ms.ToArray();
        w.Write((uint)bytes.Length);
        w.Write(bytes);
    }

    private static void WritePlayer(BinaryWriter w, PlayerActorState p)
    {
        w.Write(p.Id);
        WriteString(w, p.Name);
        w.Write(p.PositionX);
        w.Write(p.PositionY);
        w.Write(p.FacingAngleRad);
        w.Write(p.SpeedWorldPxPerSec);
        w.Write(p.HighestTierReached);
        w.Write(p.DiscoveredPoiIds.Length);
        foreach (int id in p.DiscoveredPoiIds) w.Write(id);
    }

    private static void WriteChunkDeltas(BinaryWriter w, IReadOnlyDictionary<ChunkCoord, ChunkDelta> chunks)
    {
        w.Write(chunks.Count);
        foreach (var kv in chunks)
        {
            w.Write(kv.Key.X);
            w.Write(kv.Key.Y);
            w.Write(kv.Value.SpawnsConsumed);
            w.Write(kv.Value.TileMods.Count);
            foreach (var m in kv.Value.TileMods)
            {
                w.Write(m.LocalX);
                w.Write(m.LocalY);
                w.Write((byte)m.Surface);
                w.Write((byte)m.Deco);
                w.Write(m.Flags);
            }
        }
    }

    private static void WriteWorldTileDeltas(BinaryWriter w, List<WorldTileDelta> tiles)
    {
        w.Write(tiles.Count);
        foreach (var t in tiles)
        {
            w.Write(t.X);
            w.Write(t.Y);
            w.Write(t.NewBiome);
            w.Write(t.NewFeatures);
        }
    }

    private static void WriteFlags(BinaryWriter w, Dictionary<string, int> flags)
    {
        w.Write(flags.Count);
        foreach (var kv in flags)
        {
            WriteString(w, kv.Key);
            w.Write(kv.Value);
        }
    }

    // ── Body reader ───────────────────────────────────────────────────────

    private static SaveBody ReadBody(BinaryReader r)
    {
        var body = new SaveBody();
        while (true)
        {
            byte tag = r.ReadByte();
            if (tag == TAG_END) break;
            uint len = r.ReadUInt32();
            byte[] sectionBytes = r.ReadBytes((int)len);
            if (sectionBytes.Length != len) throw new InvalidDataException("Save body truncated.");
            using var ms = new MemoryStream(sectionBytes);
            using var br = new BinaryReader(ms);
            switch (tag)
            {
                case TAG_PLAYER:    body.Player             = ReadPlayer(br); break;
                case TAG_CLOCK:     body.Clock.InGameSeconds = br.ReadInt64(); break;
                case TAG_CHUNKS:    body.ModifiedChunks     = ReadChunkDeltas(br); break;
                case TAG_WTDELTA:   body.ModifiedWorldTiles = ReadWorldTileDeltas(br); break;
                case TAG_FLAGS:     body.Flags              = ReadFlags(br); break;
                case TAG_CHARACTER:  body.PlayerCharacter = ReadCharacter(br); break;
                case TAG_NPC_ROSTER: body.NpcRoster        = ReadNpcRoster(br); break;
                case TAG_ENCOUNTER:  body.ActiveEncounter  = ReadEncounter(br); break;
                case TAG_FACTION_STANDINGS:
                    body.ReputationState.FactionStandings = ReadFactionStandings(br);
                    break;
                case TAG_REPUTATION:
                    {
                        var rep = ReadReputation(br);
                        // Faction standings may have been read earlier — preserve them.
                        rep.FactionStandings = body.ReputationState.FactionStandings;
                        body.ReputationState = rep;
                        break;
                    }
                case TAG_QUESTS:
                    body.QuestEngineState = ReadQuests(br);
                    break;
                default:             /* unknown tag: skip — forward compat */ break;
            }
        }
        return body;
    }

    private static PlayerActorState ReadPlayer(BinaryReader r)
    {
        var p = new PlayerActorState
        {
            Id                 = r.ReadInt32(),
            Name               = ReadString(r),
            PositionX          = r.ReadSingle(),
            PositionY          = r.ReadSingle(),
            FacingAngleRad     = r.ReadSingle(),
            SpeedWorldPxPerSec = r.ReadSingle(),
            HighestTierReached = r.ReadInt32(),
        };
        int n = r.ReadInt32();
        p.DiscoveredPoiIds = new int[n];
        for (int i = 0; i < n; i++) p.DiscoveredPoiIds[i] = r.ReadInt32();
        return p;
    }

    private static Dictionary<ChunkCoord, ChunkDelta> ReadChunkDeltas(BinaryReader r)
    {
        var m = new Dictionary<ChunkCoord, ChunkDelta>();
        int n = r.ReadInt32();
        for (int i = 0; i < n; i++)
        {
            int cx = r.ReadInt32();
            int cy = r.ReadInt32();
            var d = new ChunkDelta { SpawnsConsumed = r.ReadBoolean() };
            int mc = r.ReadInt32();
            for (int j = 0; j < mc; j++)
            {
                byte lx = r.ReadByte();
                byte ly = r.ReadByte();
                var sf = (TacticalSurface)r.ReadByte();
                var de = (TacticalDeco)r.ReadByte();
                byte fl = r.ReadByte();
                d.TileMods.Add(new TileMod(lx, ly, sf, de, fl));
            }
            m[new ChunkCoord(cx, cy)] = d;
        }
        return m;
    }

    private static List<WorldTileDelta> ReadWorldTileDeltas(BinaryReader r)
    {
        int n = r.ReadInt32();
        var l = new List<WorldTileDelta>(n);
        for (int i = 0; i < n; i++)
        {
            ushort x = r.ReadUInt16();
            ushort y = r.ReadUInt16();
            byte b = r.ReadByte();
            ushort f = r.ReadUInt16();
            l.Add(new WorldTileDelta(x, y, b, f));
        }
        return l;
    }

    private static Dictionary<string, int> ReadFlags(BinaryReader r)
    {
        int n = r.ReadInt32();
        var d = new Dictionary<string, int>(n);
        for (int i = 0; i < n; i++)
        {
            string k = ReadString(r);
            int v = r.ReadInt32();
            d[k] = v;
        }
        return d;
    }

    private static void WriteString(BinaryWriter w, string s)
    {
        var bytes = Encoding.UTF8.GetBytes(s ?? "");
        w.Write(bytes.Length);
        w.Write(bytes);
    }

    private static string ReadString(BinaryReader r)
    {
        int n = r.ReadInt32();
        return Encoding.UTF8.GetString(r.ReadBytes(n));
    }

    // ── Phase 5: Character section (TAG_CHARACTER = 100) ─────────────────

    private static void WriteCharacter(BinaryWriter w, PlayerCharacterState c)
    {
        WriteString(w, c.CladeId);
        WriteString(w, c.SpeciesId);
        WriteString(w, c.ClassId);
        WriteString(w, c.BackgroundId);
        w.Write(c.STR); w.Write(c.DEX); w.Write(c.CON); w.Write(c.INT); w.Write(c.WIS); w.Write(c.CHA);
        w.Write(c.Level);
        w.Write(c.Xp);
        w.Write(c.MaxHp);
        w.Write(c.CurrentHp);
        w.Write(c.ExhaustionLevel);
        w.Write(c.SkillProficiencies.Length);
        foreach (var s in c.SkillProficiencies) w.Write(s);
        w.Write(c.Conditions.Length);
        foreach (var x in c.Conditions) w.Write(x);
        w.Write(c.Inventory.Length);
        foreach (var it in c.Inventory)
        {
            WriteString(w, it.ItemId);
            w.Write(it.Qty);
            w.Write(it.Condition);
            w.Write(it.EquippedAt.HasValue);
            if (it.EquippedAt.HasValue) w.Write(it.EquippedAt.Value);
        }
        // Phase 5 M6 additions — appended at the end so v5 readers (without
        // these fields) can still load by short-reading the section.
        WriteString(w, c.FightingStyle);
        w.Write(c.RageUsesRemaining);
        // Phase 6 M3 — currency. Same EOS-check pattern: older saves
        // without it short-read this section.
        w.Write(c.CurrencyFang);
        // Phase 6.5 M0 — subclass + learned features + level-up history.
        WriteString(w, c.SubclassId);
        w.Write(c.LearnedFeatureIds.Length);
        foreach (var fid in c.LearnedFeatureIds) WriteString(w, fid);
        w.Write(c.LevelUpHistory.Length);
        foreach (var h in c.LevelUpHistory)
        {
            w.Write(h.Level);
            w.Write(h.HpGained);
            w.Write(h.HpWasAveraged);
            w.Write(h.HpHitDieResult);
            WriteString(w, h.SubclassChosen ?? "");
            w.Write(h.AsiKeys.Length);
            foreach (byte k in h.AsiKeys) w.Write(k);
            w.Write(h.AsiValues.Length);
            foreach (int v in h.AsiValues) w.Write(v);
            w.Write(h.FeaturesUnlocked.Length);
            foreach (var fid in h.FeaturesUnlocked) WriteString(w, fid);
        }
        // Phase 6.5 M4 — hybrid state (optional). EOS-check pattern: write
        // a presence byte (0/1), then the fields when present.
        // Phase 6.5 M5 appends ActiveMaskTier (also EOS-checked on read).
        bool hybridPresent = c.Hybrid is not null;
        w.Write(hybridPresent);
        if (hybridPresent)
        {
            var h = c.Hybrid!;
            WriteString(w, h.SireClade);
            WriteString(w, h.SireSpecies);
            WriteString(w, h.DamClade);
            WriteString(w, h.DamSpecies);
            w.Write(h.DominantParent);
            w.Write(h.PassingActive);
            w.Write(h.NpcsWhoKnow.Length);
            foreach (int npcId in h.NpcsWhoKnow) w.Write(npcId);
            // Phase 6.5 M5 — mask tier (single byte).
            w.Write(h.ActiveMaskTier);
        }
    }

    private static PlayerCharacterState ReadCharacter(BinaryReader r)
    {
        var c = new PlayerCharacterState
        {
            CladeId      = ReadString(r),
            SpeciesId    = ReadString(r),
            ClassId      = ReadString(r),
            BackgroundId = ReadString(r),
            STR = r.ReadByte(), DEX = r.ReadByte(), CON = r.ReadByte(),
            INT = r.ReadByte(), WIS = r.ReadByte(), CHA = r.ReadByte(),
            Level           = r.ReadInt32(),
            Xp              = r.ReadInt32(),
            MaxHp           = r.ReadInt32(),
            CurrentHp       = r.ReadInt32(),
            ExhaustionLevel = r.ReadInt32(),
        };
        int nSkills = r.ReadInt32();
        c.SkillProficiencies = new byte[nSkills];
        for (int i = 0; i < nSkills; i++) c.SkillProficiencies[i] = r.ReadByte();
        int nConds = r.ReadInt32();
        c.Conditions = new byte[nConds];
        for (int i = 0; i < nConds; i++) c.Conditions[i] = r.ReadByte();
        int nInv = r.ReadInt32();
        c.Inventory = new InventoryItemState[nInv];
        for (int i = 0; i < nInv; i++)
        {
            var it = new InventoryItemState
            {
                ItemId    = ReadString(r),
                Qty       = r.ReadInt32(),
                Condition = r.ReadInt32(),
            };
            bool hasEquip = r.ReadBoolean();
            it.EquippedAt = hasEquip ? r.ReadByte() : null;
            c.Inventory[i] = it;
        }
        // Phase 5 M6 additions — present in saves written by M6+, absent in v5
        // saves. The section is length-prefixed so v5 saves stop here.
        if (r.BaseStream.Position < r.BaseStream.Length)
        {
            c.FightingStyle     = ReadString(r);
            c.RageUsesRemaining = r.ReadInt32();
        }
        // Phase 6 M3 addition — currency. Saves without it short-read here.
        if (r.BaseStream.Position < r.BaseStream.Length)
        {
            c.CurrencyFang = r.ReadInt32();
        }
        // Phase 6.5 M0 additions — subclass / features / level-up history.
        // Same EOS-check pattern: v6 saves without these fields short-read.
        if (r.BaseStream.Position < r.BaseStream.Length)
        {
            c.SubclassId = ReadString(r);
            int nFeatures = r.ReadInt32();
            var features = new string[nFeatures];
            for (int i = 0; i < nFeatures; i++) features[i] = ReadString(r);
            c.LearnedFeatureIds = features;
            int nHistory = r.ReadInt32();
            var history = new LevelUpRecordState[nHistory];
            for (int i = 0; i < nHistory; i++)
            {
                var h = new LevelUpRecordState
                {
                    Level          = r.ReadInt32(),
                    HpGained       = r.ReadInt32(),
                    HpWasAveraged  = r.ReadBoolean(),
                    HpHitDieResult = r.ReadInt32(),
                    SubclassChosen = ReadString(r),
                };
                int nKeys = r.ReadInt32();
                var keys = new byte[nKeys];
                for (int j = 0; j < nKeys; j++) keys[j] = r.ReadByte();
                h.AsiKeys = keys;
                int nVals = r.ReadInt32();
                var vals = new int[nVals];
                for (int j = 0; j < nVals; j++) vals[j] = r.ReadInt32();
                h.AsiValues = vals;
                int nFids = r.ReadInt32();
                var fids = new string[nFids];
                for (int j = 0; j < nFids; j++) fids[j] = ReadString(r);
                h.FeaturesUnlocked = fids;
                history[i] = h;
            }
            c.LevelUpHistory = history;
        }
        // Phase 6.5 M4 — hybrid state (optional, EOS-checked).
        if (r.BaseStream.Position < r.BaseStream.Length)
        {
            bool hybridPresent = r.ReadBoolean();
            if (hybridPresent)
            {
                var h = new HybridStateSnapshot
                {
                    SireClade      = ReadString(r),
                    SireSpecies    = ReadString(r),
                    DamClade       = ReadString(r),
                    DamSpecies     = ReadString(r),
                    DominantParent = r.ReadByte(),
                    PassingActive  = r.ReadBoolean(),
                };
                int nKnow = r.ReadInt32();
                var knowList = new int[nKnow];
                for (int i = 0; i < nKnow; i++) knowList[i] = r.ReadInt32();
                h.NpcsWhoKnow = knowList;
                // Phase 6.5 M5 — mask tier (EOS-checked).
                if (r.BaseStream.Position < r.BaseStream.Length)
                    h.ActiveMaskTier = r.ReadByte();
                c.Hybrid = h;
            }
        }
        return c;
    }

    // ── Phase 5 M5: NPC roster + Encounter sections ───────────────────────

    private static void WriteNpcRoster(BinaryWriter w, NpcRosterState roster)
    {
        w.Write(roster.ChunkDeltas.Count);
        foreach (var d in roster.ChunkDeltas)
        {
            w.Write(d.ChunkX);
            w.Write(d.ChunkY);
            w.Write(d.KilledSpawnIndices.Length);
            foreach (int idx in d.KilledSpawnIndices) w.Write(idx);
        }
    }

    private static NpcRosterState ReadNpcRoster(BinaryReader r)
    {
        var roster = new NpcRosterState();
        int n = r.ReadInt32();
        for (int i = 0; i < n; i++)
        {
            var d = new NpcChunkDelta { ChunkX = r.ReadInt32(), ChunkY = r.ReadInt32() };
            int m = r.ReadInt32();
            d.KilledSpawnIndices = new int[m];
            for (int j = 0; j < m; j++) d.KilledSpawnIndices[j] = r.ReadInt32();
            roster.ChunkDeltas.Add(d);
        }
        return roster;
    }

    private static void WriteEncounter(BinaryWriter w, EncounterState enc)
    {
        w.Write(enc.EncounterId);
        w.Write(enc.RollCount);
        w.Write(enc.CurrentTurnIndex);
        w.Write(enc.RoundNumber);
        w.Write(enc.InitiativeOrder.Length);
        foreach (int i in enc.InitiativeOrder) w.Write(i);
        w.Write(enc.Combatants.Length);
        foreach (var c in enc.Combatants)
        {
            w.Write(c.Id);
            WriteString(w, c.Name);
            w.Write(c.IsPlayer);
            w.Write(c.NpcChunkX.HasValue);
            if (c.NpcChunkX.HasValue) w.Write(c.NpcChunkX.Value);
            w.Write(c.NpcChunkY.HasValue);
            if (c.NpcChunkY.HasValue) w.Write(c.NpcChunkY.Value);
            w.Write(c.NpcSpawnIndex.HasValue);
            if (c.NpcSpawnIndex.HasValue) w.Write(c.NpcSpawnIndex.Value);
            WriteString(w, c.NpcTemplateId);
            w.Write(c.CurrentHp);
            w.Write(c.PositionX);
            w.Write(c.PositionY);
            w.Write(c.Conditions.Length);
            foreach (byte cb in c.Conditions) w.Write(cb);
        }
    }

    private static EncounterState ReadEncounter(BinaryReader r)
    {
        var s = new EncounterState
        {
            EncounterId      = r.ReadUInt64(),
            RollCount        = r.ReadInt32(),
            CurrentTurnIndex = r.ReadInt32(),
            RoundNumber      = r.ReadInt32(),
        };
        int n = r.ReadInt32();
        s.InitiativeOrder = new int[n];
        for (int i = 0; i < n; i++) s.InitiativeOrder[i] = r.ReadInt32();
        int m = r.ReadInt32();
        s.Combatants = new CombatantSnapshot[m];
        for (int i = 0; i < m; i++)
        {
            var c = new CombatantSnapshot
            {
                Id        = r.ReadInt32(),
                Name      = ReadString(r),
                IsPlayer  = r.ReadBoolean(),
            };
            if (r.ReadBoolean()) c.NpcChunkX = r.ReadInt32();
            if (r.ReadBoolean()) c.NpcChunkY = r.ReadInt32();
            if (r.ReadBoolean()) c.NpcSpawnIndex = r.ReadInt32();
            c.NpcTemplateId = ReadString(r);
            c.CurrentHp     = r.ReadInt32();
            c.PositionX     = r.ReadSingle();
            c.PositionY     = r.ReadSingle();
            int cn = r.ReadInt32();
            c.Conditions = new byte[cn];
            for (int j = 0; j < cn; j++) c.Conditions[j] = r.ReadByte();
            s.Combatants[i] = c;
        }
        return s;
    }

    // ── Phase 5: Schema-version compatibility ─────────────────────────────

    /// <summary>
    /// Returns true if this binary can load the supplied header's save data.
    /// Phase 5 refuses any save with version &lt; <see cref="C.SAVE_SCHEMA_MIN_VERSION"/>
    /// (i.e. Phase-4 saves with no character data).
    /// </summary>
    public static bool IsCompatible(SaveHeader header) =>
        header.Version >= C.SAVE_SCHEMA_MIN_VERSION;

    /// <summary>Human-readable reason a save is incompatible. Empty when compatible.</summary>
    public static string IncompatibilityReason(SaveHeader header)
    {
        if (header.Version < C.SAVE_SCHEMA_MIN_VERSION)
            return $"This save is from schema v{header.Version}; minimum supported is v{C.SAVE_SCHEMA_MIN_VERSION}. " +
                   "Start a new game from the same seed to play in Phase 5.";
        return "";
    }

    // ── Phase 6 M2 — reputation ──────────────────────────────────────────

    private static void WriteFactionStandings(BinaryWriter w, Dictionary<string, int> standings)
    {
        w.Write(standings.Count);
        foreach (var kv in standings)
        {
            WriteString(w, kv.Key);
            w.Write(kv.Value);
        }
    }

    private static Dictionary<string, int> ReadFactionStandings(BinaryReader r)
    {
        int n = r.ReadInt32();
        var d = new Dictionary<string, int>(n, StringComparer.OrdinalIgnoreCase);
        for (int i = 0; i < n; i++)
        {
            string k = ReadString(r);
            int    v = r.ReadInt32();
            d[k] = v;
        }
        return d;
    }

    private static void WriteReputation(BinaryWriter w, ReputationSnapshot rep)
    {
        // Personal records.
        w.Write(rep.Personal.Count);
        foreach (var p in rep.Personal)
        {
            WriteString(w, p.RoleTag);
            w.Write(p.Score);
            w.Write(p.Trust);
            w.Write(p.Betrayed);
            w.Write(p.LastInteractionSeconds);
            w.Write(p.MemoryTags.Length);
            foreach (var m in p.MemoryTags) WriteString(w, m);
            w.Write(p.Log.Length);
            foreach (var ev in p.Log) WriteRepEvent(w, ev);
        }
        // Ledger.
        w.Write(rep.Ledger.Count);
        foreach (var ev in rep.Ledger) WriteRepEvent(w, ev);
    }

    private static ReputationSnapshot ReadReputation(BinaryReader r)
    {
        var rep = new ReputationSnapshot();
        int n = r.ReadInt32();
        for (int i = 0; i < n; i++)
        {
            var p = new PersonalDispositionSnapshot
            {
                RoleTag                = ReadString(r),
                Score                  = r.ReadInt32(),
                Trust                  = r.ReadByte(),
                Betrayed               = r.ReadBoolean(),
                LastInteractionSeconds = r.ReadInt64(),
            };
            int mtags = r.ReadInt32();
            var memory = new string[mtags];
            for (int j = 0; j < mtags; j++) memory[j] = ReadString(r);
            p.MemoryTags = memory;
            int logCount = r.ReadInt32();
            var log = new RepEventSnapshot[logCount];
            for (int j = 0; j < logCount; j++) log[j] = ReadRepEvent(r);
            p.Log = log;
            rep.Personal.Add(p);
        }
        int ledgerCount = r.ReadInt32();
        for (int i = 0; i < ledgerCount; i++) rep.Ledger.Add(ReadRepEvent(r));
        return rep;
    }

    private static void WriteRepEvent(BinaryWriter w, RepEventSnapshot ev)
    {
        w.Write(ev.SequenceId);
        w.Write(ev.Kind);
        WriteString(w, ev.FactionId);
        WriteString(w, ev.RoleTag);
        w.Write(ev.Magnitude);
        WriteString(w, ev.Note);
        w.Write(ev.OriginTileX);
        w.Write(ev.OriginTileY);
        w.Write(ev.TimestampSeconds);
    }

    private static RepEventSnapshot ReadRepEvent(BinaryReader r) => new()
    {
        SequenceId       = r.ReadInt32(),
        Kind             = r.ReadByte(),
        FactionId        = ReadString(r),
        RoleTag          = ReadString(r),
        Magnitude        = r.ReadInt32(),
        Note             = ReadString(r),
        OriginTileX      = r.ReadInt32(),
        OriginTileY      = r.ReadInt32(),
        TimestampSeconds = r.ReadInt64(),
    };

    // ── Phase 6 M4 — quest engine ────────────────────────────────────────

    private static void WriteQuests(BinaryWriter w, QuestSnapshot snap)
    {
        w.Write(snap.Active.Count);
        foreach (var s in snap.Active) WriteQuestState(w, s);
        w.Write(snap.Completed.Count);
        foreach (var s in snap.Completed) WriteQuestState(w, s);
        w.Write(snap.Journal.Count);
        foreach (var line in snap.Journal) WriteString(w, line);
    }

    private static QuestSnapshot ReadQuests(BinaryReader r)
    {
        var snap = new QuestSnapshot();
        int activeCount = r.ReadInt32();
        for (int i = 0; i < activeCount; i++) snap.Active.Add(ReadQuestState(r));
        int completedCount = r.ReadInt32();
        for (int i = 0; i < completedCount; i++) snap.Completed.Add(ReadQuestState(r));
        int journalCount = r.ReadInt32();
        for (int i = 0; i < journalCount; i++) snap.Journal.Add(ReadString(r));
        return snap;
    }

    private static void WriteQuestState(BinaryWriter w, QuestStateSnapshot s)
    {
        WriteString(w, s.QuestId);
        WriteString(w, s.CurrentStep);
        w.Write(s.Status);
        w.Write(s.StartedAt);
        w.Write(s.StepStartedAt);
        w.Write(s.JournalLines.Length);
        foreach (var line in s.JournalLines) WriteString(w, line);
    }

    private static QuestStateSnapshot ReadQuestState(BinaryReader r)
    {
        var s = new QuestStateSnapshot
        {
            QuestId       = ReadString(r),
            CurrentStep   = ReadString(r),
            Status        = r.ReadByte(),
            StartedAt     = r.ReadInt64(),
            StepStartedAt = r.ReadInt64(),
        };
        int n = r.ReadInt32();
        var lines = new string[n];
        for (int i = 0; i < n; i++) lines[i] = ReadString(r);
        s.JournalLines = lines;
        return s;
    }
}
