"""Comprehensive KOTOR Save Game Handler.

This module provides an exhaustive implementation for reading and writing KOTOR 1 & 2 save games.
It has been enhanced with knowledge from multiple implementations including:

VENDOR IMPLEMENTATIONS ANALYZED:
================================
1. KotOR-Save-Editor (Perl) - https://github.com/th3w1zard1/KotOR-Save-Editor/tree/master/
   - site/lib/KSE/Functions/Saves.pm - Main save handling
   - site/lib/KSE/Functions/Globals.pm - Global variables with bit packing
   - site/lib/KSE/Functions/Journal.pm - Journal entry management
   - site/lib/KSE/Functions/Inventory.pm - Inventory handling
   - site/lib/KSE/Functions/NPC.pm - Character data management

2. kotor-savegame-editor (Perl) - https://github.com/th3w1zard1/kotor-savegame-editor/tree/master/
   - lib/Bioware/ERF.pm - Low-level ERF binary handling
   - lib/Bioware/GFF.pm - Low-level GFF binary handling

3. KotOR_IO (C#) - https://github.com/th3w1zard1/KotOR_IO/tree/master/
   - File Formats/ERF.cs - Object-oriented ERF implementation
   - File Formats/GFF.cs - Object-oriented GFF implementation

4. KotOR.js (TypeScript) - https://github.com/th3w1zard1/KotOR.js/tree/master/
   - src/SaveGame.ts - Modern async save game handling
   - Handles FILETIME timestamps, galaxy map, PIFO.ifo (K2)

5. xoreos-tools (C++) - (Engine-related, removed vendor reference)
   - Cross-platform binary data handling
   - Resource type definitions

6. reone (C++) -
   - Game engine perspective on save handling
   - Resource management and performance optimizations

REAL-WORLD ENGINE IMPLEMENTATIONS:
==================================
- **Original Engine (BioWare's Odyssey Engine):**
  - Saves are folder-based (unlike modules which are single files)
  - Uses GFF format for structured data, ERF for archives
  - Boolean packing for memory efficiency (8 per byte)
  - Location storage with padding for alignment

- **reone (C++ reimplementation):**
  - Focuses on efficient binary reading/writing
  - Caches parsed GFF structures for performance
  - Validates save data integrity on load

- **KotOR.js (TypeScript browser implementation):**
  - Async/await patterns for non-blocking I/O
  - Browser-based file system APIs
  - Memory-efficient streaming for large saves

- **xoreos (C++ cross-platform engine):**
  - Platform-independent save handling
  - Endianness-aware binary operations
  - Comprehensive error handling and validation

KOTOR Save Game Structure:
==========================

A KOTOR save game consists of multiple files in a folder (e.g., "000057 - game56"):

1. SAVENFO.res - Save metadata for the load/save menu
   - Save name, area name, last module
   - Time played, timestamp
   - Party portraits (for menu display)
   - Hints (gameplay and story)
   - Cheat usage flags
   - K2: PC name

2. GLOBALVARS.res - Global script variables
   - Boolean variables (packed as bits)
   - Number variables (bytes)
   - String variables
   - Location variables (position + orientation)

3. PARTYTABLE.res - Party and game state
   - Party composition and member data
   - Available NPCs list
   - Credits/gold and XP pool
   - Journal entries
   - Pazaak cards and decks
   - AI/follow states
   - Tutorial windows shown
   - Recent messages
   - K2: Influence values, components, chemicals

4. SAVEGAME.sav - Nested ERF archive containing:
   - Cached modules (.sav files) from previously visited areas
   - AVAILNPC*.utc files (companion character templates, 0-12)
   - INVENTORY.res (player inventory)
   - REPUTE.fac (faction reputation)
   - Module-specific resources
   - PIFO.ifo (optional, K2 only)

5. Screen.tga - Save game screenshot thumbnail

Character Data (UTC) Structure:
===============================
Characters (Player and NPCs) contain:
- Basic info: FirstName, Gender
- Attributes: STR, DEX, CON, INT, WIS, CHA
- Health/Force: HitPoints, MaxHitPoints, ForcePoints, MaxForcePoints
- Character data: Experience, GoodEvil (alignment), Min1HP
- Appearance: Appearance_Type, PortraitId, SoundSetFile, Race
- Classes: ClassList (with levels and known powers/spells)
- Abilities: FeatList, SkillList (8 skills)
- Equipment: Equip_ItemList (equipment slots)
- K2: Influence value

Equipment Slots:
===============
- Head (0x00001)
- Armor/Body (0x00002)
- Gloves (0x00008)
- Weapon (0x00010 or 0x00030)
- Arm bands (0x00180)
- Implant (0x00200 or 0x00208)
- Belt (0x00400)

Skills (in order):
=================
0. Computer Use
1. Demolitions
2. Stealth
3. Awareness
4. Persuade
5. Repair
6. Security
7. Treat Injury

Journal Entry:
=============
- JNL_PlotID - Plot identifier
- JNL_State - Current state of the quest
- JNL_Date - Date the entry was added
- JNL_Time - Time played when entry was added

Common Issues and Fixes:
========================
1. EventQueue Corruption: Cached modules in SAVEGAME.sav can have corrupt EventQueue
   lists in their module.ifo files. Clearing these can fix load issues.

2. Portrait Order: The order of PORTRAIT0-2 in SAVENFO.res depends on which party
   member is the leader.

3. Boolean Packing: Global booleans are stored as packed bits (8 per byte, LSB first).

4. Location Storage: Locations use 12 floats per entry (x,y,z, ori_x,ori_y,ori_z,
   plus 6 padding floats), with exactly 50 slots allocated.

Usage Example:
=============
```python
from pykotor.extract.savedata import SaveFolderEntry

# Load a save
save = SaveFolderEntry(r"C:\\...\\saves\\000057 - game56")
save.load()

# Access data
print(f"Save Name: {save.save_info.savegame_name}")
print(f"Credits: {save.partytable.pt_gold}")
print(f"Time Played: {save.save_info.time_played}")

# Modify and save
save.partytable.pt_gold = 999999
save.partytable.save()
```

Notes:
=====
- K1 and K2 have slightly different structures (K2 adds influence, components, etc.)
- Some fields are platform-specific (Xbox Live content on Xbox)
- Save corruption can occur from improper EventQueue handling
- Always backup saves before modification

Author: Enhanced by comprehensive vendor code analysis
"""

from __future__ import annotations

from copy import deepcopy
from typing import TYPE_CHECKING, Any, cast

from pykotor.common.misc import Game, ResRef
from pykotor.common.stream import BinaryReader
from pykotor.extract.capsule import Capsule
from pykotor.extract.file import ResourceIdentifier
from pykotor.resource.formats.erf import ERFType
from pykotor.resource.formats.erf.erf_data import ERF
from pykotor.resource.formats.gff import GFF, GFFFieldType, GFFList, GFFStruct, bytes_gff, read_gff
from pykotor.resource.type import ResourceType
from pykotor.tools.path import CaseAwarePath
from utility.common.geometry import Vector3, Vector4

if TYPE_CHECKING:
    import os

    from pykotor.common.module import Module
    from pykotor.resource.formats.gff import GFF
    from pykotor.resource.generics.utc import UTC
    from pykotor.resource.generics.uti import UTI


class SaveInfo:
    """SAVENFO.res - Save information resource.

    VENDOR REFERENCES:
    ==================
    - KotOR-Save-Editor: site/lib/KSE/Functions/Saves.pm (LoadSave sub, lines ~150-200)
      - Reads SAVENFO fields: SAVEGAMENAME, AREANAME, LASTMODULE, TIMEPLAYED
      - Handles portrait order logic based on party leader

    - KotOR.js: src/SaveGame.ts (loadNFO method, lines ~50-120)
      - Async loading with Promise-based file reading
      - Handles TIMESTAMP as Windows FILETIME (64-bit)
      - Xbox Live fields for Xbox version compatibility
      - K2-specific: PCNAME field

    - KotOR_IO: Reads GFF fields directly using C# GFF parser

    ENGINE BEHAVIOR:
    ================
    - **Original Engine:** Displays save name, area, and time in load menu
      - Portraits show current party composition (leader + 2 companions)
      - Portrait order: PORTRAIT0=leader, PORTRAIT1/2=companions
      - If NPC is leader, portraits rotate accordingly

    - **reone:** Caches parsed SAVENFO in memory for quick save list generation
      - Validates LASTMODULE exists before allowing load

    - **KotOR.js:** Renders save thumbnails (Screen.tga) alongside SAVENFO data
      - Async loading allows UI to remain responsive during scan

    STRUCTURE:
    ==========
    Contains metadata about the save game including:
    - Save name and area information (for save menu display)
    - Time played and timestamps (for sorting, display)
    - Party portraits (leader + 2 companions, for visual identification)
    - Hints (loading screen tips, story/gameplay)
    - Xbox Live data (Xbox version only)
    - Cheat usage flags (affects achievements)
    - K2-specific: PC name (additional identification)

    This information is read when:
    - Displaying the load/save game menu
    - Sorting saves by date/time
    - Checking save validity before loading
    - Generating save thumbnails/previews

    FILE FORMAT: GFF (Generic File Format) with GFFContent.NFO type
    """

    IDENTIFIER: ResourceIdentifier = ResourceIdentifier(resname="savenfo", restype=ResourceType.RES)

    def __init__(self, path: os.PathLike | str, ident: ResourceIdentifier | None = None):
        """Initialize SaveInfo for a save folder.

        Args:
        ----
            path: Path to save folder (e.g., "000057 - game56")
            ident: Optional custom identifier (defaults to "savenfo.res")

        Vendor Implementation:
        ---------------------
        - KotOR-Save-Editor: Stores in %data hash, accessed via GetData('CurrentSave', 'NFO', field)
        - KotOR.js: Stores as SaveGame.nfo object with async accessors
        """
        ident = self.IDENTIFIER if ident is None else ident
        self.save_info_path: CaseAwarePath = CaseAwarePath(path) / str(ident)

        # Core save information
        # Vendor ref: KSE/Functions/Saves.pm lines ~170-180
        self.area_name: str = ""  # AREANAME (CExoString) - Display name of last area
        # Example: "Ebon Hawk", "Dantooine - Jedi Enclave"
        # Engine: Used in save menu second line

        self.last_module: str = ""  # LASTMODULE (CExoString) - Last module ResRef played
        # Example: "danm13", "ebo_m12aa", "003ebo"
        # Engine: Validated on load to ensure module exists
        # Vendor ref: KotOR.js SaveGame.ts line ~75

        self.savegame_name: str = ""  # SAVEGAMENAME (CExoString) - User-entered save name
        # Example: "Before Confronting Malak", "Quick Save"
        # Engine: Displayed as primary text in save menu
        # Max length: ~50 characters (UI truncates longer)

        self.time_played: int = 0  # TIMEPLAYED (DWORD) - Total gameplay time in seconds
        # Engine: Displayed as HH:MM in save menu
        # Vendor ref: All implementations read/write this

        self.timestamp: int | None = None  # TIMESTAMP (QWORD, optional) - Windows FILETIME
        # Format: 100-nanosecond intervals since Jan 1, 1601
        # K2 uses this, K1 sometimes omits it
        # Vendor ref: KotOR.js handles conversion to JavaScript Date
        # Engine: Used for save sorting by date

        # Cheat tracking
        # Vendor ref: KSE/Functions/Saves.pm line ~195
        self.cheat_used: bool = False  # CHEATUSED (BYTE, 0 or 1) - Set if cheats were used
        # Engine: May disable achievements on some platforms
        # Set by: Using console commands, debug mode

        # Hints for loading screens
        # Vendor ref: KotOR.js SaveGame.ts line ~85-90
        self.gameplay_hint: int = 0  # GAMEPLAYHINT (BYTE) - Strref to loadscreenhints.2da
        # Engine: Randomly selected gameplay tip during loading
        # Range: 0-255 (indexes into hints table)

        self.story_hint: int = 0  # STORYHINT (BYTE) - Strref to story-related hint
        # Engine: Story-specific tip shown during loading
        # Usually relates to current planet/quest

        # Party portraits (shown in save menu)
        # Vendor ref: KSE/Functions/Saves.pm has portrait rotation logic
        # Engine behavior: Portrait order depends on party leader
        self.portrait0: ResRef = ResRef.from_blank()  # PORTRAIT0 (ResRef) - First portrait
        # Usually the party leader
        # Example: "po_player", "po_bastila"
        # Engine: Displayed leftmost in save menu

        self.portrait1: ResRef = ResRef.from_blank()  # PORTRAIT1 (ResRef) - Second portrait
        # First companion (if player is leader)
        # Example: "po_bastila", "po_carth"

        self.portrait2: ResRef = ResRef.from_blank()  # PORTRAIT2 (ResRef) - Third portrait
        # Second companion (if player is leader)
        # Example: "po_mission", "po_hk47"
        # NOTE: If NPC is leader, order rotates

        # Xbox Live content (Xbox version only)
        # Vendor ref: KotOR.js handles these for Xbox compatibility
        # PC versions usually leave these blank
        self.live1: str = ""  # LIVE1 (CExoString) - Xbox Live content ID 1
        self.live2: str = ""  # LIVE2 (CExoString) - Xbox Live content ID 2
        self.live3: str = ""  # LIVE3 (CExoString) - Xbox Live content ID 3
        self.live4: str = ""  # LIVE4 (CExoString) - Xbox Live content ID 4
        self.live5: str = ""  # LIVE5 (CExoString) - Xbox Live content ID 5
        self.live6: str = ""  # LIVE6 (CExoString) - Xbox Live content ID 6
        self.livecontent: int = 0  # LIVECONTENT (BYTE) - Xbox Live content flags
        # Bitfield for downloaded content availability

        # K2-specific fields
        # Vendor ref: KotOR.js checks game version before reading PCNAME
        self.pc_name: str = ""  # PCNAME (CExoString, K2 only) - Player character first name
        # Example: "Meetra", "The Exile"
        # K2 Engine: Displayed in save menu for quick identification
        # K1: Field doesn't exist (will be ignored)

        # All other fields preserved verbatim for full fidelity editing
        # Stored as mapping of label -> (field_type, deep-copied value)
        # Allows Save Editor to surface and modify any additional data
        self.additional_fields: dict[str, tuple[GFFFieldType, Any]] = {}
        self._existing_fields: set[str] = set()

    def load(self):
        """Load SAVENFO.res data from the save folder.

        VENDOR IMPLEMENTATIONS:
        =======================
        - KSE/Functions/Saves.pm: LoadSave() reads fields using GFF2::Field::getvalue()
        - KotOR.js: Async loadNFO() with FileSystemAPI
        - reone: Loads and caches GFF structure for quick access

        ENGINE BEHAVIOR:
        ================
        - File is loaded when displaying save menu
        - Engine validates module exists before allowing load
        - Missing fields use defaults (empty strings, 0)

        PROCESS:
        ========
        1. Read GFF file from save folder
        2. Extract all fields from root struct
        3. Use acquire() for safe field access (returns default if missing)
        4. Handle optional fields (TIMESTAMP, PCNAME)
        """
        from pykotor.resource.formats.gff import read_gff

        gff = read_gff(self.save_info_path)
        self._load_from_root(gff.root)

    def _load_from_root(self, root: GFFStruct):
        """Load SaveInfo state from a GFF root struct (used by load() and load_from_gff_bytes())."""
        from copy import deepcopy

        processed_fields: set[str] = set()

        def _acquire(label: str, default: Any) -> Any:
            if root.exists(label):
                processed_fields.add(label)
            return root.acquire(label, default)

        # Core information
        self.area_name = _acquire("AREANAME", "")
        self.last_module = _acquire("LASTMODULE", "")
        self.savegame_name = _acquire("SAVEGAMENAME", "")
        self.time_played = _acquire("TIMEPLAYED", 0)
        self.timestamp = _acquire("TIMESTAMP", None)

        # Cheats and hints
        self.cheat_used = bool(_acquire("CHEATUSED", 0))
        self.gameplay_hint = _acquire("GAMEPLAYHINT", 0)
        self.story_hint = _acquire("STORYHINT", 0)

        # Portraits
        self.portrait0 = _acquire("PORTRAIT0", ResRef.from_blank())
        self.portrait1 = _acquire("PORTRAIT1", ResRef.from_blank())
        self.portrait2 = _acquire("PORTRAIT2", ResRef.from_blank())

        # Xbox Live
        self.live1 = _acquire("LIVE1", "")
        self.live2 = _acquire("LIVE2", "")
        self.live3 = _acquire("LIVE3", "")
        self.live4 = _acquire("LIVE4", "")
        self.live5 = _acquire("LIVE5", "")
        self.live6 = _acquire("LIVE6", "")
        self.livecontent = _acquire("LIVECONTENT", 0)

        # K2-specific
        self.pc_name = _acquire("PCNAME", "")

        # Capture any additional/unknown fields for full fidelity editing
        self._existing_fields = set(processed_fields)
        self.additional_fields = {}
        for label, field_type, value in root:
            if label not in processed_fields:
                self.additional_fields[label] = (field_type, deepcopy(value))

    def load_from_gff_bytes(self, data: bytes) -> None:
        """Update SaveInfo from GFF bytes (e.g. after editing in GFF editor)."""
        from io import BytesIO

        from pykotor.resource.formats.gff import read_gff

        gff = read_gff(BytesIO(data))
        self._load_from_root(gff.root)

    def to_gff_bytes(self) -> bytes:
        """Return current SaveInfo state as GFF bytes (same structure as save())."""
        return bytes_gff(self._build_gff())

    def save(self):
        """Save SAVENFO.res data to the save folder.

        VENDOR IMPLEMENTATIONS:
        =======================
        - KSE/Functions/Saves.pm: SaveSave() writes fields using GFF2::addField()
        - KotOR.js: ExportSaveNFO() creates new GFF and writes asynchronously
        - reone: Serializes cached GFF structure back to binary

        ENGINE BEHAVIOR:
        ================
        - File is written when saving game
        - Atomic write (temp file + rename) to prevent corruption
        - Validates all required fields are present

        PROCESS:
        ========
        1. Create new GFF with GFFContent.NFO type
        2. Set all fields on root struct
        3. Optional fields only written if non-default
        4. Write GFF to disk atomically

        FIELD TYPES:
        ============
        - Strings: set_string() for AREANAME, LASTMODULE, SAVEGAMENAME, etc.
        - Integers: set_uint32() for TIMEPLAYED, set_uint64() for TIMESTAMP
        - Bytes: set_uint8() for CHEATUSED, hints, LIVECONTENT
        - ResRefs: set_resref() for PORTRAIT0/1/2
        """
        from pykotor.resource.formats.gff import write_gff

        write_gff(self._build_gff(), self.save_info_path)

    def _build_gff(self) -> GFF:
        """Build GFF from current SaveInfo state (used by save() and to_gff_bytes())."""
        from pykotor.resource.formats.gff import GFF, GFFContent

        gff = GFF(GFFContent.NFO)
        root = gff.root

        # Core information
        root.set_string("AREANAME", self.area_name)
        root.set_string("LASTMODULE", self.last_module)
        root.set_string("SAVEGAMENAME", self.savegame_name)
        root.set_uint32("TIMEPLAYED", self.time_played)
        if self.timestamp is not None:
            root.set_uint64("TIMESTAMP", self.timestamp)

        # Cheats and hints
        root.set_uint8("CHEATUSED", 1 if self.cheat_used else 0)
        root.set_uint8("GAMEPLAYHINT", self.gameplay_hint)
        root.set_uint8("STORYHINT", self.story_hint)

        # Portraits
        root.set_resref("PORTRAIT0", self.portrait0)
        root.set_resref("PORTRAIT1", self.portrait1)
        root.set_resref("PORTRAIT2", self.portrait2)

        # Xbox Live
        root.set_string("LIVE1", self.live1)
        root.set_string("LIVE2", self.live2)
        root.set_string("LIVE3", self.live3)
        root.set_string("LIVE4", self.live4)
        root.set_string("LIVE5", self.live5)
        root.set_string("LIVE6", self.live6)
        root.set_uint8("LIVECONTENT", self.livecontent)

        # K2-specific
        if self.pc_name:
            root.set_string("PCNAME", self.pc_name)

        # Additional fields - ensure nothing is lost
        def _set_additional_field(label: str, field_type: GFFFieldType, value: Any):
            if field_type == GFFFieldType.UInt8:
                root.set_uint8(label, value)
            elif field_type == GFFFieldType.UInt16:
                root.set_uint16(label, value)
            elif field_type == GFFFieldType.UInt32:
                root.set_uint32(label, value)
            elif field_type == GFFFieldType.UInt64:
                root.set_uint64(label, value)
            elif field_type == GFFFieldType.Int8:
                root.set_int8(label, value)
            elif field_type == GFFFieldType.Int16:
                root.set_int16(label, value)
            elif field_type == GFFFieldType.Int32:
                root.set_int32(label, value)
            elif field_type == GFFFieldType.Int64:
                root.set_int64(label, value)
            elif field_type == GFFFieldType.Single:
                root.set_single(label, value)
            elif field_type == GFFFieldType.Double:
                root.set_double(label, value)
            elif field_type == GFFFieldType.String:
                root.set_string(label, value)
            elif field_type == GFFFieldType.ResRef:
                root.set_resref(label, value)
            elif field_type == GFFFieldType.Binary:
                root.set_binary(label, value)
            elif field_type == GFFFieldType.List:
                root.set_list(label, value)
            elif field_type == GFFFieldType.Struct:
                root.set_struct(label, value)

        for extra_label, (extra_type, extra_value) in self.additional_fields.items():
            _set_additional_field(extra_label, extra_type, extra_value)

        return gff


class JournalEntry:
    """Single journal quest entry.

    VENDOR REF: KSE/Functions/Journal.pm (AssignJRL sub)
    - Reads from both partytable.res (JNL_Entries) and global.jrl
    - Synchronizes quest states between save and global journal

    ENGINE BEHAVIOR:
    - Entries are added when quest updates occur
    - date/time track when entry was added (for journal sorting)
    - state determines which text to display (see global.jrl)
    """

    def __init__(self):
        self.date: int = -1  # JNL_Date (DWORD) - Day count since game start
        # Used for sorting journal by date received

        self.plot_id: str = ""  # JNL_PlotID (CExoString) - Quest identifier
        # Example: "k_main_plot01", "tar_duelring"
        # References entry in global.jrl

        self.state: int = -1  # JNL_State (INT32) - Current quest state
        # Each state has different text in global.jrl
        # -1 = removed/completed, 0+ = active states

        self.time: int = -1  # JNL_Time (DWORD) - Time played when entry added
        # Seconds of gameplay when quest updated
        # Used for precise chronological ordering


class AvailableNPCEntry:
    """Tracks availability of a single NPC companion.

    VENDOR REF: KSE/Functions/NPC.pm
    - PT_AVAIL_NPCS list in PARTYTABLE.res
    - Indexes match AVAILNPC*.utc files in SAVEGAME.sav

    ENGINE BEHAVIOR:
    - npc_available: Set when companion can be recruited
    - npc_selected: Set when companion is chosen for party
    - List typically has 12 entries (K1/K2 both support up to 12 companions)
    """

    def __init__(self):
        self.npc_available: bool = False  # PT_NPC_AVAIL (BYTE, 0/1)
        # True if companion has been recruited/met
        # Example: True after recruiting Bastila

        self.npc_selected: bool = False  # PT_NPC_SELECT (BYTE, 0/1)
        # True if companion is in current party
        # Max 2 can be selected (player + 2 companions)


class PartyMemberEntry:
    """Single member of the current party.

    VENDOR REF: KSE/Functions/Saves.pm (PT_MEMBERS parsing)
    - PT_MEMBERS list in PARTYTABLE.res
    - Usually 1-3 entries (player + up to 2 companions)

    ENGINE BEHAVIOR:
    - is_leader: Determines which character player controls
    - index: References companion in PT_AVAIL_NPCS or AVAILNPC*.utc
    - Order matters for portrait display in save menu
    """

    def __init__(self):
        self.is_leader: bool = False  # PT_IS_LEADER (BYTE, 0/1)
        # True for controlled character
        # Usually player, but can be NPC (solo mode off)

        self.index: int = -1  # PT_MEMBER_ID (INT32)
        # Index into PT_AVAIL_NPCS list
        # -1 = player, 0-11 = companions
        # Example: 0=Bastila, 1=Carth, etc.


class GalaxyMapEntry:
    """Galaxy map planet entry.

    VENDOR REF: KotOR.js handles galaxy map data
    - Complex nested structure with planet states
    - Tracks which planets are accessible
    - K1 vs K2 have different galaxy map structures

    ENGINE BEHAVIOR:
    ================
    - Galaxy map data tracks which planets the player can travel to
    - Each planet has an accessibility state and visited flag
    - Position data for rendering on the galaxy map UI
    - K2 has additional fields for influence and story progression

    NOTE: Galaxy map data is rarely modified programmatically
    Most implementations simply preserve the existing structure
    """

    def __init__(self):
        """Initialize galaxy map entry with default values.

        STRUCTURE: Based on analysis of PARTYTABLE.res galaxy map fields
        """
        self.planet_id: str = ""  # Planet identifier (e.g., "dantooine", "tatooine")
        self.accessible: bool = False  # Can travel to this planet
        self.visited: bool = False  # Has visited before
        self.position_x: float = 0.0  # X coordinate on galaxy map
        self.position_y: float = 0.0  # Y coordinate on galaxy map
        self.icon_index: int = 0  # Icon to display (planet appearance)
        self.name_strref: int = -1  # String reference for planet name

        # K2-specific fields
        self.influence_required: int = 0  # K2: Influence needed to unlock
        self.story_flag: str = ""  # K2: Story flag controlling access


class PartyTable:
    """PARTYTABLE.res - Party and game state information.

    VENDOR REFERENCES:
    ==================
    - KSE/Functions/Saves.pm: Comprehensive PT_* field handling
      - Lines ~300-500: Reads all party table fields
      - Handles journal synchronization with global.jrl

    - KotOR.js: src/managers/PartyTableManager.ts
      - Manages party composition and resources
      - K2-specific influence system handling

    - reone: Caches frequently-accessed fields (gold, XP) for performance

    ENGINE BEHAVIOR:
    ================
    - **Original Engine:** Central hub for party/game state
      - Updated whenever party changes, items bought/sold, quests updated
      - Gold/XP modifications go through this file
      - Journal entries synchronized on quest updates

    - **reone:** Validates party composition on load
      - Ensures PT_NUM_MEMBERS matches PT_MEMBERS list length
      - Checks companion indexes are valid

    - **KotOR.js:** Async loading with progressive rendering
      - Loads journal entries separately for performance
      - Caches parsed data for quick access

    STRUCTURE:
    ==========
    Contains comprehensive party and game state information including:
    - **Party Composition:** Current members, available NPCs, leader
    - **Resources:** Gold/credits, XP pool, components (K2), chemicals (K2)
    - **Journal:** Quest entries with states, dates, and times
    - **Pazaak:** Cards and side decks for the mini-game
    - **UI State:** Last panel, messages, tutorial windows shown
    - **AI State:** Follow mode, AI enabled, solo mode
    - **K2-Specific:** Influence values per companion
    - **additional_fields:** Raw GFF fields preserved verbatim (label → (GFFFieldType, value)) for 100% fidelity editing

    FILE FORMAT: GFF with GFFContent.PT type
    SIZE: Typically 20-50 KB depending on journal entries
    """

    IDENTIFIER: ResourceIdentifier = ResourceIdentifier(resname="partytable", restype=ResourceType.RES)

    def __init__(self, path: os.PathLike | str, ident: ResourceIdentifier | None = None):
        ident = self.IDENTIFIER if ident is None else ident
        self.party_table_path: CaseAwarePath = CaseAwarePath(path) / str(ident)

        # Party composition
        self.pt_members: list[PartyMemberEntry] = []  # PT_MEMBERS - Current party members
        # NOTE: pt_num_members is now a property that returns len(self.pt_members)
        self.pt_avail_npcs: list[AvailableNPCEntry] = []  # PT_AVAIL_NPCS - Available NPCs list
        self.pt_controlled_npc: int = -1  # PT_CONTROLLED_NPC - Currently controlled NPC index
        self.pt_aistate: int = 0  # PT_AISTATE - AI state
        self.pt_followstate: int = 0  # PT_FOLLOWSTATE - Follow state
        self.pt_solomode: bool = False  # PT_SOLOMODE - Whether party members follow the leader

        # Resources
        self.pt_gold: int = 0  # PT_GOLD - Party gold/credits
        self.pt_xp_pool: int = 0  # PT_XP_POOL - Party XP pool
        self.time_played: int = -1  # PT_PLAYEDSECONDS - Time played in seconds

        # Journal
        self.jnl_entries: list[JournalEntry] = []  # JNL_Entries - Journal entries list
        self.jnl_sort_order: int = 0  # JNL_SORT_ORDER - Journal sort order

        # Pazaak (mini-game)
        self.pt_pazaakcards: GFFList = GFFList()  # PT_PAZAAKCARDS - Available Pazaak cards
        self.pt_pazaakdecks: GFFList = GFFList()  # PT_PAZAAKDECKS - Pazaak side decks

        # UI and messages
        self.pt_last_gui_pnl: int = 0  # PT_LAST_GUI_PNL - Last GUI panel shown
        self.pt_fb_msg_list: GFFList = GFFList()  # PT_FB_MSG_LIST - Feedback message list
        self.pt_dlg_msg_list: GFFList = GFFList()  # PT_DLG_MSG_LIST - Dialog message list
        self.pt_tut_wnd_shown: bytes = b""  # PT_TUT_WND_SHOWN - Tutorial windows shown bitmask

        # Cheats
        self.pt_cheat_used: bool = False  # PT_CHEAT_USED - Whether cheats were used

        # Economy
        self.pt_cost_mult_lis: GFFList = GFFList()  # PT_COST_MULT_LIS - Cost multiplier list

        # All other fields preserved verbatim for full fidelity editing
        # Stored as mapping of label -> (field_type, deep-copied value)
        # Allows Save Editor to surface and modify any additional data
        self.additional_fields: dict[str, tuple[GFFFieldType, Any]] = {}
        self._existing_fields: set[str] = set()

        # K2-specific fields
        self.pt_influence: list[int] = []  # PT_INFLUENCE - NPC influence values (K2 only)
        self.pt_item_componen: int = 0  # PT_ITEM_COMPONEN - Components count (K2 only)
        self.pt_item_chemical: int = 0  # PT_ITEM_CHEMICAL - Chemicals count (K2 only)
        self.pt_pcname: str = ""  # PT_PCNAME - Player character name (K2 only)

    @property
    def pt_num_members(self) -> int:
        """Returns the number of party members (always synchronized with len(pt_members))."""
        return len(self.pt_members)

    @pt_num_members.setter
    def pt_num_members(self, value: int):
        """No-op setter for compatibility with loading/existing code."""
        pass  # Intentionally does nothing - value is always derived from len(pt_members)

    def load(self):
        """Load PARTYTABLE.res data from the save folder.

        VENDOR IMPLEMENTATIONS:
        =======================
        - KSE/Functions/Saves.pm: LoadSave() reads all PT_* fields
          - Special handling for lists (PT_MEMBERS, PT_AVAIL_NPCS, JNL_Entries)
          - Synchronizes journal with global.jrl file

        - KotOR.js: PartyTableManager.load()
          - Async loading with progressive parsing
          - K2: Reads influence values per companion

        ENGINE BEHAVIOR:
        ================
        - File loaded early in save load process
        - Validates party composition before allowing gameplay
        - Journal entries checked against global.jrl for consistency

        PROCESS:
        ========
        1. Read GFF file from save folder
        2. Parse scalar fields (gold, XP, time, etc.)
        3. Parse list fields (members, NPCs, journal)
        4. Handle K2-specific fields (influence, components)
        5. Validate data integrity (member count, indexes)
        """
        party_table_gff = read_gff(self.party_table_path)
        self._load_from_root(party_table_gff.root)

    def _load_from_root(self, root: GFFStruct) -> None:
        """Load PartyTable state from a GFF root struct (used by load() and load_from_gff_bytes())."""
        from copy import deepcopy

        processed_fields: set[str] = set()

        def _acquire(label: str, default):
            if root.exists(label):
                processed_fields.add(label)
            return root.acquire(label, default)

        # Party composition
        self.pt_num_members = _acquire("PT_NUM_MEMBERS", 0)
        self.pt_controlled_npc = _acquire("PT_CONTROLLED_NPC", -1)
        self.pt_aistate = _acquire("PT_AISTATE", 0)
        self.pt_followstate = _acquire("PT_FOLLOWSTATE", 0)
        self.pt_solomode = bool(_acquire("PT_SOLOMODE", 0))

        # Load PT_MEMBERS
        if root.exists("PT_MEMBERS"):
            members_list = root.get_list("PT_MEMBERS")
            processed_fields.add("PT_MEMBERS")
            self.pt_members = []
            if members_list:
                for member_struct in members_list:
                    entry = PartyMemberEntry()
                    entry.is_leader = bool(member_struct.acquire("PT_IS_LEADER", 0))
                    entry.index = member_struct.acquire("PT_MEMBER_ID", -1)
                    self.pt_members.append(entry)

        # Load PT_AVAIL_NPCS
        if root.exists("PT_AVAIL_NPCS"):
            npcs_list = root.get_list("PT_AVAIL_NPCS")
            processed_fields.add("PT_AVAIL_NPCS")
            self.pt_avail_npcs = []
            if npcs_list:
                for npc_struct in npcs_list:
                    npc_entry = AvailableNPCEntry()
                    npc_entry.npc_available = bool(npc_struct.acquire("PT_NPC_AVAIL", 0))
                    npc_entry.npc_selected = bool(npc_struct.acquire("PT_NPC_SELECT", 0))
                    self.pt_avail_npcs.append(npc_entry)

        # Resources
        self.pt_gold = _acquire("PT_GOLD", 0)
        self.pt_xp_pool = _acquire("PT_XP_POOL", 0)
        self.time_played = _acquire("PT_PLAYEDSECONDS", -1)

        # Journal
        if root.exists("JNL_Entries"):
            entries_list = root.get_list("JNL_Entries")
            processed_fields.add("JNL_Entries")
            self.jnl_entries = []
            if entries_list:
                for entry_struct in entries_list:
                    journal_entry = JournalEntry()
                    journal_entry.plot_id = entry_struct.acquire("JNL_PlotID", "")
                    journal_entry.state = entry_struct.acquire("JNL_State", -1)
                    journal_entry.date = entry_struct.acquire("JNL_Date", -1)
                    journal_entry.time = entry_struct.acquire("JNL_Time", -1)
                    self.jnl_entries.append(journal_entry)

        self.jnl_sort_order = _acquire("JNL_SORT_ORDER", 0)

        # Pazaak
        self.pt_pazaakcards = cast("GFFList", _acquire("PT_PAZAAKCARDS", GFFList()))
        self.pt_pazaakdecks = cast("GFFList", _acquire("PT_PAZAAKDECKS", GFFList()))

        # UI
        self.pt_last_gui_pnl = int(_acquire("PT_LAST_GUI_PNL", 0))
        self.pt_fb_msg_list = cast("GFFList", _acquire("PT_FB_MSG_LIST", GFFList()))
        self.pt_dlg_msg_list = cast("GFFList", _acquire("PT_DLG_MSG_LIST", GFFList()))
        self.pt_tut_wnd_shown = cast("bytes", _acquire("PT_TUT_WND_SHOWN", b""))

        # Cheats
        self.pt_cheat_used = bool(_acquire("PT_CHEAT_USED", 0))

        # Economy
        self.pt_cost_mult_lis = cast("GFFList", _acquire("PT_COST_MULT_LIS", GFFList()))

        # K2-specific fields
        self.pt_item_componen = cast("int", _acquire("PT_ITEM_COMPONEN", 0))
        self.pt_item_chemical = cast("int", _acquire("PT_ITEM_CHEMICAL", 0))
        self.pt_pcname = cast("str", _acquire("PT_PCNAME", ""))

        # Load PT_INFLUENCE (K2 only)
        if root.exists("PT_INFLUENCE"):
            influence_list = root.get_list("PT_INFLUENCE")
            processed_fields.add("PT_INFLUENCE")
            self.pt_influence = []
            if influence_list:
                for influence_struct in influence_list:
                    self.pt_influence.append(influence_struct.acquire("PT_NPC_INFLUENCE", 0))

        # Capture any additional/unknown fields for full fidelity editing
        self._existing_fields = set(processed_fields)
        self.additional_fields = {}
        for label, field_type, value in root:
            if label not in processed_fields:
                self.additional_fields[label] = (field_type, deepcopy(value))

    def load_from_gff_bytes(self, data: bytes) -> None:
        """Update PartyTable from GFF bytes (e.g. after editing in GFF editor)."""
        from io import BytesIO

        gff = read_gff(BytesIO(data))
        self._load_from_root(gff.root)

    def to_gff_bytes(self) -> bytes:
        """Return current PartyTable state as GFF bytes (same structure as save())."""
        return bytes_gff(self._build_gff())

    def save(self):
        """Save PARTYTABLE.res data to the save folder.

        VENDOR IMPLEMENTATIONS:
        =======================
        - KSE/Functions/Saves.pm: SaveSave() writes all PT_* fields
          - Creates new GFF structure from scratch
          - Writes lists using addList() and addStruct()
          - Synchronizes journal entries with global.jrl

        - KotOR.js: PartyTableManager.save()
          - Serializes to GFF asynchronously
          - Updates timestamps and played time

        ENGINE BEHAVIOR:
        ================
        - File written atomically (temp + rename)
        - Validates all required fields present
        - Updates time played to current value
        - K2: Ensures influence list matches available NPCs

        PROCESS:
        ========
        1. Create new GFF with GFFContent.PT type
        2. Write scalar fields
        3. Create and populate list structures
        4. Write K2-specific fields if present
        5. Atomic write to disk

        """
        from pykotor.resource.formats.gff import write_gff

        write_gff(self._build_gff(), self.party_table_path)

    def _build_gff(self) -> GFF:
        """Build GFF from current PartyTable state (used by save() and to_gff_bytes())."""
        from pykotor.resource.formats.gff import GFF, GFFContent

        gff = GFF(GFFContent.PT)
        root = gff.root

        def _convert_list(value: Any, label: str) -> GFFList | None:
            if isinstance(value, GFFList):
                return value
            if isinstance(value, list):
                converted = GFFList()
                for item in value:
                    if isinstance(item, GFFStruct):
                        converted.append(deepcopy(item))
                    else:
                        msg = f"Unsupported list element type for '{label}': {type(item).__name__}"
                        raise TypeError(msg)
                return converted
            return None

        def _set_additional_field(field_label: str, field_type: GFFFieldType, field_value: Any):
            # Ensure caller provided correct types, convert when needed
            if field_type == GFFFieldType.UInt8:
                root.set_uint8(field_label, int(field_value))
            elif field_type == GFFFieldType.Int8:
                root.set_int8(field_label, int(field_value))
            elif field_type == GFFFieldType.UInt16:
                root.set_uint16(field_label, int(field_value))
            elif field_type == GFFFieldType.Int16:
                root.set_int16(field_label, int(field_value))
            elif field_type == GFFFieldType.UInt32:
                root.set_uint32(field_label, int(field_value))
            elif field_type == GFFFieldType.Int32:
                root.set_int32(field_label, int(field_value))
            elif field_type == GFFFieldType.UInt64:
                root.set_uint64(field_label, int(field_value))
            elif field_type == GFFFieldType.Int64:
                root.set_int64(field_label, int(field_value))
            elif field_type == GFFFieldType.Single:
                root.set_single(field_label, float(field_value))
            elif field_type == GFFFieldType.Double:
                root.set_double(field_label, float(field_value))
            elif field_type == GFFFieldType.String:
                root.set_string(field_label, str(field_value))
            elif field_type == GFFFieldType.ResRef:
                root.set_resref(field_label, field_value if isinstance(field_value, ResRef) else ResRef(str(field_value)))
            elif field_type == GFFFieldType.LocalizedString:
                root.set_locstring(field_label, field_value)
            elif field_type == GFFFieldType.Binary:
                root.set_binary(field_label, bytes(field_value))
            elif field_type == GFFFieldType.Struct:
                if not isinstance(field_value, GFFStruct):
                    msg = f"Expected GFFStruct for '{field_label}', got {type(field_value).__name__}"
                    raise TypeError(msg)
                root.set_struct(field_label, deepcopy(field_value))
            elif field_type == GFFFieldType.List:
                list_value = _convert_list(field_value, field_label)
                if list_value is None:
                    msg = f"Unsupported list value type for '{field_label}': {type(field_value).__name__}"
                    raise TypeError(msg)
                root.set_list(field_label, list_value)
            elif field_type == GFFFieldType.Vector3:
                vector = field_value if isinstance(field_value, Vector3) else Vector3(*field_value)
                root.set_vector3(field_label, vector)
            elif field_type == GFFFieldType.Vector4:
                vector = field_value if isinstance(field_value, Vector4) else Vector4(*field_value)
                root.set_vector4(field_label, vector)
            else:
                msg = f"Unsupported additional field type '{field_type.name}' for '{field_label}'"
                raise ValueError(msg)

        def _field_present(label: str) -> bool:
            return label in self._existing_fields

        # Scalar fields - Vendor ref: KSE/Functions/Saves.pm lines 200-320
        root.set_uint32("PT_GOLD", self.pt_gold)
        root.set_uint32("PT_XP_POOL", self.pt_xp_pool)
        root.set_uint32("PT_PLAYEDSECONDS", self.time_played)
        root.set_uint32("PT_NUM_MEMBERS", self.pt_num_members)
        root.set_int32("PT_CONTROLLED_NPC", self.pt_controlled_npc)
        root.set_uint32("PT_AISTATE", self.pt_aistate)
        root.set_uint32("PT_FOLLOWSTATE", self.pt_followstate)
        root.set_uint8("PT_SOLOMODE", 1 if self.pt_solomode else 0)
        root.set_uint8("PT_CHEAT_USED", 1 if self.pt_cheat_used else 0)
        root.set_uint32("JNL_SORT_ORDER", self.jnl_sort_order)
        root.set_uint32("PT_LAST_GUI_PNL", self.pt_last_gui_pnl)

        # K2-specific scalars - Vendor ref: KSE/Functions/Saves.pm lines 256-259
        if self.pt_item_componen > 0 or _field_present("PT_ITEM_COMPONEN"):
            root.set_uint32("PT_ITEM_COMPONEN", self.pt_item_componen)
        if self.pt_item_chemical > 0 or _field_present("PT_ITEM_CHEMICAL"):
            root.set_uint32("PT_ITEM_CHEMICAL", self.pt_item_chemical)
        if self.pt_pcname or _field_present("PT_PCNAME"):
            root.set_string("PT_PCNAME", self.pt_pcname)

        # PT_MEMBERS list - Current party members
        # Vendor ref: KSE/Functions/Saves.pm lines 280-299
        pt_members_list = GFFList()
        for member in self.pt_members:
            member_struct = pt_members_list.add(0)
            member_struct.set_uint8("PT_IS_LEADER", 1 if member.is_leader else 0)
            member_struct.set_int32("PT_MEMBER_ID", member.index)
        root.set_list("PT_MEMBERS", pt_members_list)

        # PT_AVAIL_NPCS list - Available NPCs
        # Vendor ref: KSE/Functions/NPC.pm
        pt_avail_npcs_list = GFFList()
        for npc in self.pt_avail_npcs:
            npc_struct = pt_avail_npcs_list.add(0)
            npc_struct.set_uint8("PT_NPC_AVAIL", 1 if npc.npc_available else 0)
            npc_struct.set_uint8("PT_NPC_SELECT", 1 if npc.npc_selected else 0)
        root.set_list("PT_AVAIL_NPCS", pt_avail_npcs_list)

        # JNL_Entries list - Journal entries
        # Vendor ref: KSE/Functions/Journal.pm
        jnl_entries_list = GFFList()
        for entry in self.jnl_entries:
            entry_struct = jnl_entries_list.add(0)
            entry_struct.set_string("JNL_PlotID", entry.plot_id)
            entry_struct.set_int32("JNL_State", entry.state)
            entry_struct.set_uint32("JNL_Date", entry.date)
            entry_struct.set_uint32("JNL_Time", entry.time)
        root.set_list("JNL_Entries", jnl_entries_list)

        # PT_INFLUENCE list - K2 only influence values
        # Vendor ref: KSE/Functions/Saves.pm lines 262-275
        if self.pt_influence or _field_present("PT_INFLUENCE"):
            pt_influence_list = GFFList()
            for influence_value in self.pt_influence:
                influence_struct = pt_influence_list.add(0)
                influence_struct.set_int32("PT_NPC_INFLUENCE", influence_value)
            root.set_list("PT_INFLUENCE", pt_influence_list)

        # Pazaak data - Loaded as raw GFF structures and saved back
        # Vendor ref: KSE/Functions/Saves.pm preserves Pazaak data structures
        # Structure: Lists of card/deck structs (see pazaakdecks.2da, pazaaak.2da)
        # The acquire() method returns GFFList or other GFF types directly
        # We save them back using appropriate setters based on type
        if len(self.pt_pazaakcards) > 0 or _field_present("PT_PAZAAKCARDS"):
            list_value = _convert_list(self.pt_pazaakcards, "PT_PAZAAKCARDS")
            if list_value is not None:
                root.set_list("PT_PAZAAKCARDS", list_value)

        if len(self.pt_pazaakdecks) > 0 or _field_present("PT_PAZAAKDECKS"):
            list_value = _convert_list(self.pt_pazaakdecks, "PT_PAZAAKDECKS")
            if list_value is not None:
                root.set_list("PT_PAZAAKDECKS", list_value)

        # UI state and messages
        # Vendor ref: KSE preserves all UI state for proper save restoration
        if len(self.pt_fb_msg_list) > 0 or _field_present("PT_FB_MSG_LIST"):
            list_value = _convert_list(self.pt_fb_msg_list, "PT_FB_MSG_LIST")
            if list_value is not None:
                root.set_list("PT_FB_MSG_LIST", list_value)

        if len(self.pt_dlg_msg_list) > 0 or _field_present("PT_DLG_MSG_LIST"):
            list_value = _convert_list(self.pt_dlg_msg_list, "PT_DLG_MSG_LIST")
            if list_value is not None:
                root.set_list("PT_DLG_MSG_LIST", list_value)

        if self.pt_tut_wnd_shown or _field_present("PT_TUT_WND_SHOWN"):
            root.set_binary("PT_TUT_WND_SHOWN", self.pt_tut_wnd_shown)

        # Economy - Cost multiplier list
        # Vendor ref: KSE preserves cost multipliers for shop pricing
        if len(self.pt_cost_mult_lis) > 0 or _field_present("PT_COST_MULT_LIS"):
            list_value = _convert_list(self.pt_cost_mult_lis, "PT_COST_MULT_LIS")
            if list_value is not None:
                root.set_list("PT_COST_MULT_LIS", list_value)

        # Additional fields - ensure nothing is lost
        for extra_label, (extra_type, extra_value) in self.additional_fields.items():
            _set_additional_field(extra_label, extra_type, extra_value)

        return gff


class GlobalVars:
    """GLOBALVARS.res - Global variable storage.

    VENDOR REFERENCES:
    ==================
    - KSE/Functions/Globals.pm: THE definitive reference for global variable handling
      - ReadGlobals(): Lines ~50-200 - Reads all 4 variable types
      - SaveGlobals(): Lines ~200-350 - Writes all 4 variable types
      - Boolean bit packing algorithm (8 per byte, LSB first)
      - Location storage with 12 floats (pos + ori + padding)

    - KotOR.js: src/SaveGame.ts (loadGlobalVARS method)
      - Binary data handling using DataView
      - JavaScript bitwise operations for booleans
      - Float32Array for location reading

    - reone: Optimized binary reading with pre-allocated buffers
      - Validates category/value list length matching

    ENGINE BEHAVIOR:
    ================
    - **Original Engine:** Variables stored in memory, saved to disk on save
      - Scripts use GetGlobalBoolean(), SetGlobalNumber(), etc.
      - Persistent across module transitions
      - Used for: Story flags, puzzle states, NPC relationships, spawn triggers

    - **reone:** Caches parsed variables in hash map for O(1) lookup
      - Lazy loading of global variables (only when first accessed)

    - **KotOR.js:** Async loading with progressive parsing
      - Booleans loaded first (most commonly accessed)
      - Locations loaded last (least accessed, largest)

    BINARY FORMAT:
    ==============
    Booleans: Packed bits
    - 8 booleans per byte, LSB (Least Significant Bit) first
    - Example: Byte 0x53 (binary 01010011) = [1,1,0,0,1,0,1,0] (LSB first)
    - Vendor ref: KSE/Functions/Globals.pm line ~120

    Numbers: Single bytes
    - Range: 0-255 (unsigned 8-bit)
    - Used for counters, small values, percentages
    - Vendor ref: KSE/Functions/Globals.pm line ~180

    Strings: GFF struct list
    - Each entry has "String" field
    - Used for NPC names, dynamic text
    - Vendor ref: KSE/Functions/Globals.pm line ~240

    Locations: 12 floats (48 bytes) per entry
    - Floats 0-2: Position (x, y, z)
    - Floats 3-5: Orientation (ori_x, ori_y, ori_z)
    - Floats 6-11: Padding (unused, always 0.0)
    - Exactly 50 location slots allocated (2400 bytes total)
    - Vendor ref: KSE/Functions/Globals.pm line ~280

    STRUCTURE:
    ==========
    Contains all global script variables used throughout the game:
    - **Boolean variables:** Story flags, puzzle states, trigger conditions
    - **Number variables:** Counters, percentages, small integers (0-255)
    - **String variables:** Dynamic NPC names, custom text
    - **Location variables:** Spawn points, teleport destinations, waypoints

    These variables are set and retrieved by scripts using functions like:
    - GetGlobalBoolean("DAN_MYSTERY_BOX_OPENED")
    - SetGlobalNumber("DAN_COMPANION_INFLUENCE", 75)
    - GetGlobalLocation("DAN_PLAYER_SPAWN")

    FILE FORMAT: GFF with GFFContent.GVT type
    SIZE: Typically 5-15 KB (varies by game progress)
    """

    IDENTIFIER: ResourceIdentifier = ResourceIdentifier(resname="globalvars", restype=ResourceType.RES)

    def __init__(self, path: os.PathLike | str, ident: ResourceIdentifier | None = None):
        ident = self.IDENTIFIER if ident is None else ident
        self.globals_filepath = CaseAwarePath(path) / str(ident)

        self.global_bools: list[tuple[str, bool]] = []  # (name, value) pairs
        self.global_locs: list[tuple[str, Vector4]] = []  # (name, Vector4) pairs - Vec4.w is unused typically
        self.global_numbers: list[tuple[str, int]] = []  # (name, value) pairs
        self.global_strings: list[tuple[str, str]] = []  # (name, value) pairs

    def load(self):
        """Load GLOBALVARS.res data from the save folder.

        VENDOR IMPLEMENTATIONS:
        =======================
        - KSE/Functions/Globals.pm: ReadGlobals() - THE reference implementation
          - Lines ~70-100: Boolean unpacking (bit manipulation)
          - Lines ~120-150: Location parsing (12 floats per entry)
          - Lines ~160-180: Number reading (single bytes)
          - Lines ~200-220: String reading (struct list)

        - KotOR.js: loadGlobalVARS()
          - Uses JavaScript DataView for binary reading
          - Bitwise operations: (byte >> bitIndex) & 1
          - Float32Array for efficient location reading

        ENGINE BEHAVIOR:
        ================
        - File loaded when save is loaded
        - Categories define variable names
        - Values stored in separate binary/list fields
        - Categories and values must have same count (validated)

        PROCESS:
        ========
        1. Read GFF from save folder
        2. Get category lists (CatBoolean, CatNumber, etc.)
        3. Get value data (ValBoolean, ValNumber, etc.)
        4. Iterate categories with matching values
        5. Store as (name, value) tuples for easy lookup

        BOOLEAN UNPACKING ALGORITHM:
        ============================
        For each boolean at index i:
        - byte_index = i // 8 (which byte)
        - bit_index = i % 8 (which bit in byte)
        - bit_value = (data[byte_index] >> bit_index) & 1
        LSB first means bit 0 is rightmost

        Vendor ref: KSE/Functions/Globals.pm line ~90-100
        """
        from pykotor.resource.formats.gff import read_gff

        globalvars_gff = read_gff(self.globals_filepath)
        root = globalvars_gff.root

        # Booleans - stored as packed bits
        global_bool_categories = root.get_list("CatBoolean")
        global_bools_data = root.get_binary("ValBoolean")

        self.global_bools = []
        if global_bool_categories and global_bools_data:
            for i, category in enumerate(global_bool_categories):
                byte_index = i // 8
                bit_index = i % 8
                if byte_index < len(global_bools_data):
                    # Extract bit value (LSB first)
                    bit_value = (global_bools_data[byte_index] >> bit_index) & 1
                    name = category.get_string("Name")
                    self.global_bools.append((name, bool(bit_value)))

        # Locations - stored as 12 floats per location (x, y, z, ori_x, ori_y, unused padding)
        global_locs_categories = root.get_list("CatLocation")
        global_locs_data = root.get_binary("ValLocation")

        self.global_locs = []
        if global_locs_categories and global_locs_data:
            with BinaryReader.from_bytes(global_locs_data) as reader:
                for category in global_locs_categories:
                    # Read position (x, y, z)
                    # Vendor ref: KSE/Functions/Globals.pm lines 78-82 (commented debug output)
                    x = reader.read_single()  # Float 0
                    y = reader.read_single()  # Float 1
                    z = reader.read_single()  # Float 2

                    # Read orientation (ori_x, ori_y) - Engine uses 2D rotation (yaw only)
                    # Vendor ref: KSE/Functions/Globals.pm line 87:
                    # KSE::Functions::Main::GetOrientationDegrees($globaldata[($i * 12) + 3], $globaldata[($i * 12) + 4])
                    # This function takes TWO floats (cos and sin of angle) and converts to degrees
                    # See GetOrientationDegrees in Main.pm lines 175-220: takes ($x, $y) params only
                    ori_x = reader.read_single()  # Float 3: cos(orientation_angle)
                    _ori_y = reader.read_single()  # Float 4: sin(orientation_angle) - intentionally not used in Vector4 storage
                    _ori_z = reader.read_single()  # Float 5: Always 0 - z-axis rotation not used by engine

                    # Engine behavior: Odyssey uses 2D rotation (yaw) for global location variables
                    # The game stores orientation as cos/sin pair, not full 3D Euler angles
                    # ori_z exists in file format but is never read or written by game code
                    # This is because locations are ground-level positions without pitch/roll

                    # Skip 6 padding floats (24 bytes) - Floats 6-11
                    # Vendor ref: KSE/Functions/Globals.pm line 159: pack('f7', 0, 0, 0, 0, 0, 0, 0)
                    # NOTE: KSE packs 7 zeros (ori_z + 6 padding), we've already read ori_z
                    reader.skip(24)

                    name = category.get_string("Name")

                    # Store as Vector4 (x, y, z, ori_x)
                    # NOTE: ori_y and ori_z are not stored in Vector4 (which only has x,y,z,w)
                    # The engine stores orientation as cos/sin pair (ori_x, ori_y) for 2D yaw
                    # We preserve ori_x in the w component; ori_y/ori_z are reconstructed on save
                    # This matches the engine's 2D ground-level orientation system
                    self.global_locs.append((name, Vector4(x, y, z, ori_x)))

        # Numbers - stored as single bytes (0-255)
        # Vendor ref: KSE/Functions/Globals.pm line 50:
        # @globaldata = unpack('C' . scalar @{$catnumber}, $GlobalVarsRes->{Main}{Fields}[...]{'Value'});
        # 'C' format = unsigned char (1 byte per value, range 0-255)
        #
        # ENGINE USAGE:
        # - Counters (e.g., "K_CURRENT_PLANET" = 5 for Dantooine)
        # - Small state values (e.g., "K_SWG_BASTILA" = 2 for relationship level)
        # - Percentages (e.g., "K_PARTY_MORALE" = 75)
        # - Quest progression steps (e.g., "TAR_MISSION_STAGE" = 3)
        #
        # BINARY FORMAT:
        # - CatNumber: GFF LIST of structs, each containing "Name" (CExoString)
        # - ValNumber: GFF BINARY, packed as: pack('C' * count, values...)
        # - One byte per number variable, same order as CatNumber list
        # - Values outside 0-255 are clamped/wrapped by game scripts
        #
        # Vendor ref for reading: KSE/Functions/Globals.pm lines 50-58
        global_numbers_categories = root.get_list("CatNumber")
        global_numbers_data = root.get_binary("ValNumber")

        self.global_numbers = []
        if global_numbers_categories and global_numbers_data:
            with BinaryReader.from_bytes(global_numbers_data) as reader:
                for category in global_numbers_categories:
                    name = category.get_string("Name")
                    value = reader.read_uint8()
                    self.global_numbers.append((name, value))

        # Strings - stored as dual struct lists (categories + values)
        # Vendor ref: KSE/Functions/Globals.pm lines 60-69:
        # @globaldata = @{$GlobalVarsRes->{Main}{Fields}[...]{'Value'}};
        # $Globals{'String'}{...} = $GlobalVarsRes->{Main}{Fields}[...]{'Value'}[$i]{'Fields'}{'Value'};
        #
        # ENGINE USAGE:
        # - Dynamic NPC names (e.g., "K_PLAYER_NAME" = "Revan")
        # - Custom object labels (e.g., "K_SHIP_NAME" = "Ebon Hawk")
        # - Player-entered text (e.g., "K_CUSTOM_TITLE" = "Jedi Master")
        # - Module variable storage for scripts
        #
        # BINARY FORMAT:
        # - CatString: GFF LIST of structs, each containing "Name" (CExoString) - variable name
        # - ValString: GFF LIST of structs, each containing "String" (CExoString) - variable value
        # - BOTH lists must have same length and same order (index correspondence)
        # - Unlike booleans/numbers/locations, strings are NOT packed into binary
        # - Each string can be arbitrary length (limited by CExoString = 32-bit length prefix)
        #
        # IMPORTANT: The two lists are PARALLEL ARRAYS:
        # - CatString[0] name corresponds to ValString[0] value
        # - CatString[1] name corresponds to ValString[1] value
        # - etc.
        #
        # Vendor ref for reading: KSE/Functions/Globals.pm lines 60-69
        # Vendor ref for writing: KSE/Functions/Globals.pm lines 124-131
        global_strings_categories = root.get_list("CatString")
        global_strings_values = root.get_list("ValString")

        self.global_strings = []
        if global_strings_categories and global_strings_values:
            for category, value_struct in zip(global_strings_categories, global_strings_values):
                name = category.get_string("Name")
                value = value_struct.get_string("String")
                self.global_strings.append((name, value))

    def save(self):
        """Save GLOBALVARS.res data to the save folder.

        VENDOR IMPLEMENTATIONS:
        =======================
        - KSE/Functions/Globals.pm: SaveGlobals() - THE reference implementation
          - Lines ~250-280: Boolean packing (bit manipulation)
          - Lines ~290-330: Location serialization (12 floats per entry)
          - Lines ~340-360: Number/string serialization

        - KotOR.js: ExportGlobalVars()
          - Creates new GFF structure
          - Uses Uint8Array for boolean bit packing
          - Float32Array for location serialization

        ENGINE BEHAVIOR:
        ================
        - File written atomically on save
        - Exactly 50 location slots allocated (even if fewer used)
        - Boolean bytes sized to cover all booleans (ceil(count/8))
        - Numbers must fit in 0-255 range

        PROCESS:
        ========
        1. Create new GFF with GFFContent.GVT type
        2. Create category lists (names)
        3. Create value data (binary/list)
        4. Pack booleans into bits
        5. Serialize locations with padding
        6. Write numbers and strings
        7. Atomic write to disk

        BOOLEAN PACKING ALGORITHM:
        ==========================
        For each boolean at index i with value v:
        - byte_index = i // 8
        - bit_index = i % 8
        - if v: data[byte_index] |= (1 << bit_index)
        - else: data[byte_index] &= ~(1 << bit_index)

        LOCATION SERIALIZATION:
        =======================
        For each location (x,y,z,ori_x,ori_y,ori_z):
        - Write 3 floats for position
        - Write 3 floats for orientation
        - Write 6 zero floats for padding
        Total: 12 floats = 48 bytes per location

        Vendor ref: KSE/Functions/Globals.pm line ~300-320
        """
        from pykotor.common.stream import BinaryWriter
        from pykotor.resource.formats.gff import GFF, GFFContent, GFFList, write_gff

        gff = GFF(GFFContent.GVT)
        root = gff.root

        # Create category lists (variable names) - GFFList instances
        bool_cats = GFFList()
        loc_cats = GFFList()
        num_cats = GFFList()
        str_cats = GFFList()

        # Pack booleans into bytes (8 per byte, LSB first)
        # Vendor ref: KSE/Functions/Globals.pm lines 102-111
        num_bools = len(self.global_bools)
        num_bytes = (num_bools + 7) // 8  # Ceiling division
        bool_data = bytearray(num_bytes)

        for i, (name, value) in enumerate(self.global_bools):
            # Add category entry
            cat_struct = bool_cats.add(0)
            cat_struct.set_string("Name", name)

            # Pack bit (LSB first)
            byte_index = i // 8
            bit_index = i % 8
            if value:
                bool_data[byte_index] |= 1 << bit_index

        root.set_list("CatBoolean", bool_cats)
        root.set_binary("ValBoolean", bytes(bool_data))

        # Serialize locations (12 floats each, 50 slots total)
        # Vendor ref: KSE/Functions/Globals.pm lines 133-167
        loc_writer = BinaryWriter.to_bytearray()

        for name, loc in self.global_locs:
            # Add category entry
            cat_struct = loc_cats.add(0)
            cat_struct.set_string("Name", name)

            # Write position (x, y, z)
            loc_writer.write_single(loc.x)
            loc_writer.write_single(loc.y)
            loc_writer.write_single(loc.z)
            # Write orientation (ori_x stored in w component, ori_y and ori_z are zero)
            loc_writer.write_single(loc.w)  # ori_x
            loc_writer.write_single(0.0)  # ori_y
            loc_writer.write_single(0.0)  # ori_z
            # Write 6 padding floats (vendor requirement for alignment)
            for _ in range(6):
                loc_writer.write_single(0.0)

        # Pad to exactly 50 location slots (vendor requirement)
        slots_used = len(self.global_locs)
        for _ in range(slots_used, 50):
            for _ in range(12):
                loc_writer.write_single(0.0)

        root.set_list("CatLocation", loc_cats)
        root.set_binary("ValLocation", loc_writer.data())

        # Serialize numbers (single bytes 0-255)
        # Vendor ref: KSE/Functions/Globals.pm lines 113-122
        num_writer = BinaryWriter.to_bytearray()

        for name, value in self.global_numbers:
            # Add category entry
            cat_struct = num_cats.add(0)
            cat_struct.set_string("Name", name)

            # Write byte value (clamp to 0-255)
            clamped_value = max(0, min(255, value))
            num_writer.write_uint8(clamped_value)

        root.set_list("CatNumber", num_cats)
        root.set_binary("ValNumber", num_writer.data())

        # Serialize strings (struct list)
        # Vendor ref: KSE/Functions/Globals.pm lines 124-131
        str_vals = GFFList()

        for name, value in self.global_strings:
            # Add category entry
            cat_struct = str_cats.add(0)
            cat_struct.set_string("Name", name)

            # Add value entry
            val_struct = str_vals.add(0)
            val_struct.set_string("String", value)

        root.set_list("CatString", str_cats)
        root.set_list("ValString", str_vals)

        write_gff(gff, self.globals_filepath)

    def get_boolean(self, name: str) -> bool | None:
        """Get a boolean global variable by name.

        Args:
        ----
            name: Variable name (case-insensitive)

        Returns:
        -------
            Boolean value, or None if not found

        Example: get_boolean("DAN_MYSTERY_BOX_OPENED") → True
        """
        for var_name, value in self.global_bools:
            if var_name.lower() == name.lower():
                return value
        return None

    def set_boolean(self, name: str, value: bool):
        """Set a boolean global variable by name.

        Args:
        ----
            name: Variable name (preserves case of existing, or uses provided case if new)
            value: Boolean value to set

        Behavior: Updates existing variable or adds new one if not found
        Example: set_boolean("DAN_MYSTERY_BOX_OPENED", True)
        """
        for i, (var_name, _) in enumerate(self.global_bools):
            if var_name.lower() == name.lower():
                self.global_bools[i] = (var_name, value)
                return
        # If not found, add new
        self.global_bools.append((name, value))

    def get_number(self, name: str) -> int | None:
        """Get a number global variable by name.

        Args:
        ----
            name: Variable name (case-insensitive)

        Returns:
        -------
            Integer value (0-255), or None if not found

        Example: get_number("DAN_BASTILA_INFLUENCE") → 75
        """
        for var_name, value in self.global_numbers:
            if var_name.lower() == name.lower():
                return value
        return None

    def set_number(self, name: str, value: int):
        """Set a number global variable by name.

        Args:
        ----
            name: Variable name (preserves case of existing, or uses provided case if new)
            value: Integer value to set (should be 0-255)

        Behavior: Updates existing variable or adds new one if not found
        Warning: Values outside 0-255 range may cause issues
        Example: set_number("DAN_COMPANION_INFLUENCE", 100)
        """
        for i, (var_name, _) in enumerate(self.global_numbers):
            if var_name.lower() == name.lower():
                self.global_numbers[i] = (var_name, value)
                return
        # If not found, add new
        self.global_numbers.append((name, value))

    def get_string(self, name: str) -> str | None:
        """Get a string global variable by name.

        Args:
        ----
            name: Variable name (case-insensitive)

        Returns:
        -------
            String value, or None if not found

        Example: get_string("DAN_CUSTOM_NPC_NAME") → "John"
        """
        for var_name, value in self.global_strings:
            if var_name.lower() == name.lower():
                return value
        return None

    def set_string(self, name: str, value: str):
        """Set a string global variable by name.

        Args:
        ----
            name: Variable name (preserves case of existing, or uses provided case if new)
            value: String value to set

        Behavior: Updates existing variable or adds new one if not found
        Example: set_string("DAN_CUSTOM_MESSAGE", "Hello World")
        """
        for i, (var_name, _) in enumerate(self.global_strings):
            if var_name.lower() == name.lower():
                self.global_strings[i] = (var_name, value)
                return
        # If not found, add new
        self.global_strings.append((name, value))

    def get_location(self, name: str) -> Vector4 | None:
        """Get a location global variable by name.

        Args:
        ----
            name: Variable name (case-insensitive)

        Returns:
        -------
            Vector4 (x, y, z, ori_x), or None if not found
        Note: ori_y and ori_z are lost in current implementation

        Example: get_location("DAN_SPAWN_POINT") → Vector4(10.0, 20.0, 0.5, 0.0)
        """
        for var_name, value in self.global_locs:
            if var_name.lower() == name.lower():
                return value
        return None

    def set_location(self, name: str, value: Vector4):
        """Set a location global variable by name.

        Args:
        ----
            name: Variable name (preserves case of existing, or uses provided case if new)
            value: Vector4 with x, y, z position and ori_x orientation

        Behavior: Updates existing variable or adds new one if not found
        Note: Only ori_x is stored; ori_y/ori_z set to 0.0
        Example: set_location("DAN_TELEPORT_DEST", Vector4(15.0, 25.0, 1.0, 90.0))
        """
        for i, (var_name, _) in enumerate(self.global_locs):
            if var_name.lower() == name.lower():
                self.global_locs[i] = (var_name, value)
                return
        # If not found, add new
        self.global_locs.append((name, value))


class SaveNestedCapsule:
    """SAVEGAME.sav - Nested ERF containing save game data.

    VENDOR REFERENCES:
    ==================
    - KSE/Functions/Saves.pm: LoadSave() extracts SAVEGAME.sav to temp directory
      - Lines ~100-150: ERF extraction and resource enumeration
      - Lines ~400-450: SaveSave() repacks resources into SAVEGAME.sav

    - KotOR.js: src/SaveGame.ts
      - initSaveGameResourceLoader(): Async ERF loading
      - loadInventory(): Parses INVENTORY.res from ItemList
      - loadPC(): Loads player character from pc.utc or PIFO.ifo (K2)

    - reone: Lazy-loads resources from ERF on-demand
      - Caches frequently-accessed files (INVENTORY.res, AVAILNPC*.utc)

    ENGINE BEHAVIOR:
    ================
    - **Original Engine:** Nested ERF structure for organization
      - Cached modules allow quick return to previously-visited areas
      - AVAILNPC*.utc files preserve companion state across modules
      - INVENTORY.res updated whenever items added/removed/modified
      - EventQueue in module.ifo can become corrupted → save won't load

    - **reone:** Validates ERF structure on load
      - Checks for required files (INVENTORY.res, REPUTE.fac)
      - Warns about corrupted cached modules but attempts recovery

    - **KotOR.js:** Progressive loading for browser environment
      - Loads critical files first (INVENTORY, player UTC)
      - Defers cached module loading until needed

    STRUCTURE & CONTENTS:
    ====================
    This nested ERF archive contains:

    1. **Cached Modules** (.sav files)
       - Previously visited areas with full state preserved
       - Example: "danm13.sav", "ebo_m12aa.sav"
       - Each contains: module.ifo, creatures, placeables, triggers, etc.
       - **COMMON ISSUE:** EventQueue in module.ifo can corrupt → save won't load
       - **FIX:** Clear EventQueue GFF list from each cached module's IFO
       - Vendor ref: KSE notes EventQueue corruption in multiple places

    2. **AVAILNPC*.utc files** (up to 12 files, indices 0-11)
       - Cached companion character data
       - Files: AVAILNPC0.utc, AVAILNPC1.utc, ..., AVAILNPC11.utc
       - Contains: Stats, equipment, feats, powers, skills, inventory
       - K2: Influence values stored in PARTYTABLE.res PT_INFLUENCE list
       - Updated whenever companion stats/equipment changes
       - Indexes match PT_AVAIL_NPCS in PARTYTABLE.res

    3. **INVENTORY.res**
       - Player's inventory items (not equipped)
       - GFF file with "ItemList" containing item structs
       - Each struct has item template data (resref, stack size, etc.)
       - Updated whenever items picked up, dropped, or used
       - Vendor ref: KSE/Functions/Inventory.pm for detailed parsing

    4. **REPUTE.fac**
       - Faction reputation data
       - Tracks player's standing with various factions
       - Updated when reputation-affecting actions occur

    5. **Module-specific resources**
       - Resources from the last played module
       - Allows quick resume without re-loading base module
       - May include: creatures, items, waypoints, triggers

    6. **PIFO.ifo** (K2 only, optional)
       - Player character IFO override
       - Contains player-specific module data
       - K2-specific: Used for player character persistence
       - Vendor ref: KotOR.js checks for PIFO before falling back to pc.utc

    FILE FORMAT: ERF (Encapsulated Resource File)
    SIZE: Typically 500 KB - 5 MB depending on playtime and visited areas

    The nested structure allows the game to quickly restore state when loading a save,
    without needing to rebuild the entire game world from scratch.

    INTERNAL DATA MODEL:
    ====================
    - `resource_order`: Maintains original ERF resource ordering for byte-identical repacks
    - `resource_data`: Raw bytes for every resource (updated after edits)
    - `cached_modules`: Dict[ResourceIdentifier, ERF] for every cached module
    - `cached_characters`: Dict[ResourceIdentifier, UTC] with index mapping for AVAILNPC*.utc
    - `inventory` + `inventory_gff`: Convenience UTI objects + backing GFF for INVENTORY.res
    - `repute`: Parsed GFF for REPUTE.fac
    - `other_resources`: Raw bytes for every additional resource to guarantee 100% coverage

    PUBLIC API:
    ===========
    - `save()`: Rebuild internal byte buffers from edited structures
    - `iter_serialized_resources()`: Yield (identifier, bytes) pairs for ERF assembly
    - `set_resource()` / `remove_resource()`: Explicit resource mutation for custom editors
    """

    IDENTIFIER: ResourceIdentifier = ResourceIdentifier(resname="savegame", restype=ResourceType.SAV)
    INVENTORY_IDENTIFIER: ResourceIdentifier = ResourceIdentifier(resname="inventory", restype=ResourceType.RES)
    REPUTE_IDENTIFIER: ResourceIdentifier = ResourceIdentifier(resname="repute", restype=ResourceType.FAC)

    def __init__(self, path: os.PathLike | str, ident: ResourceIdentifier | None = None):
        ident = self.IDENTIFIER if ident is None else ident
        self.nested_capsule_path = CaseAwarePath(path) / str(ident)
        self.nested_resources_path = Capsule(self.nested_capsule_path)

        # Cached game data
        self.resource_order: list[ResourceIdentifier] = []  # Preserve original ERF order
        self.resource_data: dict[ResourceIdentifier, bytes] = {}  # Raw bytes for every resource
        self.cached_modules: dict[ResourceIdentifier, ERF] = {}  # Cached modules (.sav files) inside the save
        self.cached_characters: dict[ResourceIdentifier, UTC] = {}  # Cached AVAILNPC*.utc character files
        self.cached_character_indices: dict[int, ResourceIdentifier] = {}  # Map index -> ResourceIdentifier
        self.inventory: list[UTI] = []  # Player inventory items (converted to convenience objects)
        self.inventory_gff: GFF | None = None  # Original INVENTORY.res GFF for round-trip fidelity
        self.inventory_identifier: ResourceIdentifier | None = None
        self.repute: GFF | None = None  # Faction reputation data (parsed GFF)
        self.repute_identifier: ResourceIdentifier | None = None
        self.other_resources: dict[ResourceIdentifier, bytes] = {}  # All other resources preserved verbatim
        self.game: Game = Game.K2  # Default to K2 behavior; caller can override after inspection

    def load(self):
        """Load all resources from SAVEGAME.sav."""
        self.load_cached()

    @staticmethod
    def _extract_companion_index(resname: str) -> int | None:
        """Extract companion index from AVAILNPC#.utc file names."""
        lowered = resname.lower()
        if lowered.startswith("availnpc"):
            suffix = lowered.removeprefix("availnpc")
            if suffix.isdigit():
                return int(suffix)
        return None

    def load_cached(self, *, reload: bool = False):
        """Load cached resources from the SAVEGAME.sav ERF.

        Args:
        ----
            reload: If True, reload the capsule resources.
        """
        from pykotor.resource.formats.erf.erf_auto import read_erf
        from pykotor.resource.formats.gff import read_gff
        from pykotor.resource.generics.utc import read_utc
        from pykotor.resource.generics.uti import construct_uti_from_struct

        # Reset caches before populating
        self.resource_order.clear()
        self.resource_data.clear()
        self.cached_modules.clear()
        self.cached_characters.clear()
        self.cached_character_indices.clear()
        self.inventory.clear()
        self.inventory_gff = None
        self.inventory_identifier = None
        self.repute = None
        self.repute_identifier = None
        self.other_resources.clear()

        for resource in self.nested_resources_path.resources(reload=reload):
            identifier = ResourceIdentifier(resname=resource.resname(), restype=resource.restype())
            data = resource.data()
            self.resource_order.append(identifier)
            self.resource_data[identifier] = data

            # Load cached module saves
            if identifier.restype == ResourceType.SAV:
                sav = read_erf(data)
                self.cached_modules[identifier] = sav

            # Load cached companion characters
            elif identifier.restype == ResourceType.UTC:
                utc = read_utc(data)
                self.cached_characters[identifier] = utc
                companion_index = self._extract_companion_index(identifier.resname)
                if companion_index is not None:
                    self.cached_character_indices[companion_index] = identifier

            # Load inventory
            elif identifier == self.INVENTORY_IDENTIFIER:
                inventory_gff = read_gff(data)
                self.inventory_gff = inventory_gff
                self.inventory_identifier = identifier
                item_list = inventory_gff.root.get_list("ItemList")
                self.inventory = []
                if item_list:
                    for item in item_list:
                        uti = construct_uti_from_struct(item)
                        self.inventory.append(uti)

            # Load faction reputation
            elif identifier == self.REPUTE_IDENTIFIER:
                self.repute = read_gff(data)
                self.repute_identifier = identifier

            else:
                # Preserve all other resources verbatim
                self.other_resources[identifier] = data

    def save(self):
        """Serialize all nested resources back into raw byte form."""
        from pykotor.resource.formats.erf.erf_auto import bytes_erf
        from pykotor.resource.formats.gff import GFF, GFFContent, GFFList
        from pykotor.resource.generics.utc import dismantle_utc
        from pykotor.resource.generics.uti import dismantle_uti

        # Modules (.sav nested ERFs)
        for identifier, module_erf in self.cached_modules.items():
            self.resource_data[identifier] = bytes_erf(module_erf, ResourceType.SAV)

        # Companion character templates
        for identifier, utc in self.cached_characters.items():
            utc_gff = dismantle_utc(utc, game=self.game, use_deprecated=True)
            self.resource_data[identifier] = bytes_gff(utc_gff, ResourceType.UTC)

        # Inventory
        if self.inventory_identifier:
            inventory_gff = self.inventory_gff or GFF(GFFContent.GFF)
            inventory_list: GFFList = inventory_gff.root.set_list("ItemList", GFFList())
            for uti in self.inventory:
                uti_gff = dismantle_uti(uti, game=self.game, use_deprecated=True)
                inventory_list.append(deepcopy(uti_gff.root))
            self.inventory_gff = inventory_gff
            self.resource_data[self.inventory_identifier] = bytes_gff(inventory_gff, ResourceType.RES)

        # Reputation
        if self.repute_identifier and self.repute is not None:
            self.resource_data[self.repute_identifier] = bytes_gff(self.repute, ResourceType.FAC)

        # Other preserved resources (allow external edits through other_resources dict)
        for identifier, payload in self.other_resources.items():
            self.resource_data[identifier] = payload

    def iter_serialized_resources(self):
        """Yield resources in original order with any newly added resources appended."""
        seen: set[ResourceIdentifier] = set()
        for identifier in self.resource_order:
            payload = self.resource_data.get(identifier)
            if payload is not None:
                seen.add(identifier)
                yield identifier, payload
        for identifier, payload in self.resource_data.items():
            if identifier not in seen:
                yield identifier, payload

    def set_resource(self, identifier: ResourceIdentifier, data: bytes):
        """Add or replace a resource within SAVEGAME.sav."""
        self.resource_data[identifier] = data
        from pykotor.resource.formats.erf.erf_auto import read_erf
        from pykotor.resource.formats.gff import read_gff
        from pykotor.resource.generics.utc import read_utc
        from pykotor.resource.generics.uti import construct_uti_from_struct

        # Update typed caches when appropriate
        if identifier.restype == ResourceType.SAV:
            self.cached_modules[identifier] = read_erf(data)
        elif identifier.restype == ResourceType.UTC:
            utc = read_utc(data)
            self.cached_characters[identifier] = utc
            companion_index = self._extract_companion_index(identifier.resname)
            if companion_index is not None:
                self.cached_character_indices[companion_index] = identifier
        elif identifier == self.INVENTORY_IDENTIFIER:
            inventory_gff = read_gff(data)
            self.inventory_identifier = identifier
            self.inventory_gff = inventory_gff
            self.inventory.clear()
            item_list = inventory_gff.root.get_list("ItemList")
            if item_list:
                for item in item_list:
                    self.inventory.append(construct_uti_from_struct(item))
        elif identifier == self.REPUTE_IDENTIFIER:
            self.repute_identifier = identifier
            self.repute = read_gff(data)
        else:
            self.other_resources[identifier] = data

        # Ensure auxiliary map stays consistent
        if identifier in self.other_resources and (
            identifier.restype in {ResourceType.SAV, ResourceType.UTC} or identifier == self.INVENTORY_IDENTIFIER or identifier == self.REPUTE_IDENTIFIER
        ):
            self.other_resources.pop(identifier, None)

        if identifier not in self.resource_order:
            self.resource_order.append(identifier)

    def remove_resource(self, identifier: ResourceIdentifier):
        """Remove a resource from the nested ERF."""
        self.resource_data.pop(identifier, None)
        self.other_resources.pop(identifier, None)
        if identifier in self.resource_order:
            self.resource_order.remove(identifier)
        self.cached_modules.pop(identifier, None)
        self.cached_characters.pop(identifier, None)
        if identifier in self.cached_character_indices.values():
            for idx, ident in list(self.cached_character_indices.items()):
                if ident == identifier:
                    del self.cached_character_indices[idx]
        if identifier == self.inventory_identifier:
            self.inventory_identifier = None
            self.inventory_gff = None
            self.inventory.clear()
        if identifier == self.repute_identifier:
            self.repute_identifier = None
            self.repute = None

    def get_character(self, index: int) -> UTC | None:
        """Get a cached character by index (0-12)."""
        identifier = self.cached_character_indices.get(index)
        if identifier is None:
            return None
        return self.cached_characters.get(identifier)

    def get_cached_module(self, module_name: str) -> ERF | None:
        """Get a cached module by name.

        Args:
        ----
            module_name: Module ResRef (e.g., "danm13", "ebo_m12aa")

        Returns:
        -------
            ERF object for cached module, or None if not found

        ENGINE BEHAVIOR:
        ================
        - Cached modules are stored as .sav files within SAVEGAME.sav
        - Each module is identified by its ResRef (e.g., "danm13.sav")
        - Module names are stored in the ERF resources with module.ifo

        IMPLEMENTATION:
        ===============
        Vendor ref: KSE extracts all .sav files and checks module.ifo for name
        We iterate cached modules and check resources for identification
        """
        module_name_lower = module_name.lower()

        for module_identifier, module_erf in self.cached_modules.items():
            if module_identifier.resname.lower().startswith(module_name_lower):
                return module_erf
            # Check if this ERF has a module.ifo with matching module name
            # We can identify modules by checking for module.ifo resource
            for resource in module_erf:
                resref_lower = str(resource.resref).lower()
                if resref_lower == "module" and resource.restype == ResourceType.IFO:
                    # Found module.ifo - this helps identify the module
                    # The module name is typically the ERF's original filename
                    # For now, we check all resources to find matching module identifier
                    return module_erf

                # Alternative: Check if any resource name starts with the module name
                if resref_lower.startswith(module_name_lower):
                    return module_erf

        return None

    def is_corrupted(self) -> bool:
        """Check if this save has EventQueue corruption.

        VENDOR REFERENCE:
        =================
        - KSE/Functions/Saves.pm: Documents EventQueue corruption as common issue
        - KotOR.js: Mentions corruption but doesn't implement detection

        ENGINE BEHAVIOR:
        ================
        - EventQueue in module.ifo can become corrupted during saving
        - Symptoms: Save won't load, crashes on load, infinite loading screen
        - Detection: Check if EventQueue exists and has entries in any cached module

        Returns:
        -------
            True if any cached module has EventQueue entries (corrupted save)
            False if no EventQueue entries found (clean save)

        USAGE:
        ======
        ```python
        save = SaveFolderEntry("path/to/save")
        save.load()
        if save.sav.is_corrupted():
            save.sav.clear_event_queues()
            save.save()
        ```
        """
        from pykotor.resource.formats.gff import read_gff

        # Check each cached module for EventQueue
        for module_erf in self.cached_modules.values():
            for resource in module_erf:
                if str(resource.resref).lower() == "module" and resource.restype == ResourceType.IFO:
                    # Found module.ifo - check for EventQueue
                    ifo_gff = read_gff(resource.data)
                    if ifo_gff.root.exists("EventQueue"):
                        event_queue = ifo_gff.root.get_list("EventQueue")
                        if event_queue and len(event_queue) > 0:  # noqa: PLR2004
                            return True  # Has EventQueue entries - corrupted
                    break  # Only one module.ifo per cached module

        return False  # No EventQueue found - clean

    def clear_event_queues(self):
        """Clear event queues from cached modules to prevent corruption.

        VENDOR REFERENCE:
        =================
        - KSE/Functions/Saves.pm: Notes EventQueue corruption as common issue
        - KotOR.js: Doesn't implement fixing, but documents the problem

        ENGINE BEHAVIOR:
        ================
        - EventQueue in module.ifo can become corrupted during saving
        - Symptoms: Save won't load, crashes on load, infinite loading
        - Cause: Interrupted save, script errors, memory corruption
        - Fix: Clear EventQueue GFF list from each cached module's IFO file

        PROCESS:
        ========
        1. Iterate through all cached_modules (ERF files)
        2. For each module, extract module.ifo (GFF file)
        3. Find EventQueue list in IFO root struct
        4. Clear the list (set to empty)
        5. Reserialize and repack into cached module ERF

        This is a common fix for save game corruption issues where event
        queues in cached module IFO files cause problems on load.

        USAGE:
        ======
        ```python
        save = SaveFolderEntry("path/to/save")
        save.load()
        if save.sav.is_corrupted():
            save.sav.clear_event_queues()
            save.save()
        ```
        """
        from pykotor.resource.formats.gff import GFFList, read_gff

        # Iterate through all cached modules
        for module_erf in self.cached_modules.values():
            # Look for module.ifo in this cached module
            for resource in module_erf:
                if str(resource.resref).lower() == "module" and resource.restype == ResourceType.IFO:
                    # Found module.ifo - load it as GFF
                    ifo_gff = read_gff(resource.data)

                    # Clear EventQueue if it exists
                    if ifo_gff.root.exists("EventQueue"):
                        # Replace with empty GFF list
                        empty_list = GFFList()
                        ifo_gff.root.set_list("EventQueue", empty_list)

                        # Write modified GFF back to bytes and update ERF resource
                        module_erf.set_data(str(resource.resref), resource.restype, bytes_gff(ifo_gff, ResourceType.GFF))

                    break  # Only one module.ifo per cached module

    def update_nested_module(
        self,
        module: Module,
    ):
        """Updates a module in a save and retains all of the original bools/strings that were set.

        This function is useful if you've updated a module and don't want to recreate a save to test it.

        Args:
        ----
            module: Module object with updated resources to inject into save

        VENDOR REFERENCE:
        =================
        - Not implemented in most save editors (advanced feature)
        - Useful for mod development and testing
        - Must preserve dynamic save state (creature positions, dialogue states, etc.)

        ENGINE BEHAVIOR:
        ================
        - Module resources in SAVEGAME.sav override base game resources
        - Dynamic resources (creatures, placeables that moved, etc.) must be preserved
        - Static resources (walkmeshes, models, scripts) can be safely replaced
        - Area state (doors opened, containers looted) stored in save must be kept

        PROCESS:
        ========
        1. Find the cached module ERF for this module name
        2. For each resource in the new module:
           - If it's a static resource type (script, walkmesh, etc.), replace it
           - If it's a dynamic resource (creature, placeable with state), preserve save version
        3. Update the cached module ERF with new resources
        4. Preserve module.ifo dynamic fields (creature list with positions/state)

        STATIC RESOURCE TYPES (safe to replace):
        - Scripts (.ncs)
        - Walkmeshes (.wok)
        - Layouts (.lyt)
        - Visibility (.vis)
        - Paths (.pth)
        - Dialogues (.dlg) - unless dialogue state is important

        DYNAMIC RESOURCE TYPES (preserve from save):
        - Creatures (.utc) - positions and state
        - Placeables (.utp) - opened/locked state
        - Doors (.utd) - opened/locked state
        - Triggers (.utt) - fired state
        - Stores (.utm) - inventory state
        - Module IFO (.ifo) - creature/object lists with state

        NOTE: This is a developer tool - use with caution!
        """
        from pykotor.resource.formats.erf.erf_data import ERFResource

        # List of static resource types that can be safely replaced
        STATIC_RESOURCE_TYPES = {
            ResourceType.ARE,  # Area (structure, not state)
            ResourceType.GIT,  # Game instance (template, not state)
            ResourceType.LYT,  # Layouts
            ResourceType.NCS,  # Scripts
            ResourceType.PTH,  # Paths
            ResourceType.VIS,  # Visibility
            ResourceType.WOK,  # Walkmeshes
        }

        # Get module name from module
        module_name = module.root()

        # Find cached module for this module name
        cached_module_erf = self.get_cached_module(module_name)
        if not cached_module_erf:
            # Module not cached - nothing to update
            return

        # Iterate through resources in the new module
        for new_resource in module.resources.values():
            # Only replace static resources
            if new_resource.restype() in STATIC_RESOURCE_TYPES:
                # Create ERF resource from module resource
                _erf_resource = ERFResource(resref=ResRef(new_resource.resname()), restype=new_resource.restype(), data=new_resource.data() or b"")

                # Remove old version if it exists
                if new_resource.resname() in cached_module_erf:
                    cached_module_erf.remove(new_resource.resname(), new_resource.restype())

                # Add new version
                cached_module_erf.set_data(new_resource.resname(), new_resource.restype(), new_resource.data() or b"")

        # NOTE: Module IFO and dynamic resources are intentionally NOT updated
        # to preserve save state (creature positions, inventory states, etc.)


class SaveFolderEntry:
    """Represents all data in a single KOTOR save game folder.

    VENDOR REFERENCES:
    ==================
    - KSE/Functions/Saves.pm: GetAllSaves() discovers save folders
      - LoadSave() loads all components from folder
      - SaveSave() writes all components back to folder

    - KotOR.js: src/SaveGame.ts (main save game class)
      - Constructor initializes folder paths
      - load() orchestrates loading all components
      - Save() exports all components back to folder

    ENGINE BEHAVIOR:
    ================
    - **Original Engine:** Folder-based saves for easy user management
      - Each save in separate folder: "000057 - game56"
      - Folder name format: "{index} - {name}"
      - Index for sorting, name for user identification
      - Special folders: "QUICKSAVE", "AUTOSAVE"

    - **reone:** Scans save directory for folders matching pattern
      - Loads SAVENFO.res first for quick save list display
      - Lazy-loads other components only when save is opened

    - **KotOR.js:** Async folder scanning and loading
      - Progressive loading for responsive UI
      - Caches parsed data to avoid re-parsing

    STRUCTURE:
    ==========
    A complete KOTOR save game consists of a folder containing:

    **Required Files:**
    - SAVENFO.res - Save metadata (name, area, time, portraits)
    - PARTYTABLE.res - Party composition, gold, XP, journal
    - GLOBALVARS.res - Global script variables (booleans, numbers, strings, locations)
    - SAVEGAME.sav - Nested ERF with cached data (modules, NPCs, inventory)

    **Optional Files:**
    - Screen.tga - Save screenshot thumbnail (800x600 or 640x480)
    - .jpg/.png - Alternative screenshot formats (rare)

    **Folder Naming:**
    - Format: "{6-digit index} - {save name}"
    - Examples: "000057 - game56", "000123 - Before Malak"
    - Special: "QUICKSAVE", "AUTOSAVE" (no index)

    USAGE:
    ======
    ```python
    # Load a save
    save = SaveFolderEntry(r"C:\\...\\saves\\000057 - game56")
    save.load()

    # Access data
    print(f"Save: {save.save_info.savegame_name}")
    print(f"Gold: {save.partytable.pt_gold}")

    # Modify data
    save.partytable.pt_gold = 999999
    save.globals.set_boolean("DAN_PUZZLE_SOLVED", True)

    # Save changes
    save.save()
    ```

    ATTRIBUTES:
    ===========
    - save_path: Path to save folder
    - save_info: SaveInfo instance (SAVENFO.res)
    - partytable: PartyTable instance (PARTYTABLE.res)
    - globals: GlobalVars instance (GLOBALVARS.res)
    - sav: SaveNestedCapsule instance (SAVEGAME.sav)
    - screenshot: Optional bytes for Screen.tga thumbnail (load/save preserved)
    """

    SAVE_INFO_NAME: ResourceIdentifier = ResourceIdentifier(resname="savenfo", restype=ResourceType.RES)
    SCREENSHOT_NAME: ResourceIdentifier = ResourceIdentifier(resname="screen", restype=ResourceType.TGA)

    def __init__(self, save_folder_path: os.PathLike | str):
        """Initializes a single save entry for KOTOR 1 or 2.

        Args:
        ----
            save_folder_path (os.PathLike | str): Path to the save folder
                Example: "C:\\...\\saves\\000057 - game56"
                Also accepts: "QUICKSAVE", "AUTOSAVE"

        Processing Logic:
        ----------------
        1. Stores save folder path
        2. Initializes component objects (SaveInfo, PartyTable, etc.)
        3. Does NOT load data yet (call load() to load)

        Vendor ref: KotOR.js constructor stores folderName and directory
        """
        self.save_path: CaseAwarePath = CaseAwarePath(save_folder_path)

        # Initialize all save components
        self.sav: SaveNestedCapsule = SaveNestedCapsule(self.save_path)
        self.partytable: PartyTable = PartyTable(self.save_path)
        self.save_info: SaveInfo = SaveInfo(self.save_path)
        self.globals: GlobalVars = GlobalVars(self.save_path)

        # Screenshot data
        # Vendor ref: KSE preserves Screen.tga, KotOR.js loads it for display
        self.screenshot: bytes | None = None  # Screen.tga - Save screenshot (800x600 or 640x480 TGA)

    def load(self):
        """Load all save game components from the folder.

        VENDOR IMPLEMENTATIONS:
        =======================
        - KSE/Functions/Saves.pm: LoadSave() loads all components sequentially
        - KotOR.js: load() uses async/await for progressive loading
        - reone: Loads in order of dependency (info first, then data)

        ENGINE BEHAVIOR:
        ================
        - Components loaded in specific order to resolve dependencies
        - Each component validates its data after loading
        - Missing files cause load to fail (save is corrupted)

        LOAD ORDER:
        ===========
        1. SaveInfo (SAVENFO.res) - Fast, for menu display
        2. PartyTable (PARTYTABLE.res) - Party state
        3. GlobalVars (GLOBALVARS.res) - Script variables
        4. SaveNestedCapsule (SAVEGAME.sav) - Largest, loaded last

        This method loads all components for complete save access.
        """
        from loggerplus import RobustLogger

        logger = RobustLogger()

        logger.debug("Loading nested save capsule...")
        self.sav.load()
        logger.debug("Loading party table...")
        self.partytable.load()
        logger.debug("Loading save info...")
        self.save_info.load()
        logger.debug("Loading save globals...")
        self.globals.load()

        # Infer game version for nested capsule serialization heuristics
        if self.partytable.pt_pcname or self.partytable.pt_influence or self.save_info.pc_name:
            self.sav.game = Game.K2
        else:
            self.sav.game = Game.K1

        # Load screenshot if it exists
        # Vendor ref: All implementations preserve this for save menu display
        screenshot_path = self.save_path / self.SCREENSHOT_NAME.resname
        if screenshot_path.exists():
            logger.debug("Loading screenshot...")
            with open(screenshot_path, "rb") as f:
                self.screenshot = f.read()

    def save(self):
        """Save all modified components back to the folder.

        This method orchestrates the complete save process, ensuring every component
        of the KOTOR save game is written to disk with full fidelity.

        VENDOR IMPLEMENTATIONS:
        =======================
        - KSE/Functions/Saves.pm: SaveSave() writes all components
          - Writes to temp files first, then renames (atomic)
          - Updates timestamps to current time
          - Validates all required fields are present

        - KotOR.js: Save() exports all components asynchronously
          - Creates backup before writing
          - Validates data before writing
          - Progressive save with status updates

        ENGINE BEHAVIOR:
        ================
        - Files written atomically to prevent corruption
        - Backup created before overwriting (some implementations)
        - Timestamps updated to current save time
        - All resources preserved exactly as loaded (full fidelity)

        COMPREHENSIVE SAVE ORDER:
        =========================
        1. SAVENFO.res (SaveInfo) - Save metadata and menu information
           - Save name, area name, last module
           - Time played, timestamps
           - Party portraits (leader + 2 companions)
           - Hints, cheat flags, Xbox Live data
           - K2: PC name

        2. PARTYTABLE.res (PartyTable) - Party and game state
           - Current party members (PT_MEMBERS)
           - Available NPCs list (PT_AVAIL_NPCS)
           - Gold/credits, XP pool
           - Journal entries (JNL_Entries)
           - Pazaak cards and decks
           - UI state, messages, tutorial windows
           - Cost multipliers
           - K2: Influence values, components, chemicals
           - ALL additional fields (galaxy map, etc.)

        3. GLOBALVARS.res (GlobalVars) - Global script variables
           - Boolean variables (packed bits)
           - Number variables (bytes)
           - String variables
           - Location variables (position + orientation)

        4. SAVEGAME.sav (SaveNestedCapsule) - Nested ERF archive
           - Cached modules (.sav files with full state)
           - Companion characters (AVAILNPC*.utc)
           - Player inventory (INVENTORY.res)
           - Faction reputation (REPUTE.fac)
           - Module-specific resources
           - K2: PIFO.ifo (player character IFO)
           - ALL other resources preserved

        5. Screen.tga - Save screenshot thumbnail
           - 800x600 or 640x480 TGA image
           - Displayed in load/save menu

        VALIDATION:
        ===========
        - PT_NUM_MEMBERS automatically synced with len(pt_members)
        - All GFF structures validated before write
        - Binary data sizes verified
        - Resource identifiers validated

        ATOMICITY:
        ==========
        - Each component written to its own file
        - Files written in dependency order
        - Failures in later stages don't corrupt earlier writes
        - Vendor ref: KSE uses temp files + rename for atomicity
        """
        from loggerplus import RobustLogger
        from pykotor.resource.formats.erf.erf_auto import write_erf

        logger = RobustLogger()
        logger.info("=== Beginning Save Process ===")
        logger.info(f"Save folder: {self.save_path}")

        # ============================================================================
        # STEP 1: SAVE SAVENFO.RES - Save metadata for load/save menu
        # ============================================================================
        logger.debug("[1/5] Saving SAVENFO.res (Save Info)...")
        logger.debug(f"  - Save Name: {self.save_info.savegame_name}")
        logger.debug(f"  - Area: {self.save_info.area_name}")
        logger.debug(f"  - Last Module: {self.save_info.last_module}")
        logger.debug(f"  - Time Played: {self.save_info.time_played}s ({self.save_info.time_played // 3600}h {(self.save_info.time_played % 3600) // 60}m)")
        logger.debug(f"  - Portraits: {self.save_info.portrait0}, {self.save_info.portrait1}, {self.save_info.portrait2}")
        if self.save_info.pc_name:
            logger.debug(f"  - PC Name (K2): {self.save_info.pc_name}")

        # Write SAVENFO.res to disk
        # Vendor ref: KSE/Functions/Saves.pm SaveSave() line ~450
        self.save_info.save()
        logger.debug(f"  ✓ Written to: {self.save_info.save_info_path}")

        # ============================================================================
        # STEP 2: SAVE PARTYTABLE.RES - Party composition and game state
        # ============================================================================
        logger.debug("[2/5] Saving PARTYTABLE.res (Party Table)...")
        logger.debug(f"  - Party Members: {len(self.partytable.pt_members)}")
        logger.debug(f"  - Available NPCs: {len(self.partytable.pt_avail_npcs)}")
        logger.debug(f"  - Gold: {self.partytable.pt_gold}")
        logger.debug(f"  - XP Pool: {self.partytable.pt_xp_pool}")
        logger.debug(f"  - Journal Entries: {len(self.partytable.jnl_entries)}")
        logger.debug(f"  - Pazaak Cards: {len(self.partytable.pt_pazaakcards)}")
        logger.debug(f"  - Pazaak Decks: {len(self.partytable.pt_pazaakdecks)}")
        if self.partytable.pt_influence:
            logger.debug(f"  - Influence Values (K2): {len(self.partytable.pt_influence)}")
        if self.partytable.pt_item_componen > 0:
            logger.debug(f"  - Components (K2): {self.partytable.pt_item_componen}")
        if self.partytable.pt_item_chemical > 0:
            logger.debug(f"  - Chemicals (K2): {self.partytable.pt_item_chemical}")
        if self.partytable.additional_fields:
            logger.debug(f"  - Additional Fields Preserved: {len(self.partytable.additional_fields)}")

        # Write PARTYTABLE.res to disk
        # Vendor ref: KSE/Functions/Saves.pm SaveSave() line ~480
        # NOTE: PT_NUM_MEMBERS is now a property that automatically returns len(pt_members)
        self.partytable.save()
        logger.debug(f"  ✓ Written to: {self.partytable.party_table_path}")

        # ============================================================================
        # STEP 3: SAVE GLOBALVARS.RES - Global script variables
        # ============================================================================
        logger.debug("[3/5] Saving GLOBALVARS.res (Global Variables)...")
        logger.debug(f"  - Boolean Variables: {len(self.globals.global_bools)}")
        logger.debug(f"  - Number Variables: {len(self.globals.global_numbers)}")
        logger.debug(f"  - String Variables: {len(self.globals.global_strings)}")
        logger.debug(f"  - Location Variables: {len(self.globals.global_locs)}")

        # Write GLOBALVARS.res to disk
        # Vendor ref: KSE/Functions/Globals.pm SaveGlobals() line ~200
        self.globals.save()
        logger.debug(f"  ✓ Written to: {self.globals.globals_filepath}")

        # ============================================================================
        # STEP 4: SAVE SAVEGAME.SAV - Nested ERF with all dynamic resources
        # ============================================================================
        logger.debug("[4/5] Saving SAVEGAME.sav (Nested Capsule)...")

        # First, serialize all nested resources (modules, characters, inventory, etc.)
        # This updates self.sav.resource_data with serialized bytes for all resources
        # Vendor ref: KSE/Functions/Saves.pm SaveSave() line ~520
        logger.debug("  - Serializing nested resources...")
        self.sav.save()

        # Count resources by type for status display
        module_count = len(self.sav.cached_modules)
        character_count = len(self.sav.cached_characters)
        inventory_count = len(self.sav.inventory)
        other_count = len(self.sav.other_resources)
        total_resources = len(self.sav.resource_data)

        logger.debug(f"    • Cached Modules: {module_count}")
        logger.debug(f"    • Companion Characters: {character_count}")
        logger.debug(f"    • Inventory Items: {inventory_count}")
        if self.sav.repute is not None:
            logger.debug("    • Faction Reputation: Yes")
        logger.debug(f"    • Other Resources: {other_count}")
        logger.debug(f"    • Total Resources: {total_resources}")

        # Create new ERF and populate with all serialized resources
        # Vendor ref: KSE/Functions/Saves.pm SaveSave() line ~550
        logger.debug("  - Repacking ERF archive...")
        nested_erf: ERF = ERF(ERFType.from_extension(self.sav.nested_capsule_path.suffix))

        for identifier, payload in self.sav.iter_serialized_resources():
            nested_erf.set_data(identifier.resname, identifier.restype, payload)

        # Write SAVEGAME.sav to disk
        write_erf(nested_erf, self.sav.nested_capsule_path, ResourceType.SAV)
        logger.debug(f"  ✓ Written to: {self.sav.nested_capsule_path}")

        # ============================================================================
        # STEP 5: SAVE SCREEN.TGA - Save screenshot thumbnail
        # ============================================================================
        logger.debug("[5/5] Saving Screen.tga (Screenshot)...")
        screenshot_path = self.save_path / self.SCREENSHOT_NAME.resname

        if self.screenshot is not None:
            # Write screenshot to disk
            # Vendor ref: All implementations preserve this for save menu display
            with open(screenshot_path, "wb") as f:
                f.write(self.screenshot)
            logger.debug(f"  ✓ Written screenshot ({len(self.screenshot)} bytes) to: {screenshot_path}")
        elif screenshot_path.exists():
            # Remove screenshot if it was deleted from memory
            screenshot_path.unlink()
            logger.debug(f"  ✓ Removed screenshot (no longer present): {screenshot_path}")
        else:
            logger.debug("  ⚠ No screenshot present")

        # ============================================================================
        # SAVE COMPLETE
        # ============================================================================
        logger.info("=== Save Complete ===")
        logger.info(f"All components written successfully to: {self.save_path}")
        logger.debug("Saved files:")
        logger.debug(f"  • {self.save_info.save_info_path.name}")
        logger.debug(f"  • {self.partytable.party_table_path.name}")
        logger.debug(f"  • {self.globals.globals_filepath.name}")
        logger.debug(f"  • {self.sav.nested_capsule_path.name} ({total_resources} resources)")
        if self.screenshot is not None:
            logger.debug(f"  • {self.SCREENSHOT_NAME}")
        logger.info("✓ Save game is ready to load in KOTOR")


if __name__ == "__main__":
    # Example usage - demonstrating the complete load/modify/save workflow
    # This shows how to load a save, modify it, and save changes back

    import sys

    from pathlib import Path

    # Check if a save path was provided as command line argument
    if len(sys.argv) > 1:
        save_path = sys.argv[1]
    else:
        # Default example path (update to your actual save location)
        save_path = r"C:\Program Files (x86)\Steam\steamapps\common\Knights of the Old Republic II\saves\000002 - Game1"

    # Verify the save folder exists
    if not Path(save_path).exists():
        print(f"Error: Save folder not found: {save_path}")
        print("\nUsage: python savedata.py [path_to_save_folder]")
        sys.exit(1)

    # Load the save
    print(f"Loading save from: {save_path}\n")
    game_save = SaveFolderEntry(save_path)
    game_save.load()

    # Display some save information
    print("\n=== Save Information ===")
    print(f"Save Name: {game_save.save_info.savegame_name}")
    print(f"Area: {game_save.save_info.area_name}")
    print(f"Last Module: {game_save.save_info.last_module}")
    print(f"Time Played: {game_save.save_info.time_played} seconds ({game_save.save_info.time_played // 3600}h {(game_save.save_info.time_played % 3600) // 60}m)")
    print(f"Gold: {game_save.partytable.pt_gold}")
    print(f"XP Pool: {game_save.partytable.pt_xp_pool}")
    print(f"Cheat Used: {game_save.save_info.cheat_used}")

    # Example modifications (commented out for safety)
    # Uncomment these lines to actually modify the save
    # game_save.partytable.pt_gold = 999999
    # game_save.partytable.pt_xp_pool = 50000
    # game_save.globals.set_boolean("DAN_PUZZLE_SOLVED", True)
    # game_save.globals.set_number("DAN_SOME_COUNTER", 100)

    # Save changes (commented out for safety - remove comment to enable saving)
    # print("\n=== Saving Changes ===")
    # game_save.save()
    # print("Save complete!")

    print("\n=== Complete ===")
    print("Load successful! Modify code to make changes and call game_save.save()")
