#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Masowa konwersja tekstowych modeli DirectX .x używanych przez OMSI 2 do .o3d.

Działanie:
  1. Rekurencyjnie skanuje folder Sceneryobjects w poszukiwaniu plików .sco.
  2. Wyszukuje w nich odwołania do modeli kończących się na .x.
  3. Szuka modelu względem folderu "model" danego obiektu.
  4. Konwertuje tekstowy DirectX .x do niezaszyfrowanego O3D v7 zgodnego
     ze strukturą zapisu załączonej wtyczki O3D_Decrypter.
  5. Odwraca kolejność indeksów trójkątów tak samo jak eksporter O3D
     załączonej wtyczki Blendera (odpowiednik „Flip Normals”).
  6. Po udanej konwersji zmienia rozszerzenie w .sco z .x na .o3d.
  7. Dopisuje do .sco bezpieczny blok historii z listą poprawionych modeli.
     Dzięki niemu kolejne uruchomienia rozpoznają wcześniej obsłużone obiekty.

Skrypt nie wymaga Blendera ani zewnętrznych bibliotek.
Obsługiwany jest tekstowy DirectX X: "xof ....txt ....".
Pliki binarne i skompresowane .x są pomijane z czytelnym błędem.
"""

from __future__ import print_function

import argparse
import codecs
import math
import os
import re
import shutil
import struct
import sys
import tempfile
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple, Union


CONVERTER_VERSION = 2
MARKER_BEGIN = "// OMSI_X_TO_O3D_MARKER_BEGIN"
MARKER_END = "// OMSI_X_TO_O3D_MARKER_END"
WINDING_MODE_FLIPPED = "blender_o3d_flip_normals"
WINDING_MODE_LEGACY = "legacy_unflipped"


Vec2 = Tuple[float, float]
Vec3 = Tuple[float, float, float]
Matrix4 = Tuple[
    Tuple[float, float, float, float],
    Tuple[float, float, float, float],
    Tuple[float, float, float, float],
    Tuple[float, float, float, float],
]

IDENTITY4: Matrix4 = (
    (1.0, 0.0, 0.0, 0.0),
    (0.0, 1.0, 0.0, 0.0),
    (0.0, 0.0, 1.0, 0.0),
    (0.0, 0.0, 0.0, 1.0),
)


class XParseError(RuntimeError):
    pass


class ConversionError(RuntimeError):
    pass


@dataclass
class Material:
    name: str = "Material"
    diffuse: Tuple[float, float, float, float] = (0.8, 0.8, 0.8, 1.0)
    specular: Vec3 = (0.0, 0.0, 0.0)
    emissive: Vec3 = (0.0, 0.0, 0.0)
    power: float = 0.0
    texture: str = ""

    def key(self) -> Tuple[object, ...]:
        return (
            tuple(round(v, 8) for v in self.diffuse),
            tuple(round(v, 8) for v in self.specular),
            tuple(round(v, 8) for v in self.emissive),
            round(self.power, 8),
            self.texture.lower(),
        )


MaterialRef = Union[Material, str]


@dataclass
class MeshData:
    name: str
    vertices: List[Vec3]
    faces: List[List[int]]
    transform: Matrix4
    normals: List[Vec3] = field(default_factory=list)
    normal_faces: List[List[int]] = field(default_factory=list)
    uvs: List[Vec2] = field(default_factory=list)
    face_material_indices: List[int] = field(default_factory=list)
    material_refs: List[MaterialRef] = field(default_factory=list)


@dataclass
class ConvertedModel:
    vertices: List[Tuple[Vec3, Vec3, Vec2]]
    triangles: List[Tuple[Tuple[int, int, int], int]]
    materials: List[Material]
    texture_names: List[str]


TOKEN_RE = re.compile(
    r"""
    (?P<WS>\s+)
  | (?P<LINECOMMENT>//[^\r\n]*)
  | (?P<BLOCKCOMMENT>/\*.*?\*/)
  | (?P<HASHCOMMENT>\#[^\r\n]*)
  | (?P<STRING>"(?:\\.|[^"\\])*")
  | (?P<NUMBER>[+-]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][+-]?\d+)?)
  | (?P<IDENT>[A-Za-z_$][A-Za-z0-9_$\.\-]*)
  | (?P<PUNCT>[{};,<>\[\]])
  | (?P<OTHER>\S)
    """,
    re.VERBOSE | re.DOTALL,
)

NUMBER_RE = re.compile(r"^[+-]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][+-]?\d+)?$")


def decode_x_text(data: bytes, source: Path) -> str:
    if data.startswith(codecs.BOM_UTF8):
        return data.decode("utf-8-sig")
    if data.startswith(codecs.BOM_UTF16_LE):
        return data.decode("utf-16-le")
    if data.startswith(codecs.BOM_UTF16_BE):
        return data.decode("utf-16-be")
    for encoding in ("utf-8", "cp1252", "latin-1"):
        try:
            return data.decode(encoding)
        except UnicodeDecodeError:
            pass
    raise ConversionError("Nie można rozpoznać kodowania pliku: {}".format(source))


def tokenize_x(text: str) -> List[str]:
    # Nagłówek ma postać np. "xof 0303txt 0032" i nie jest elementem gramatyki.
    text = re.sub(r"^\ufeff?\s*xof\s+\d{4}(txt|bin|tzip|bzip)\s+\d{4}\s*", "", text, count=1, flags=re.I)
    tokens: List[str] = []
    pos = 0
    while pos < len(text):
        match = TOKEN_RE.match(text, pos)
        if not match:
            raise XParseError("Nie można odczytać pliku .x w pozycji {}".format(pos))
        pos = match.end()
        kind = match.lastgroup
        value = match.group(0)
        if kind in ("WS", "LINECOMMENT", "BLOCKCOMMENT", "HASHCOMMENT"):
            continue
        tokens.append(value)
    return tokens


class XParser:
    def __init__(self, tokens: Sequence[str], source: Path):
        self.tokens = list(tokens)
        self.i = 0
        self.source = source
        self.global_materials: Dict[str, Material] = {}
        self.meshes: List[MeshData] = []

    def eof(self) -> bool:
        return self.i >= len(self.tokens)

    def peek(self, offset: int = 0) -> Optional[str]:
        idx = self.i + offset
        return self.tokens[idx] if idx < len(self.tokens) else None

    def pop(self) -> str:
        if self.eof():
            raise XParseError("Nieoczekiwany koniec pliku {}".format(self.source))
        token = self.tokens[self.i]
        self.i += 1
        return token

    def accept(self, token: str, casefold: bool = False) -> bool:
        current = self.peek()
        if current is None:
            return False
        if (current.lower() == token.lower()) if casefold else (current == token):
            self.i += 1
            return True
        return False

    def expect(self, token: str, casefold: bool = False) -> None:
        current = self.pop()
        ok = (current.lower() == token.lower()) if casefold else (current == token)
        if not ok:
            raise XParseError(
                "Oczekiwano {!r}, znaleziono {!r} w {} (token {})".format(
                    token, current, self.source, self.i
                )
            )

    def consume_delimiters(self) -> None:
        while self.peek() in (";", ","):
            self.i += 1

    def read_number(self) -> float:
        self.consume_delimiters()
        token = self.pop()
        if not NUMBER_RE.match(token):
            raise XParseError(
                "Oczekiwano liczby, znaleziono {!r} w {} (token {})".format(
                    token, self.source, self.i
                )
            )
        return float(token)

    def read_int(self) -> int:
        value = self.read_number()
        rounded = int(value)
        if abs(value - rounded) > 1e-6:
            raise XParseError("Oczekiwano liczby całkowitej, znaleziono {}".format(value))
        return rounded

    def read_string_or_name(self) -> str:
        self.consume_delimiters()
        token = self.pop()
        if token.startswith('"') and token.endswith('"'):
            value = token[1:-1]
            return value.replace(r"\"", '"').replace(r"\\", "\\")
        return token

    def skip_balanced(self) -> None:
        self.expect("{")
        depth = 1
        while depth and not self.eof():
            token = self.pop()
            if token == "{":
                depth += 1
            elif token == "}":
                depth -= 1
        if depth:
            raise XParseError("Niedomknięty blok w {}".format(self.source))

    def skip_object_body(self) -> None:
        # Po nazwie typu może wystąpić opcjonalna nazwa obiektu.
        if self.peek() != "{" and self.peek() is not None:
            self.i += 1
        if self.peek() == "{":
            self.skip_balanced()

    def parse(self) -> Tuple[List[MeshData], Dict[str, Material]]:
        while not self.eof():
            token = self.pop()
            low = token.lower()
            if low == "template":
                self.skip_object_body()
            elif low == "material":
                material = self.parse_material_after_keyword()
                if material.name:
                    self.global_materials[material.name.lower()] = material
            elif low == "frame":
                self.parse_frame(IDENTITY4)
            elif low == "mesh":
                self.parse_mesh(IDENTITY4)
            elif self.peek() == "{" or (self.peek(1) == "{"):
                self.skip_object_body()
            # Samotne znaczniki i deklaracje szablonów są ignorowane.
        return self.meshes, self.global_materials

    def parse_frame(self, parent_transform: Matrix4) -> None:
        if self.peek() != "{":
            self.pop()  # nazwa ramki
        self.expect("{")
        world = parent_transform
        while not self.eof() and self.peek() != "}":
            token = self.pop()
            low = token.lower()
            if low == "frametransformmatrix":
                local = self.parse_matrix_block()
                world = matrix_multiply(local, parent_transform)
            elif low == "frame":
                self.parse_frame(world)
            elif low == "mesh":
                self.parse_mesh(world)
            elif low == "material":
                material = self.parse_material_after_keyword()
                if material.name:
                    self.global_materials[material.name.lower()] = material
            elif self.peek() == "{" or self.peek(1) == "{":
                self.skip_object_body()
            else:
                self.consume_delimiters()
        self.expect("}")
        self.consume_delimiters()

    def parse_matrix_block(self) -> Matrix4:
        self.expect("{")
        values = [self.read_number() for _ in range(16)]
        self.consume_delimiters()
        self.expect("}")
        self.consume_delimiters()
        return (
            tuple(values[0:4]),
            tuple(values[4:8]),
            tuple(values[8:12]),
            tuple(values[12:16]),
        )  # type: ignore[return-value]

    def parse_material_after_keyword(self) -> Material:
        name = "Material"
        if self.peek() != "{":
            name = self.read_string_or_name()
        self.expect("{")
        diffuse = tuple(self.read_number() for _ in range(4))
        power = self.read_number()
        specular = tuple(self.read_number() for _ in range(3))
        emissive = tuple(self.read_number() for _ in range(3))
        texture = ""
        self.consume_delimiters()
        while not self.eof() and self.peek() != "}":
            token = self.pop()
            low = token.lower()
            if low in ("texturefilename", "texturefileName".lower()):
                self.expect("{")
                texture = self.read_string_or_name()
                self.consume_delimiters()
                self.expect("}")
                self.consume_delimiters()
            elif self.peek() == "{" or self.peek(1) == "{":
                self.skip_object_body()
            else:
                self.consume_delimiters()
        self.expect("}")
        self.consume_delimiters()
        texture = normalize_texture_name(texture)
        return Material(
            name=name,
            diffuse=diffuse,  # type: ignore[arg-type]
            specular=specular,  # type: ignore[arg-type]
            emissive=emissive,  # type: ignore[arg-type]
            power=power,
            texture=texture,
        )

    def parse_mesh(self, transform: Matrix4) -> None:
        name = "Mesh"
        if self.peek() != "{":
            name = self.read_string_or_name()
        self.expect("{")

        vertex_count = self.read_int()
        vertices: List[Vec3] = []
        for _ in range(vertex_count):
            vertices.append((self.read_number(), self.read_number(), self.read_number()))

        face_count = self.read_int()
        faces: List[List[int]] = []
        for _ in range(face_count):
            count = self.read_int()
            face = [self.read_int() for _ in range(count)]
            if any(index < 0 or index >= vertex_count for index in face):
                raise XParseError("Indeks wierzchołka poza zakresem w siatce {!r}".format(name))
            faces.append(face)

        mesh = MeshData(name=name, vertices=vertices, faces=faces, transform=transform)
        self.consume_delimiters()

        while not self.eof() and self.peek() != "}":
            token = self.pop()
            low = token.lower()
            if low == "meshnormals":
                mesh.normals, mesh.normal_faces = self.parse_normals_block()
            elif low == "meshtexturecoords":
                mesh.uvs = self.parse_uv_block()
            elif low == "meshmateriallist":
                mesh.face_material_indices, mesh.material_refs = self.parse_material_list_block()
            elif low == "material":
                material = self.parse_material_after_keyword()
                mesh.material_refs.append(material)
            elif self.peek() == "{" or self.peek(1) == "{":
                self.skip_object_body()
            else:
                self.consume_delimiters()

        self.expect("}")
        self.consume_delimiters()
        self.meshes.append(mesh)

    def parse_normals_block(self) -> Tuple[List[Vec3], List[List[int]]]:
        self.expect("{")
        count = self.read_int()
        normals = [
            (self.read_number(), self.read_number(), self.read_number())
            for _ in range(count)
        ]
        face_count = self.read_int()
        normal_faces: List[List[int]] = []
        for _ in range(face_count):
            n = self.read_int()
            indices = [self.read_int() for _ in range(n)]
            if any(index < 0 or index >= count for index in indices):
                raise XParseError("Indeks normalnej poza zakresem")
            normal_faces.append(indices)
        self.consume_delimiters()
        self.expect("}")
        self.consume_delimiters()
        return normals, normal_faces

    def parse_uv_block(self) -> List[Vec2]:
        self.expect("{")
        count = self.read_int()
        uvs = [(self.read_number(), self.read_number()) for _ in range(count)]
        self.consume_delimiters()
        self.expect("}")
        self.consume_delimiters()
        return uvs

    def parse_material_list_block(self) -> Tuple[List[int], List[MaterialRef]]:
        self.expect("{")
        material_count = self.read_int()
        face_index_count = self.read_int()
        face_indices = [self.read_int() for _ in range(face_index_count)]
        refs: List[MaterialRef] = []
        self.consume_delimiters()

        while not self.eof() and self.peek() != "}" and len(refs) < material_count:
            if self.accept("Material", casefold=True):
                refs.append(self.parse_material_after_keyword())
            elif self.accept("{"):
                ref_name = self.read_string_or_name()
                self.consume_delimiters()
                self.expect("}")
                self.consume_delimiters()
                refs.append(ref_name)
            else:
                token = self.pop()
                if token not in (";", ","):
                    refs.append(token)

        # Zdarzają się eksportery deklarujące więcej materiałów niż wpisów.
        while len(refs) < material_count:
            refs.append(Material(name="Material_{}".format(len(refs))))

        # Pomiń nietypowe dodatkowe bloki przed końcem MeshMaterialList.
        while not self.eof() and self.peek() != "}":
            token = self.pop()
            if self.peek() == "{" or self.peek(1) == "{":
                self.skip_object_body()
            else:
                self.consume_delimiters()
        self.expect("}")
        self.consume_delimiters()
        return face_indices, refs


def matrix_multiply(a: Matrix4, b: Matrix4) -> Matrix4:
    result = []
    for r in range(4):
        row = []
        for c in range(4):
            row.append(sum(a[r][k] * b[k][c] for k in range(4)))
        result.append(tuple(row))
    return tuple(result)  # type: ignore[return-value]


def transform_point(v: Vec3, m: Matrix4) -> Vec3:
    x, y, z = v
    return (
        x * m[0][0] + y * m[1][0] + z * m[2][0] + m[3][0],
        x * m[0][1] + y * m[1][1] + z * m[2][1] + m[3][1],
        x * m[0][2] + y * m[1][2] + z * m[2][2] + m[3][2],
    )


def determinant3(m: Matrix4) -> float:
    a, b, c = m[0][0], m[0][1], m[0][2]
    d, e, f = m[1][0], m[1][1], m[1][2]
    g, h, i = m[2][0], m[2][1], m[2][2]
    return a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)


def inverse3(m: Matrix4) -> Optional[Tuple[Vec3, Vec3, Vec3]]:
    a, b, c = m[0][0], m[0][1], m[0][2]
    d, e, f = m[1][0], m[1][1], m[1][2]
    g, h, i = m[2][0], m[2][1], m[2][2]
    det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
    if abs(det) < 1e-12:
        return None
    inv_det = 1.0 / det
    return (
        ((e * i - f * h) * inv_det, (c * h - b * i) * inv_det, (b * f - c * e) * inv_det),
        ((f * g - d * i) * inv_det, (a * i - c * g) * inv_det, (c * d - a * f) * inv_det),
        ((d * h - e * g) * inv_det, (b * g - a * h) * inv_det, (a * e - b * d) * inv_det),
    )


def normalize(v: Vec3) -> Vec3:
    length = math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
    if length < 1e-12:
        return (0.0, 1.0, 0.0)
    return (v[0] / length, v[1] / length, v[2] / length)


def transform_normal(v: Vec3, m: Matrix4) -> Vec3:
    inv = inverse3(m)
    if inv is None:
        # Awaryjnie zwykłe przekształcenie liniowe.
        x, y, z = v
        return normalize(
            (
                x * m[0][0] + y * m[1][0] + z * m[2][0],
                x * m[0][1] + y * m[1][1] + z * m[2][1],
                x * m[0][2] + y * m[1][2] + z * m[2][2],
            )
        )
    x, y, z = v
    # n' = n * transpose(inverse(A)) dla macierzy i wektorów w konwencji DirectX.
    return normalize(
        (
            x * inv[0][0] + y * inv[0][1] + z * inv[0][2],
            x * inv[1][0] + y * inv[1][1] + z * inv[1][2],
            x * inv[2][0] + y * inv[2][1] + z * inv[2][2],
        )
    )


def subtract(a: Vec3, b: Vec3) -> Vec3:
    return (a[0] - b[0], a[1] - b[1], a[2] - b[2])


def cross(a: Vec3, b: Vec3) -> Vec3:
    return (
        a[1] * b[2] - a[2] * b[1],
        a[2] * b[0] - a[0] * b[2],
        a[0] * b[1] - a[1] * b[0],
    )


def normalize_texture_name(name: str) -> str:
    if not name:
        return ""
    normalized = name.replace("/", "\\").strip().strip('"')
    # Wtyczka zapisuje bpy.path.basename(), więc w O3D pozostaje nazwa pliku.
    return normalized.rsplit("\\", 1)[-1]


def resolve_material(ref: MaterialRef, globals_map: Dict[str, Material]) -> Material:
    if isinstance(ref, Material):
        return ref
    found = globals_map.get(ref.lower())
    if found is not None:
        return found
    return Material(name=ref or "Material")


def parse_x_model(source: Path) -> Tuple[List[MeshData], Dict[str, Material]]:
    data = source.read_bytes()
    header = data[:64].decode("latin-1", errors="ignore")
    match = re.match(r"^\ufeff?\s*xof\s+\d{4}(txt|bin|tzip|bzip)\s+\d{4}", header, re.I)
    if not match:
        raise ConversionError("Brak prawidłowego nagłówka DirectX X: {}".format(source))
    storage = match.group(1).lower()
    if storage != "txt":
        raise ConversionError(
            "Nieobsługiwany {} model .x (obsługiwany jest format tekstowy txt): {}".format(
                storage, source
            )
        )
    text = decode_x_text(data, source)
    parser = XParser(tokenize_x(text), source)
    meshes, materials = parser.parse()
    if not meshes:
        raise ConversionError("W pliku nie znaleziono żadnej siatki Mesh: {}".format(source))
    return meshes, materials


def build_converted_model(
    meshes: Sequence[MeshData],
    globals_map: Dict[str, Material],
    flip_normals: bool = True,
) -> ConvertedModel:
    """Buduje strukturę O3D.

    Załączona wtyczka Blendera zapisuje zwykły obiekt z odwróconą kolejnością
    indeksów trójkąta (pack_o3d_triangle(..., invert_normals=True)). W OMSI jest
    to odpowiednik operacji „Flip Normals”. Domyślnie odtwarzamy dokładnie to
    zachowanie. Wektory normalnych pozostają bez negowania, tak jak we wtyczce.
    """
    out_vertices: List[Tuple[Vec3, Vec3, Vec2]] = []
    out_triangles: List[Tuple[Tuple[int, int, int], int]] = []
    out_materials: List[Material] = []
    material_lookup: Dict[Tuple[object, ...], int] = {}
    vertex_lookup: Dict[Tuple[object, ...], int] = {}

    def material_index(material: Material) -> int:
        key = material.key()
        if key not in material_lookup:
            material_lookup[key] = len(out_materials)
            out_materials.append(material)
        return material_lookup[key]

    default_mat_index: Optional[int] = None

    for mesh_no, mesh in enumerate(meshes):
        world_vertices = [transform_point(v, mesh.transform) for v in mesh.vertices]
        mirrored_transform = determinant3(mesh.transform) < 0.0

        # Bez trybu Flip Normals zachowanie odpowiada starszej wersji skryptu:
        # odwrócenie tylko przy lustrzanej transformacji. Z włączonym trybem
        # odwracamy wynik końcowy raz jeszcze, dokładnie jak eksporter Blendera.
        reverse_triangle_order = mirrored_transform
        if flip_normals:
            reverse_triangle_order = not reverse_triangle_order

        resolved_materials = [resolve_material(ref, globals_map) for ref in mesh.material_refs]
        resolved_material_indices = [material_index(mat) for mat in resolved_materials]

        for face_no, face in enumerate(mesh.faces):
            if len(face) < 3:
                continue

            local_mat = mesh.face_material_indices[face_no] if face_no < len(mesh.face_material_indices) else 0
            if 0 <= local_mat < len(resolved_material_indices):
                mat_index = resolved_material_indices[local_mat]
            else:
                if default_mat_index is None:
                    default_mat_index = material_index(Material())
                mat_index = default_mat_index

            normal_indices = mesh.normal_faces[face_no] if face_no < len(mesh.normal_faces) else []

            # Normalna logiczna modelu po zastosowaniu FrameTransformMatrix.
            # Przy transformacji lustrzanej iloczyn wektorowy ma przeciwny znak
            # niż normalna przekształcona macierzą odwrotnie transponowaną.
            face_normal = normalize(
                cross(
                    subtract(world_vertices[face[1]], world_vertices[face[0]]),
                    subtract(world_vertices[face[2]], world_vertices[face[0]]),
                )
            )
            if mirrored_transform:
                face_normal = (-face_normal[0], -face_normal[1], -face_normal[2])

            for i in range(1, len(face) - 1):
                corners = (0, i + 1, i) if reverse_triangle_order else (0, i, i + 1)
                tri_indices: List[int] = []
                for corner in corners:
                    source_vertex_index = face[corner]
                    position = world_vertices[source_vertex_index]

                    if corner < len(normal_indices) and 0 <= normal_indices[corner] < len(mesh.normals):
                        normal = transform_normal(mesh.normals[normal_indices[corner]], mesh.transform)
                    elif len(mesh.normals) == len(mesh.vertices):
                        normal = transform_normal(mesh.normals[source_vertex_index], mesh.transform)
                    else:
                        normal = face_normal

                    uv = mesh.uvs[source_vertex_index] if source_vertex_index < len(mesh.uvs) else (0.0, 0.0)
                    key = (
                        mesh_no,
                        source_vertex_index,
                        round(normal[0], 7),
                        round(normal[1], 7),
                        round(normal[2], 7),
                        round(uv[0], 7),
                        round(uv[1], 7),
                    )
                    index = vertex_lookup.get(key)
                    if index is None:
                        index = len(out_vertices)
                        vertex_lookup[key] = index
                        out_vertices.append((position, normal, uv))
                    tri_indices.append(index)

                out_triangles.append(((tri_indices[0], tri_indices[1], tri_indices[2]), mat_index))

    if not out_triangles:
        raise ConversionError("Model nie zawiera żadnych ścian możliwych do zapisania")
    if len(out_materials) > 0xFFFF:
        raise ConversionError("O3D obsługuje najwyżej 65535 materiałów")
    texture_names = sorted({m.texture for m in out_materials if m.texture}, key=str.lower)
    return ConvertedModel(out_vertices, out_triangles, out_materials, texture_names)

def pack_pascal_cp1252(value: str) -> bytes:
    encoded = value.encode("cp1252", errors="replace")
    if len(encoded) > 255:
        raise ConversionError("Nazwa tekstury jest dłuższa niż 255 bajtów: {!r}".format(value))
    return struct.pack("<{}p".format(len(encoded) + 1), encoded)


def write_o3d_v7(model: ConvertedModel, target: Path) -> None:
    data = bytearray()
    # Nagłówek zgodny z build_o3d_binary(..., version=7) z załączonej wtyczki.
    data += struct.pack("<BBB", 0x84, 0x19, 7)
    data += struct.pack("<BI", 1, 0xFFFFFFFF)  # indeksy 32-bit, bez szyfrowania

    data += struct.pack("<BI", 0x17, len(model.vertices))
    for position, normal, uv in model.vertices:
        data += struct.pack(
            "<ffffffff",
            position[0], position[1], position[2],
            normal[0], normal[1], normal[2],
            uv[0], uv[1],
        )

    data += struct.pack("<BI", 0x49, len(model.triangles))
    for indices, mat_index in model.triangles:
        if mat_index < 0 or mat_index > 0xFFFF:
            raise ConversionError("Indeks materiału poza zakresem: {}".format(mat_index))
        data += struct.pack("<IIIH", indices[0], indices[1], indices[2], mat_index)

    data += struct.pack("<BH", 0x26, len(model.materials))
    for material in model.materials:
        data += struct.pack(
            "<fffffffffff",
            material.diffuse[0], material.diffuse[1], material.diffuse[2], material.diffuse[3],
            material.specular[0], material.specular[1], material.specular[2],
            material.emissive[0], material.emissive[1], material.emissive[2],
            material.power,
        )
        data += pack_pascal_cp1252(material.texture)

    # Macierz jednostkowa; transformacje Frame zostały już zastosowane do geometrii.
    data += struct.pack(
        "<Bffffffffffffffff",
        0x79,
        1.0, 0.0, 0.0, 0.0,
        0.0, 1.0, 0.0, 0.0,
        0.0, 0.0, 1.0, 0.0,
        0.0, 0.0, 0.0, 1.0,
    )

    target.parent.mkdir(parents=True, exist_ok=True)
    fd, temp_name = tempfile.mkstemp(prefix=target.name + ".", suffix=".tmp", dir=str(target.parent))
    try:
        with os.fdopen(fd, "wb") as handle:
            handle.write(data)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(temp_name, str(target))
    except Exception:
        try:
            os.unlink(temp_name)
        except OSError:
            pass
        raise


def validate_o3d_v7(path: Path) -> Tuple[int, int, int]:
    data = path.read_bytes()
    if len(data) < 8 or data[0:3] != bytes((0x84, 0x19, 7)):
        raise ConversionError("Wynikowy plik nie ma prawidłowego nagłówka O3D v7")
    offset = 8
    vertex_count = triangle_count = material_count = -1
    while offset < len(data):
        section = data[offset]
        offset += 1
        if section == 0x17:
            vertex_count = struct.unpack_from("<I", data, offset)[0]
            offset += 4 + vertex_count * 32
        elif section == 0x49:
            triangle_count = struct.unpack_from("<I", data, offset)[0]
            offset += 4 + triangle_count * 14
        elif section == 0x26:
            material_count = struct.unpack_from("<H", data, offset)[0]
            offset += 2
            for _ in range(material_count):
                offset += 44
                if offset >= len(data):
                    raise ConversionError("Uszkodzony blok materiałów O3D")
                length = data[offset]
                offset += length + 1
        elif section == 0x54:
            # Skrypt nie generuje kości; obsługa walidacyjna na przyszłość.
            bone_count = struct.unpack_from("<H", data, offset)[0]
            offset += 2
            for _ in range(bone_count):
                name_len = data[offset]
                offset += name_len + 1
                weight_count = struct.unpack_from("<H", data, offset)[0]
                offset += 2 + weight_count * 8
        elif section == 0x79:
            offset += 64
        else:
            raise ConversionError("Nieznana sekcja O3D 0x{:02X}".format(section))
        if offset > len(data):
            raise ConversionError("Wynikowy plik O3D jest ucięty")
    if offset != len(data) or min(vertex_count, triangle_count, material_count) < 0:
        raise ConversionError("Niepełna struktura wynikowego O3D")
    return vertex_count, triangle_count, material_count


def convert_x_to_o3d(source: Path, target: Path, flip_normals: bool = True) -> ConvertedModel:
    meshes, globals_map = parse_x_model(source)
    model = build_converted_model(meshes, globals_map, flip_normals=flip_normals)
    write_o3d_v7(model, target)
    validate_o3d_v7(target)
    return model


def split_reference(reference: str) -> List[str]:
    cleaned = reference.strip().strip('"').replace("/", "\\")
    return [part for part in cleaned.split("\\") if part not in ("", ".")]


def find_child_case_insensitive(directory: Path, name: str) -> Optional[Path]:
    exact = directory / name
    if exact.exists():
        return exact
    try:
        wanted = name.casefold()
        for child in directory.iterdir():
            if child.name.casefold() == wanted:
                return child
    except (OSError, PermissionError):
        return None
    return None


def resolve_case_insensitive(base: Path, parts: Sequence[str]) -> Optional[Path]:
    current = base
    for part in parts:
        if part == "..":
            current = current.parent
            continue
        found = find_child_case_insensitive(current, part)
        if found is None:
            return None
        current = found
    return current


def candidate_package_roots(sco_path: Path, scenery_root: Path) -> Iterable[Path]:
    current = sco_path.parent
    root_resolved = scenery_root.resolve()
    seen = set()
    while True:
        key = str(current.resolve()).casefold()
        if key not in seen:
            seen.add(key)
            yield current
        if current.resolve() == root_resolved or root_resolved not in current.resolve().parents:
            break
        current = current.parent


def resolve_model_reference(sco_path: Path, reference: str, scenery_root: Path) -> Tuple[Optional[Path], Optional[Path], Optional[Path]]:
    parts = split_reference(reference)
    if not parts:
        return None, None, None

    for package_root in candidate_package_roots(sco_path, scenery_root):
        if parts[0].casefold() == "model":
            relative = parts
        else:
            relative = ["model"] + parts
        source = resolve_case_insensitive(package_root, relative)
        if source is not None and source.is_file():
            model_dir = resolve_case_insensitive(package_root, ["model"]) or (package_root / "model")
            texture_dir = resolve_case_insensitive(package_root, ["texture"]) or (package_root / "texture")
            target = source.with_suffix(".o3d")
            return source, target, texture_dir

    # Awaryjnie model obok pliku SCO — nie jest to standard OMSI, ale występuje w dodatkach.
    direct = resolve_case_insensitive(sco_path.parent, parts)
    if direct is not None and direct.is_file():
        return direct, direct.with_suffix(".o3d"), sco_path.parent / "texture"
    return None, None, None


def decode_sco(data: bytes) -> Tuple[str, str, bool]:
    if data.startswith(codecs.BOM_UTF8):
        return data.decode("utf-8-sig"), "utf-8", True
    try:
        return data.decode("utf-8"), "utf-8", False
    except UnicodeDecodeError:
        return data.decode("cp1252"), "cp1252", False


def encode_sco(text: str, encoding: str, bom: bool) -> bytes:
    encoded = text.encode(encoding)
    if bom and encoding.lower().replace("_", "-") == "utf-8":
        return codecs.BOM_UTF8 + encoded
    return encoded


X_LINE_RE = re.compile(r"^(?P<prefix>\s*)(?P<ref>[^\r\n]*?\.x)(?P<suffix>\s*(?:(?:;|//).*?)?)(?P<newline>\r?\n)?$", re.I)
DIRECTIVE_RE = re.compile(r"^\s*\[([^\]]+)\]\s*$")


@dataclass
class ScoReference:
    line_index: int
    directive: str
    reference: str


@dataclass
class ScoMarker:
    version: int = 0
    winding_mode: str = ""
    references: List[ScoReference] = field(default_factory=list)
    span: Optional[Tuple[int, int]] = None


@dataclass
class Stats:
    sco_files: int = 0
    references: int = 0
    converted: int = 0
    reused: int = 0
    sco_updated: int = 0
    missing: int = 0
    errors: int = 0
    textures_missing: int = 0
    marked_sco: int = 0
    markers_written: int = 0
    legacy_repaired: int = 0


MARKER_BLOCK_RE = re.compile(
    r"(?ms)^[ \t]*//\s*OMSI_X_TO_O3D_MARKER_BEGIN\s*\r?\n"
    r".*?"
    r"^[ \t]*//\s*OMSI_X_TO_O3D_MARKER_END[^\r\n]*(?:\r?\n)?"
)
MARKER_VERSION_RE = re.compile(r"^\s*//\s*converter_version\s*=\s*(\d+)\s*$", re.I)
MARKER_WINDING_RE = re.compile(r"^\s*//\s*winding_mode\s*=\s*(\S+)\s*$", re.I)
MARKER_MODEL_RE = re.compile(
    r"^\s*//\s*model\s*=\s*\[(?P<directive>[^\]]*)\]\s*"
    r"(?P<source>.*?)\s*->\s*(?P<target>.*?)\s*$",
    re.I,
)


def reference_key(ref: ScoReference) -> Tuple[str, str]:
    return ref.directive.strip().casefold(), ref.reference.strip().replace("/", "\\").casefold()


def merge_references(*groups: Sequence[ScoReference]) -> List[ScoReference]:
    merged: Dict[Tuple[str, str], ScoReference] = {}
    order: List[Tuple[str, str]] = []
    for group in groups:
        for ref in group:
            key = reference_key(ref)
            if key not in merged:
                merged[key] = ref
                order.append(key)
            elif merged[key].line_index < 0 <= ref.line_index:
                # Wersja znaleziona bezpośrednio w bieżącym SCO ma prawidłowy indeks linii.
                merged[key] = ref
    return [merged[key] for key in order]


def parse_sco_marker(text: str) -> ScoMarker:
    match = MARKER_BLOCK_RE.search(text)
    if not match:
        return ScoMarker()

    marker = ScoMarker(span=match.span())
    for line in match.group(0).splitlines():
        version_match = MARKER_VERSION_RE.match(line)
        if version_match:
            marker.version = int(version_match.group(1))
            continue
        winding_match = MARKER_WINDING_RE.match(line)
        if winding_match:
            marker.winding_mode = winding_match.group(1).strip()
            continue
        model_match = MARKER_MODEL_RE.match(line)
        if model_match:
            source = model_match.group("source").strip()
            if source.lower().endswith(".x"):
                marker.references.append(
                    ScoReference(
                        line_index=-1,
                        directive=model_match.group("directive").strip().lower(),
                        reference=source,
                    )
                )
    marker.references = merge_references(marker.references)
    return marker


def detect_newline(text: str) -> str:
    return "\r\n" if "\r\n" in text else "\n"


def marker_target_name(reference: str) -> str:
    return re.sub(r"(?i)\.x$", ".o3d", reference.strip())


def render_sco_marker(references: Sequence[ScoReference], winding_mode: str, newline: str) -> str:
    refs = merge_references(references)
    lines = [
        MARKER_BEGIN,
        "// converter_version={}".format(CONVERTER_VERSION),
        "// winding_mode={}".format(winding_mode),
    ]
    for ref in refs:
        directive = ref.directive or "unknown"
        lines.append(
            "// model=[{}] {} -> {}".format(
                directive,
                ref.reference.strip(),
                marker_target_name(ref.reference),
            )
        )
    lines.append(MARKER_END)
    return newline.join(lines) + newline


def apply_sco_marker(text: str, references: Sequence[ScoReference], winding_mode: str) -> str:
    newline = detect_newline(text)
    marker_text = render_sco_marker(references, winding_mode, newline)
    match = MARKER_BLOCK_RE.search(text)
    if match:
        return text[: match.start()] + marker_text + text[match.end() :]

    if text and not text.endswith(("\n", "\r")):
        text += newline
    if text and not text.endswith(newline * 2):
        text += newline
    return text + marker_text


def legacy_references_from_backup(
    sco_path: Path,
    current_lines: Sequence[str],
    all_x_lines: bool,
) -> List[ScoReference]:
    """Odzyskuje listę modeli przerobionych przez starszą wersję skryptu.

    Starsza wersja tworzyła .sco.bak przed zamianą .x na .o3d. Akceptujemy
    wyłącznie wpisy, których linia w bieżącym SCO jest dokładnie odpowiednikiem
    linii z kopii po zmianie rozszerzenia. Nie dotykamy w ten sposób natywnych O3D.
    """
    backup_path = sco_path.with_name(sco_path.name + ".bak")
    if not backup_path.exists() or not backup_path.is_file():
        return []
    try:
        backup_text, _, _ = decode_sco(backup_path.read_bytes())
        backup_lines = backup_text.splitlines(keepends=True)
        backup_refs = scan_sco_references(backup_lines, all_x_lines=all_x_lines)
    except Exception:
        return []

    recovered: List[ScoReference] = []
    for ref in backup_refs:
        if ref.line_index < 0 or ref.line_index >= len(current_lines):
            continue
        expected = replace_x_extension_in_line(backup_lines[ref.line_index]).strip().casefold()
        current = current_lines[ref.line_index].strip().casefold()
        if current == expected:
            recovered.append(ScoReference(-1, ref.directive, ref.reference))
    return merge_references(recovered)

def scan_sco_references(lines: Sequence[str], all_x_lines: bool) -> List[ScoReference]:
    result: List[ScoReference] = []
    directive = ""
    for index, line in enumerate(lines):
        stripped_no_nl = line.rstrip("\r\n")
        directive_match = DIRECTIVE_RE.match(stripped_no_nl)
        if directive_match:
            directive = directive_match.group(1).strip().lower()
            continue
        match = X_LINE_RE.match(line)
        if not match:
            continue
        reference = match.group("ref").strip()
        # Domyślnie akceptujemy typowe dyrektywy modelowe. --all-x-lines rozszerza skan.
        is_model_directive = (
            directive == "mesh"
            or directive.startswith("mesh_")
            or directive in ("collision_mesh", "crossing_heightdeformation")
        )
        if is_model_directive or all_x_lines:
            result.append(ScoReference(index, directive, reference))
    return result


def replace_x_extension_in_line(line: str) -> str:
    return re.sub(r"(?i)\.x(?=\s*(?:(?:;|//).*?)?(?:\r?\n)?$)", ".o3d", line, count=1)


def atomic_write(path: Path, data: bytes) -> None:
    fd, temp_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
    try:
        with os.fdopen(fd, "wb") as handle:
            handle.write(data)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(temp_name, str(path))
    except Exception:
        try:
            os.unlink(temp_name)
        except OSError:
            pass
        raise


def check_textures(model: ConvertedModel, texture_dir: Optional[Path]) -> List[str]:
    if texture_dir is None:
        return list(model.texture_names)
    missing = []
    for texture in model.texture_names:
        if resolve_case_insensitive(texture_dir, split_reference(texture)) is None:
            # OMSI często zastępuje BMP odpowiednikiem DDS.
            stem = Path(texture).stem
            dds = stem + ".dds"
            if resolve_case_insensitive(texture_dir, split_reference(dds)) is None:
                missing.append(texture)
    return missing


def process_scenery(
    scenery_root: Path,
    overwrite: bool,
    update_sco: bool,
    backup: bool,
    dry_run: bool,
    all_x_lines: bool,
    verbose: bool,
    flip_normals: bool,
) -> Stats:
    stats = Stats()
    conversion_cache: Dict[Tuple[str, str, bool], Tuple[bool, Optional[ConvertedModel], Optional[str]]] = {}
    winding_mode = WINDING_MODE_FLIPPED if flip_normals else WINDING_MODE_LEGACY

    sco_files = sorted(
        (p for p in scenery_root.rglob("*") if p.is_file() and p.suffix.lower() == ".sco"),
        key=lambda p: str(p).lower(),
    )

    for sco_path in sco_files:
        stats.sco_files += 1
        try:
            raw = sco_path.read_bytes()
            text, encoding, bom = decode_sco(raw)
            lines = text.splitlines(keepends=True)
            current_refs = scan_sco_references(lines, all_x_lines=all_x_lines)
            marker = parse_sco_marker(text)

            if marker.span is not None:
                stats.marked_sco += 1
                if verbose:
                    print(
                        "[MARKER] {}: wersja {}, tryb {}, modeli {}".format(
                            sco_path,
                            marker.version,
                            marker.winding_mode or "nieznany",
                            len(marker.references),
                        )
                    )

            legacy_refs: List[ScoReference] = []
            if not marker.references:
                legacy_refs = legacy_references_from_backup(
                    sco_path,
                    current_lines=lines,
                    all_x_lines=all_x_lines,
                )
                if legacy_refs:
                    stats.legacy_repaired += 1
                    print(
                        "[NAPRAWA] {}: odzyskano z .sco.bak {} wcześniejszych modeli; "
                        "zostaną zapisane ponownie z poprawionymi ścianami".format(
                            sco_path, len(legacy_refs)
                        )
                    )

            refs = merge_references(current_refs, marker.references, legacy_refs)
            if not refs:
                continue

            stats.references += len(refs)
            marker_keys = {reference_key(ref) for ref in marker.references}
            changed_lines = False
            successful_refs: List[ScoReference] = []

            for ref in refs:
                source, target, texture_dir = resolve_model_reference(sco_path, ref.reference, scenery_root)
                if source is None or target is None:
                    stats.missing += 1
                    print("[BRAK] {}: [{}] {}".format(sco_path, ref.directive, ref.reference))
                    continue

                ref_is_currently_marked = (
                    marker.version >= CONVERTER_VERSION
                    and marker.winding_mode == winding_mode
                    and reference_key(ref) in marker_keys
                )

                # Brak aktualnego markera oznacza, że plik mógł powstać starszą
                # wersją z niewłaściwą kolejnością ścian. Wymuszamy jednorazową
                # regenerację z oryginalnego .x, nawet jeśli O3D jest nowszy.
                force_winding_repair = not ref_is_currently_marked
                need_convert = (
                    overwrite
                    or force_winding_repair
                    or not target.exists()
                    or source.stat().st_mtime_ns > target.stat().st_mtime_ns
                )

                cache_key = (
                    str(source.resolve()).casefold(),
                    winding_mode,
                    force_winding_repair,
                )
                success = True
                model: Optional[ConvertedModel] = None
                error: Optional[str] = None

                if cache_key in conversion_cache:
                    success, model, error = conversion_cache[cache_key]
                elif need_convert:
                    if dry_run:
                        reason = "naprawa kolejności ścian" if force_winding_repair else "konwersja"
                        print("[PLAN: {}] {} -> {}".format(reason, source, target))
                    else:
                        try:
                            model = convert_x_to_o3d(source, target, flip_normals=flip_normals)
                            counts = validate_o3d_v7(target)
                            stats.converted += 1
                            print(
                                "[OK]   {} -> {} (wierzchołki: {}, trójkąty: {}, materiały: {}; {})".format(
                                    source,
                                    target,
                                    counts[0],
                                    counts[1],
                                    counts[2],
                                    "Flip Normals" if flip_normals else "bez Flip Normals",
                                )
                            )
                            missing_textures = check_textures(model, texture_dir)
                            for texture in missing_textures:
                                stats.textures_missing += 1
                                print("[TEKSTURA?] {} (szukano w {})".format(texture, texture_dir))
                        except Exception as exc:
                            success = False
                            error = "{}: {}".format(type(exc).__name__, exc)
                            stats.errors += 1
                            print("[BŁĄD] {}: {}".format(source, error))
                            if verbose:
                                traceback.print_exc()
                else:
                    stats.reused += 1
                    if verbose:
                        print("[ISTNIEJE/POTWIERDZONE] {}".format(target))

                conversion_cache[cache_key] = (success, model, error)
                if not success:
                    continue

                successful_refs.append(ScoReference(-1, ref.directive, ref.reference))
                if update_sco and ref.line_index >= 0:
                    updated_line = replace_x_extension_in_line(lines[ref.line_index])
                    if updated_line != lines[ref.line_index]:
                        lines[ref.line_index] = updated_line
                        changed_lines = True

            joined_text = "".join(lines)
            final_text = joined_text
            marker_changed = False
            if update_sco and successful_refs:
                marker_refs = merge_references(marker.references, successful_refs)
                final_text = apply_sco_marker(joined_text, marker_refs, winding_mode)
                marker_changed = final_text != joined_text

            if changed_lines or marker_changed:
                if dry_run:
                    print("[PLAN SCO] {}{}".format(
                        sco_path,
                        " + marker historii" if marker_changed else "",
                    ))
                else:
                    if backup:
                        backup_path = sco_path.with_name(sco_path.name + ".bak")
                        if not backup_path.exists():
                            shutil.copy2(str(sco_path), str(backup_path))
                    new_data = encode_sco(final_text, encoding, bom)
                    atomic_write(sco_path, new_data)
                    stats.sco_updated += 1
                    if marker_changed:
                        stats.markers_written += 1
                    print("[SCO]  Zaktualizowano {}".format(sco_path))
        except Exception as exc:
            stats.errors += 1
            print("[BŁĄD SCO] {}: {}: {}".format(sco_path, type(exc).__name__, exc))
            if verbose:
                traceback.print_exc()

    return stats

def find_scenery_root(input_path: Path) -> Path:
    path = input_path.expanduser().resolve()
    if not path.exists() or not path.is_dir():
        raise SystemExit("Folder nie istnieje: {}".format(path))
    if path.name.casefold() == "sceneryobjects":
        return path
    child = find_child_case_insensitive(path, "Sceneryobjects")
    if child is not None and child.is_dir():
        return child
    raise SystemExit(
        "Podaj folder Sceneryobjects albo główny folder OMSI 2 zawierający Sceneryobjects: {}".format(path)
    )


def build_arg_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Konwersja modeli .x wskazanych przez pliki .sco w OMSI 2 Sceneryobjects do O3D v7.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "folder",
        type=Path,
        help="folder Sceneryobjects albo główny folder OMSI 2",
    )
    parser.add_argument(
        "--overwrite",
        action="store_true",
        help="konwertuj ponownie także wtedy, gdy wynikowy .o3d jest aktualny",
    )
    parser.add_argument(
        "--no-flip-normals",
        action="store_true",
        help="wyłącz domyślne odwracanie ścian zgodne z eksporterem O3D Blendera",
    )
    parser.add_argument(
        "--no-update-sco",
        action="store_true",
        help="nie zmieniaj odwołań .x na .o3d w plikach .sco",
    )
    parser.add_argument(
        "--no-backup",
        action="store_true",
        help="nie twórz jednorazowej kopii pliku .sco z rozszerzeniem .sco.bak",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="tylko pokaż plan; niczego nie zapisuj",
    )
    parser.add_argument(
        "--all-x-lines",
        action="store_true",
        help="przetwarzaj każdą samodzielną linię kończącą się .x, również poza typowymi dyrektywami modelowymi",
    )
    parser.add_argument(
        "--verbose",
        action="store_true",
        help="pokaż pominięte istniejące pliki i pełne tracebacki błędów",
    )
    return parser


def main(argv: Optional[Sequence[str]] = None) -> int:
    args = build_arg_parser().parse_args(argv)
    scenery_root = find_scenery_root(args.folder)
    print("Sceneryobjects: {}".format(scenery_root))
    stats = process_scenery(
        scenery_root=scenery_root,
        overwrite=args.overwrite,
        update_sco=not args.no_update_sco,
        backup=not args.no_backup,
        dry_run=args.dry_run,
        all_x_lines=args.all_x_lines,
        verbose=args.verbose,
        flip_normals=not args.no_flip_normals,
    )
    print(
        "\nPodsumowanie: SCO={}, modele z historii/odwołań={}, skonwertowane={}, "
        "użyte potwierdzone={}, zaktualizowane SCO={}, zapisane markery={}, "
        "naprawione starsze SCO={}, brak modeli={}, brak tekstur={}, błędy={}".format(
            stats.sco_files,
            stats.references,
            stats.converted,
            stats.reused,
            stats.sco_updated,
            stats.markers_written,
            stats.legacy_repaired,
            stats.missing,
            stats.textures_missing,
            stats.errors,
        )
    )
    return 1 if stats.errors or stats.missing else 0


if __name__ == "__main__":
    raise SystemExit(main())
