"""Batch patching utilities for KOTOR resources.

This module provides functions for:
- Patching GFF files (translating LocalizedStrings, setting unskippable dialogs)
- Converting GFF files between K1 and TSL formats
- Converting TGA/TPC textures
- Processing TLK files
- Batch patching installations, folders, and files

References:
----------
        Based on swkotor.exe resource formats:
        - CResGFF::CreateGFFFile @ 0x00411260 - Creates new GFF file
        - CResGFF::WriteGFFFile @ 0x00413030 - Writes GFF data to file
        - CTlkTable::AddFile @ 0x0041d920 - Adds TLK file to table
        - CExoEncapsulatedFile::CExoEncapsulatedFile @ 0x0040ef90 - ERF/RIM file handling
        Tools/BatchPatcher/src/batchpatcher/__main__.py - Original implementation


"""

from __future__ import annotations

import concurrent.futures

from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING, Any

from pykotor.common.alien_sounds import ALIEN_SOUNDS
from pykotor.common.language import LocalizedString
from pykotor.common.misc import Game, ResRef
from pykotor.common.stream import BinaryWriter
from pykotor.extract.capsule import Capsule, LazyCapsule
from pykotor.extract.file import FileResource, ResourceIdentifier
from pykotor.extract.installation import Installation
from pykotor.resource.formats.erf.erf_auto import write_erf
from pykotor.resource.formats.erf.erf_data import ERF, ERFType
from pykotor.resource.formats.gff import (
    GFF,
    GFFContent,
    GFFFieldType,
    GFFList,
    GFFStruct,
    read_gff,
)
from pykotor.resource.formats.gff.gff_auto import bytes_gff
from pykotor.resource.formats.rim.rim_auto import write_rim
from pykotor.resource.formats.rim.rim_data import RIM
from pykotor.resource.formats.tlk import read_tlk, write_tlk
from pykotor.resource.formats.tpc.io_tga import TPCTGAReader, TPCTGAWriter
from pykotor.resource.formats.tpc.io_tpc import TPCBinaryReader, TPCBinaryWriter
from pykotor.resource.formats.tpc.tpc_auto import bytes_tpc
from pykotor.resource.formats.tpc.tpc_data import TPC
from pykotor.resource.generics.are import read_are, write_are
from pykotor.resource.generics.dlg import read_dlg, write_dlg
from pykotor.resource.generics.git import read_git, write_git
from pykotor.resource.generics.jrl import read_jrl, write_jrl
from pykotor.resource.generics.pth import read_pth, write_pth
from pykotor.resource.generics.utc import read_utc, write_utc
from pykotor.resource.generics.utd import read_utd, write_utd
from pykotor.resource.generics.ute import read_ute, write_ute
from pykotor.resource.generics.uti import read_uti, write_uti
from pykotor.resource.generics.utm import read_utm, write_utm
from pykotor.resource.generics.utp import read_utp, write_utp
from pykotor.resource.generics.uts import read_uts, write_uts
from pykotor.resource.generics.utt import read_utt, write_utt
from pykotor.resource.generics.utw import read_utw, write_utw
from pykotor.resource.salvage import validate_capsule
from pykotor.resource.type import ResourceType
from pykotor.tools.encoding import decode_bytes_with_fallbacks
from pykotor.tools.misc import is_any_erf_type_file, is_capsule_file
from pykotor.tools.path import CaseAwarePath

if TYPE_CHECKING:
    from collections.abc import Callable

    from pykotor.common.language import Language
    from pykotor.resource.formats.tlk import TLK
    from pykotor.resource.formats.tlk.tlk_data import TLKEntry


class PatchingConfig:
    """Configuration for batch patching operations."""

    def __init__(self):
        self.translate: bool = False
        self.set_unskippable: bool = False
        self.convert_tga: str | None = None  # "TGA to TPC", "TPC to TGA", or None
        self.k1_convert_gffs: bool = False
        self.tsl_convert_gffs: bool = False
        self.always_backup: bool = True
        self.max_threads: int = 2
        self.translator: Any = None  # Translator instance
        self.log_callback: Callable[[str], None] | None = None

    def is_patching(self) -> bool:
        """Check if any patching operation is enabled."""
        return bool(self.translate or self.set_unskippable or self.convert_tga or self.k1_convert_gffs or self.tsl_convert_gffs)


def log_message(config: PatchingConfig, message: str) -> None:
    """Log a message using the configured callback."""
    if config.log_callback:
        config.log_callback(message)


def _serialize_patched_resource(resource: FileResource, patched_data: GFF | TPC) -> tuple[ResourceType, bytes]:
    """Serialize patched resource data for writing to capsules/files."""
    if isinstance(patched_data, GFF):
        return resource.restype(), bytes_gff(patched_data)
    return ResourceType.TPC, bytes(bytes_tpc(patched_data))


def _ensure_processed_files(processed_files: set[Path] | None) -> set[Path]:
    """Return a mutable processed-files set for patching workflows."""
    return processed_files if processed_files is not None else set()


def patch_nested_gff(
    gff_struct: GFFStruct,
    gff_content: GFFContent,
    gff: GFF,
    config: PatchingConfig,
    current_path: Path | None = None,
    made_change: bool = False,
    alien_vo_count: int = -1,
) -> tuple[bool, int]:
    """Recursively patch GFF structures.

    Args:
    ----
        gff_struct: The GFF struct to patch
        gff_content: The GFF content type
        gff: The parent GFF object
        config: Patching configuration
        current_path: Current path in the GFF structure
        made_change: Whether any changes have been made
        alien_vo_count: Count of alien voice-overs found

    Returns:
    -------
        Tuple of (made_change: bool, alien_vo_count: int)
    """
    if gff_content != GFFContent.DLG and not config.translate:
        return False, alien_vo_count

    if gff_content == GFFContent.DLG and config.set_unskippable:
        sound_raw = gff_struct.acquire("Sound", None)  # type: ignore[arg-type]
        sound: ResRef | None = sound_raw if isinstance(sound_raw, ResRef) else None
        sound_str = "" if sound is None else str(sound).strip().lower()
        if sound and sound_str.strip() and sound_str in ALIEN_SOUNDS:
            alien_vo_count += 1

    current_path = current_path or Path("GFFRoot")
    for label, ftype, value in gff_struct:
        if label.lower() == "mod_name":
            continue
        child_path: Path = current_path / label

        if ftype == GFFFieldType.Struct:
            assert isinstance(value, GFFStruct), f"Not a GFFStruct instance: {value.__class__.__name__}: {value}"  # noqa: S101
            result_made_change, alien_vo_count = patch_nested_gff(value, gff_content, gff, config, child_path, made_change, alien_vo_count)
            made_change |= result_made_change
            continue

        if ftype == GFFFieldType.List:
            assert isinstance(value, GFFList), f"Not a GFFList instance: {value.__class__.__name__}: {value}"  # noqa: S101
            result_made_change, alien_vo_count = recurse_through_list(value, gff_content, gff, config, child_path, made_change, alien_vo_count)
            made_change |= result_made_change
            continue

        if ftype == GFFFieldType.LocalizedString and config.translate:
            assert isinstance(value, LocalizedString), f"{value.__class__.__name__}: {value}"  # noqa: S101
            log_message(
                config,
                f"Translating CExoLocString at {child_path} to {config.translator.to_lang.name if config.translator else 'unknown'}",
            )
            made_change |= translate_locstring(value, config)
    return made_change, alien_vo_count


def recurse_through_list(
    gff_list: GFFList,
    gff_content: GFFContent,
    gff: GFF,
    config: PatchingConfig,
    current_path: Path | None = None,
    made_change: bool = False,
    alien_vo_count: int = -1,
) -> tuple[bool, int]:
    """Recursively process GFF lists.

    Args:
    ----
        gff_list: The GFF list to process
        gff_content: The GFF content type
        gff: The parent GFF object
        config: Patching configuration
        current_path: Current path in the GFF structure
        made_change: Whether any changes have been made
        alien_vo_count: Count of alien voice-overs found

    Returns:
    -------
        Tuple of (made_change: bool, alien_vo_count: int)
    """
    current_path = current_path or Path("GFFListRoot")
    for list_index, gff_struct in enumerate(gff_list):
        result_made_change, alien_vo_count = patch_nested_gff(gff_struct, gff_content, gff, config, current_path / str(list_index), made_change, alien_vo_count)
        made_change |= result_made_change
    return made_change, alien_vo_count


def translate_locstring(locstring: LocalizedString, config: PatchingConfig) -> bool:
    """Translate a LocalizedString using the configured translator.

    Args:
    ----
        locstring: The LocalizedString to translate
        config: Patching configuration with translator

    Returns:
    -------
        True if changes were made, False otherwise
    """
    if not config.translator:
        return False

    made_change = False
    new_substrings: dict[int, str] = deepcopy(locstring._substrings)  # noqa: SLF001
    for lang, gender, text in locstring:
        if text is not None and text.strip():
            translated_text = config.translator.translate(text, from_lang=lang)
            log_message(config, f"Translated {text} --> {translated_text}")
            substring_id = LocalizedString.substring_id(config.translator.to_lang, gender)
            new_substrings[substring_id] = str(translated_text)
            made_change = True
    locstring._substrings = new_substrings  # noqa: SLF001
    return made_change


def fix_encoding(text: str, encoding: str) -> str:
    """Fix text encoding by re-encoding and decoding.

    Args:
    ----
        text: Text to fix
        encoding: Target encoding

    Returns:
    -------
        Fixed text
    """
    return text.encode(encoding=encoding, errors="ignore").decode(encoding=encoding, errors="ignore").strip()


def convert_gff_game(
    from_game: Game,
    resource: FileResource,
    config: PatchingConfig,
) -> None:
    """Convert a GFF resource from one game format to another.

    Args:
    ----
        from_game: Source game (K1 or K2)
        resource: The resource to convert
        config: Patching configuration
    """
    to_game = Game.K2 if from_game.is_k1() else Game.K1
    new_name = resource.filename()
    converted_data: Path | bytearray = bytearray()
    if not resource.inside_capsule:
        new_name = f"{resource.resname()}_{to_game.name!s}.{resource.restype()!s}" if config.always_backup else resource.filename()
        converted_data = resource.filepath().with_name(new_name)
        savepath = converted_data
    else:
        savepath = resource.filepath()

    log_message(config, f"Converting {resource.path_ident().parent}/{resource.path_ident().name} to {to_game.name}")
    generic: Any
    try:
        # Mapping of ResourceType to (read_func, write_func) pairs for GFF conversion
        gff_converters = {
            ResourceType.ARE: (read_are, write_are),
            ResourceType.DLG: (read_dlg, write_dlg),
            ResourceType.GIT: (read_git, write_git),
            ResourceType.JRL: (read_jrl, write_jrl),
            ResourceType.PTH: (read_pth, write_pth),
            ResourceType.UTC: (read_utc, write_utc),
            ResourceType.UTD: (read_utd, write_utd),
            ResourceType.UTE: (read_ute, write_ute),
            ResourceType.UTI: (read_uti, write_uti),
            ResourceType.UTM: (read_utm, write_utm),
            ResourceType.UTP: (read_utp, write_utp),
            ResourceType.UTS: (read_uts, write_uts),
            ResourceType.UTT: (read_utt, write_utt),
            ResourceType.UTW: (read_utw, write_utw),
        }

        restype = resource.restype()
        if restype in gff_converters:
            read_func, write_func = gff_converters[restype]
            generic = read_func(resource.data(), offset=0, size=resource.size())
            write_func(generic, converted_data, to_game)
        else:
            log_message(config, f"Unsupported gff: {resource.identifier()}")
    except (OSError, ValueError):
        log_message(config, f"Corrupted GFF: '{resource.path_ident()}', skipping...")
        if not resource.inside_capsule:
            return
        log_message(config, f"Corrupted GFF: '{resource.path_ident()}', will start validation process of '{resource.filepath().name}'...")
        new_erfrim = validate_capsule(resource.filepath(), strict=True, game=to_game)
        if isinstance(new_erfrim, ERF):
            log_message(config, f"Saving salvaged ERF to '{savepath}'")
            write_erf(new_erfrim, savepath)
            return
        if isinstance(new_erfrim, RIM):
            log_message(config, f"Saving salvaged RIM to '{savepath}'")
            write_rim(new_erfrim, savepath)
            return
        log_message(config, f"Whole erf/rim is corrupt: {resource!r}")
        return

    if isinstance(converted_data, bytearray):
        log_message(config, f"Saving conversions in ERF/RIM at '{savepath}'")
        lazy_capsule = LazyCapsule(savepath, create_nonexisting=True)
        lazy_capsule.delete(resource.resname(), resource.restype())
        lazy_capsule.add(resource.resname(), resource.restype(), bytes(converted_data))


def process_translations(
    tlk: TLK,
    from_lang: Language,
    config: PatchingConfig,
) -> None:
    """Process translations for a TLK file.

    Args:
    ----
        tlk: The TLK file to translate
        from_lang: Source language
        config: Patching configuration with translator
    """
    if not config.translator:
        return

    def translate_entry(tlkentry: TLKEntry, from_lang: Language) -> tuple[str, str]:
        text = tlkentry.text
        if not text.strip() or text.isdigit():
            return text, ""
        if "Do not translate this text" in text:
            return text, text
        if "actual text to be translated" in text:
            return text, text
        return text, config.translator.translate(text, from_lang=from_lang)

    with concurrent.futures.ThreadPoolExecutor(max_workers=config.max_threads) as executor:
        future_to_strref: dict[concurrent.futures.Future[tuple[str, str]], int] = {executor.submit(translate_entry, tlkentry, from_lang): strref for strref, tlkentry in tlk}

        for future in concurrent.futures.as_completed(future_to_strref):
            strref: int = future_to_strref[future]
            try:
                original_text, translated_text = future.result()
                if translated_text.strip():
                    translated_text = fix_encoding(translated_text, config.translator.to_lang.get_encoding())
                    tlk.replace(strref, translated_text)
                    log_message(config, f"#{strref} Translated {original_text} --> {translated_text}")
            except Exception as exc:  # pylint: disable=W0718  # noqa: BLE001
                log_message(config, f"tlk strref {strref} generated an exception: {exc}")


def patch_resource(
    resource: FileResource,
    config: PatchingConfig,
    processed_files: set[Path] | None = None,
) -> GFF | TPC | None:
    """Patch a single resource (GFF, TPC, TLK).

    Args:
    ----
        resource: The resource to patch
        config: Patching configuration
        processed_files: Set to track processed files (optional)

    Returns:
    -------
        Patched GFF or TPC object, or None if no changes or error
    """
    processed_files = _ensure_processed_files(processed_files)

    # Handle TLK translation
    if resource.restype().extension.lower() == "tlk" and config.translate and config.translator:
        tlk: TLK | None = None
        log_message(config, f"Loading TLK '{resource.filepath()}'")
        try:
            tlk = read_tlk(resource.data())
        except Exception:  # pylint: disable=W0718  # noqa: BLE001
            log_message(config, f"[Error] loading TLK '{resource.identifier()}' at '{resource.filepath()}'!")
            return None

        from_lang: Language = tlk.language
        new_filename_stem = f"{resource.resname()}_{config.translator.to_lang.get_bcp47_code() or 'UNKNOWN'}"
        new_file_path = resource.filepath().parent / f"{new_filename_stem}.{resource.restype().extension}"
        tlk.language = config.translator.to_lang
        log_message(config, f"Translating TalkTable resource at {resource.filepath()} to {config.translator.to_lang.name}")
        process_translations(tlk, from_lang, config)
        write_tlk(tlk, new_file_path)
        processed_files.add(new_file_path)

    # Handle TGA to TPC conversion
    if resource.restype().extension.lower() == "tga" and config.convert_tga == "TGA to TPC":
        log_message(config, f"Converting TGA at {resource.path_ident()} to TPC...")
        try:
            return TPCTGAReader(resource.data()).load()
        except Exception:  # pylint: disable=W0718  # noqa: BLE001
            log_message(config, f"[Error] loading TGA '{resource.identifier()}' at '{resource.filepath()}'!")
            return None

    # Handle TPC to TGA conversion
    if resource.restype().extension.lower() == "tpc" and config.convert_tga == "TPC to TGA":
        log_message(config, f"Converting TPC at {resource.path_ident()} to TGA...")
        try:
            return TPCBinaryReader(resource.data()).load()
        except Exception:  # pylint: disable=W0718  # noqa: BLE001
            log_message(config, f"[Error] loading TPC '{resource.identifier()}' at '{resource.filepath()}'!")
            return None

    # Handle GFF files
    if resource.restype().name.upper() in {x.name for x in GFFContent}:
        if config.k1_convert_gffs and not resource.inside_capsule:
            convert_gff_game(Game.K2, resource, config)
        if config.tsl_convert_gffs and not resource.inside_capsule:
            convert_gff_game(Game.K1, resource, config)

        gff: GFF | None = None
        try:
            gff = read_gff(resource.data())
            alien_owner: str | None = None
            if gff.content is GFFContent.DLG and config.set_unskippable:
                skippable = gff.root.acquire("Skippable", None)
                if skippable not in {0, "0"}:
                    conversationtype = gff.root.acquire("ConversationType", None)
                    if conversationtype not in {"1", 1}:
                        alien_owner = gff.root.acquire("AlienRaceOwner", None)  # TSL only

            made_change, alien_vo_count = patch_nested_gff(
                gff.root,
                gff_content=gff.content,
                gff=gff,
                config=config,
                current_path=resource.path_ident(),
            )

            if config.set_unskippable and alien_owner in {0, "0", None} and alien_vo_count != -1 and alien_vo_count < 3 and gff.content is GFFContent.DLG:
                skippable = gff.root.acquire("Skippable", None)
                if skippable not in {0, "0"}:
                    conversationtype = gff.root.acquire("ConversationType", None)
                    if conversationtype not in {"1", 1}:
                        log_message(config, f"Setting dialog {resource.path_ident()} as unskippable")
                        made_change = True
                        gff.root.set_uint8("Skippable", 0)

            if made_change:
                return gff
        except Exception as e:  # pylint: disable=W0718  # noqa: BLE001
            log_message(config, f"[Error] cannot load corrupted GFF '{resource.path_ident()}'!")
            if not isinstance(e, (OSError, ValueError)):
                log_message(config, f"[Error] loading GFF '{resource.path_ident()}'!")
            return None

    return None


def patch_and_save_noncapsule(
    resource: FileResource,
    config: PatchingConfig,
    savedir: Path | None = None,
) -> None:
    """Patch and save a non-capsule resource.

    Args:
    ----
        resource: The resource to patch
        config: Patching configuration
        savedir: Optional directory to save to
    """
    patched_data: GFF | TPC | None = patch_resource(resource, config)
    if patched_data is None:
        return

    capsule = Capsule(resource.filepath()) if resource.inside_capsule else None

    if isinstance(patched_data, GFF):
        new_data = bytes_gff(patched_data)

        new_gff_filename = resource.filename()
        if config.translate and config.translator:
            new_gff_filename = f"{resource.resname()}_{config.translator.to_lang.get_bcp47_code()}.{resource.restype().extension}"

        new_path = (savedir or resource.filepath().parent) / new_gff_filename
        if new_path.exists() and savedir:
            log_message(config, f"Skipping '{new_gff_filename}', already exists on disk")
        else:
            log_message(config, f"Saving patched gff to '{new_path}'")
            BinaryWriter.dump(new_path, new_data)

    elif isinstance(patched_data, TPC):
        if capsule is None:
            txi_file = resource.filepath().with_suffix(".txi")
            if txi_file.is_file():
                log_message(config, "Embedding TXI information...")
                data: bytes = txi_file.read_bytes()
                txi_text: str = decode_bytes_with_fallbacks(data)
                patched_data.txi = txi_text
        else:
            txi_data = capsule.resource(resource.resname(), ResourceType.TXI)
            if txi_data is not None:
                log_message(config, "Embedding TXI information from resource found in capsule...")
                txi_text = decode_bytes_with_fallbacks(txi_data)
                patched_data.txi = txi_text

        new_path = (savedir or resource.filepath().parent) / resource.resname()
        if config.convert_tga == "TGA to TPC":
            new_path = new_path.with_suffix(".tpc")
            TPCBinaryWriter(patched_data, new_path).write()
        else:
            new_path = new_path.with_suffix(".tga")
            TPCTGAWriter(patched_data, new_path).write()

        if new_path.exists():
            log_message(config, f"Skipping '{new_path}', already exists on disk")


def patch_capsule_file(
    c_file: Path,
    config: PatchingConfig,
    processed_files: set[Path] | None = None,
) -> None:
    """Patch a capsule file (ERF/RIM).

    Args:
    ----
        c_file: Path to the capsule file
        config: Patching configuration
        processed_files: Set to track processed files (optional)
    """
    processed_files = _ensure_processed_files(processed_files)

    log_message(config, f"Load {c_file.name}")
    try:
        file_capsule = Capsule(c_file)
    except ValueError as e:
        log_message(config, f"Could not load '{c_file}'. Reason: {e}")
        return

    new_filepath: Path = c_file
    if config.translate and config.translator:
        new_filepath = c_file.parent / f"{c_file.stem}_{config.translator.to_lang.get_bcp47_code()}{c_file.suffix}"

    if not config.is_patching():
        return

    new_resources: list[tuple[str, ResourceType, bytes]] = []
    omitted_resources: list[ResourceIdentifier] = []
    for resource in file_capsule:
        patched_data: GFF | TPC | None = patch_resource(resource, config, processed_files)
        if patched_data is None:
            continue

        if isinstance(patched_data, TPC):
            txi_resource = file_capsule.resource(resource.resname(), ResourceType.TXI)
            if txi_resource is not None:
                patched_data.txi = decode_bytes_with_fallbacks(txi_resource)
                omitted_resources.append(ResourceIdentifier(resource.resname(), ResourceType.TXI))

        patched_restype, new_data = _serialize_patched_resource(resource, patched_data)
        log_message(config, f"Adding patched {patched_restype.name} resource '{resource.identifier()}' to capsule {new_filepath.name}")
        new_resources.append((resource.resname(), patched_restype, new_data))
        omitted_resources.append(resource.identifier())

    erf_or_rim: ERF | RIM = ERF(ERFType.from_extension(new_filepath)) if is_any_erf_type_file(c_file) else RIM()
    for resource in file_capsule:
        if resource.identifier() not in omitted_resources:
            erf_or_rim.set_data(resource.resname(), resource.restype(), resource.data())
    for resinfo in new_resources:
        erf_or_rim.set_data(*resinfo)

    log_message(config, f"Saving back to {new_filepath.name}")
    if is_any_erf_type_file(c_file):
        write_erf(erf_or_rim, new_filepath)  # type: ignore[arg-type]
    else:
        write_rim(erf_or_rim, new_filepath)  # type: ignore[arg-type]


def patch_erf_or_rim(
    resources: list[FileResource],
    filename: str,
    erf_or_rim: RIM | ERF,
    config: PatchingConfig,
) -> Path:
    """Patch resources in an ERF or RIM.

    Args:
    ----
        resources: List of resources to patch
        filename: Original filename
        erf_or_rim: The ERF or RIM to patch
        config: Patching configuration

    Returns:
    -------
        New filename path
    """
    omitted_resources: list[ResourceIdentifier] = []
    new_filename = Path(filename)
    if config.translate and config.translator:
        new_filename = Path(f"{new_filename.stem}_{config.translator.to_lang.name}{new_filename.suffix}")

    for resource in resources:
        patched_data: GFF | TPC | None = patch_resource(resource, config)
        if patched_data is None:
            continue

        if isinstance(patched_data, TPC):
            txi_resource: FileResource | None = next(
                (res for res in resources if res.resname() == resource.resname() and res.restype() == ResourceType.TXI),
                None,
            )
            if txi_resource:
                patched_data.txi = decode_bytes_with_fallbacks(txi_resource.data())
                omitted_resources.append(txi_resource.identifier())

        patched_restype, new_data = _serialize_patched_resource(resource, patched_data)
        log_message(config, f"Adding patched {patched_restype.name} resource '{resource.identifier()}' to {new_filename}")
        erf_or_rim.set_data(resource.resname(), patched_restype, new_data)
        omitted_resources.append(resource.identifier())

    for resource in resources:
        if resource.identifier() not in omitted_resources:
            try:
                erf_or_rim.set_data(resource.resname(), resource.restype(), resource.data())
            except (OSError, ValueError):
                log_message(config, f"Corrupted resource: {resource!r}, skipping...")
            except Exception:
                log_message(config, f"Unexpected exception occurred for resource {resource!r}")

    return new_filename


def patch_file(
    file: Path | str,
    config: PatchingConfig,
    processed_files: set[Path] | None = None,
) -> None:
    """Patch a single file.

    Args:
    ----
        file: Path to file to patch
        config: Patching configuration
        processed_files: Set to track processed files (optional)
    """
    processed_files = _ensure_processed_files(processed_files)

    c_file = Path(file)
    if c_file in processed_files:
        return

    if is_capsule_file(c_file):
        patch_capsule_file(c_file, config, processed_files)
    elif config.is_patching():
        patch_and_save_noncapsule(FileResource.from_path(c_file), config)


def patch_folder(
    folder_path: Path | str,
    config: PatchingConfig,
    processed_files: set[Path] | None = None,
) -> None:
    """Patch all files in a folder recursively.

    Args:
    ----
        folder_path: Path to folder to patch
        config: Patching configuration
        processed_files: Set to track processed files (optional)
    """
    processed_files = _ensure_processed_files(processed_files)

    c_folderpath = Path(folder_path)
    log_message(config, f"Recursing through resources in the '{c_folderpath.name}' folder...")
    for file_path in c_folderpath.rglob("*"):
        patch_file(file_path, config, processed_files)


def is_kotor_install_dir(path: Path) -> bool:
    """Check if a path is a KOTOR installation directory.

    Args:
    ----
        path: Path to check

    Returns:
    -------
        True if path is a KOTOR installation directory
    """
    c_path: CaseAwarePath = CaseAwarePath(path)
    return bool(c_path.is_dir() and c_path.joinpath("chitin.key").is_file())


def patch_install(
    install_path: Path | str,
    config: PatchingConfig,
    processed_files: set[Path] | None = None,
) -> None:
    """Patch a KOTOR installation.

    Args:
    ----
        install_path: Path to KOTOR installation
        config: Patching configuration
        processed_files: Set to track processed files (optional)
    """
    processed_files = _ensure_processed_files(processed_files)

    log_message(config, f"Using install dir for operations:\t{install_path}")

    k_install = Installation(install_path)
    if config.is_patching():
        log_message(config, "Patching modules...")
        if config.k1_convert_gffs or config.tsl_convert_gffs:
            for module_name in k_install._modules:  # noqa: SLF001
                log_message(config, f"Validating ERF/RIM in the Modules folder: '{module_name}'")
                module_path = k_install.module_path().joinpath(module_name)
                to_game = Game.K2 if config.tsl_convert_gffs else Game.K1
                erf_or_rim = validate_capsule(module_path, strict=True, game=to_game)
                if isinstance(erf_or_rim, ERF):
                    write_erf(erf_or_rim, module_path)
                elif isinstance(erf_or_rim, RIM):
                    write_rim(erf_or_rim, module_path)
                else:
                    log_message(config, f"Unknown ERF/RIM: '{module_path.relative_to(k_install.path().parent)}'")

        k_install.load_modules()
        for module_name, resources in k_install._modules.items():  # noqa: SLF001
            res_ident = ResourceIdentifier.from_path(module_name)
            filename = str(res_ident)
            filepath = k_install.path().joinpath("Modules", filename)
            if res_ident.restype == ResourceType.RIM:
                if filepath.with_suffix(".mod").is_file():
                    log_message(config, f"Skipping {filepath}, a .mod already exists at this path.")
                    continue
                new_rim = RIM()
                new_rim_filename = patch_erf_or_rim(resources, module_name, new_rim, config)
                log_message(config, f"Saving '{new_rim_filename}'")
                write_rim(new_rim, filepath.parent / new_rim_filename, res_ident.restype)

            elif res_ident.restype.name in (ResourceType.ERF, ResourceType.MOD, ResourceType.SAV):
                new_erf = ERF(ERFType.from_extension(filepath.suffix))
                if res_ident.restype == ResourceType.SAV:
                    new_erf.is_save = True
                new_erf_filename = patch_erf_or_rim(resources, module_name, new_erf, config)
                log_message(config, f"Saving '{new_erf_filename}'")
                write_erf(new_erf, filepath.parent / new_erf_filename, res_ident.restype)

            else:
                log_message(config, f"Unsupported module: {module_name} - cannot patch")

    if config.is_patching():
        log_message(config, "Patching Override...")
    override_path = k_install.override_path()
    override_path.mkdir(exist_ok=True, parents=True)
    for folder in k_install.override_list():
        for resource in k_install.override_resources(folder):
            if config.is_patching():
                patch_and_save_noncapsule(resource, config)

    if config.is_patching():
        log_message(config, "Extract and patch BIF data, saving to Override (will not overwrite)")
    for resource in k_install.core_resources():
        if config.translate or config.set_unskippable:
            patch_and_save_noncapsule(resource, savedir=override_path, config=config)

    patch_file(k_install.path().joinpath("dialog.tlk"), config, processed_files)


def determine_input_path(
    path: Path,
    config: PatchingConfig,
    processed_files: set[Path] | None = None,
) -> None:
    """Determine what type of path was provided and patch accordingly.

    Args:
    ----
        path: Path to patch
        config: Patching configuration
        processed_files: Set to track processed files (optional)
    """
    if not path.exists() or path.resolve() == Path.cwd().resolve():
        import errno

        raise FileNotFoundError(errno.ENOENT, f"No such file or directory: {path}")

    if is_kotor_install_dir(path):
        return patch_install(path, config, processed_files)

    if path.is_dir():
        return patch_folder(path, config, processed_files)

    if path.is_file():
        return patch_file(path, config, processed_files)

    return None
