#!/usr/bin/env python3
"""Find the livery file that breaks aircraft creation in dcs-mcp.

    python find_broken_livery.py
    python find_broken_livery.py "C:\\Program Files\\Eagle Dynamics\\DCS World"
    python find_broken_livery.py <any folder that contains liveries>

WHAT THIS IS FOR

If creating any aircraft fails with

    could not convert string to float: '.'

then one livery file somewhere on your machine has a malformed number in it,
and the error does not say which. This script finds it.

Pure standard library -- no dcs-mcp, no pydcs, no DCS running. Python 3.8+.
It only reads files; it changes nothing.

ARGUMENTS ARE OPTIONAL, AND YOU CANNOT GET THEM WRONG

With no arguments it locates your DCS installation and your Saved Games folder
by itself. Any path you DO pass is searched every plausible way -- as a DCS
installation, and as a livery tree in its own right -- so the installation
folder, a Saved Games folder, and a Liveries folder all work.

That is deliberate. An earlier version used the argument ONLY as an
installation path while its own help text told users with a non-default setup
to pass their Saved Games folder. Following those instructions produced a scan
that searched nothing relevant, reported "no problems found", and sent the user
away -- a correct diagnosis made to look wrong by the tool meant to give it.

IT ALWAYS SAYS WHERE IT LOOKED, BEFORE IT SAYS WHAT IT FOUND

Every location is printed with the number of files read, and missing ones are
marked. A scanner that reports "nothing found" without saying where it searched
is silence that looks like data, and a diagnostic is the last place that can be
afforded: "Saved Games\\DCS\\Liveries -- not found" tells the user something
real, while a bare "no problems" may convince them their problem is elsewhere
when it is not.

WHAT IT LOOKS FOR, precisely

Not "a dot". `.1` is a perfectly good number and parses fine. The failure is a
dot that starts a number and is not followed by any digit -- for example

    {"num_black", DECAL, "num_black", .};      <-- a bare dot as a value

which the parser reads as the number ".", and "." is not a number. Two dots in
a row (`1.2.3`) produce a different, harmless error, so this script reports
only the case that actually stops you.
"""
from __future__ import annotations

import os
import sys
import zipfile

NUMSTART = set("0123456789.-")
NUMBODY = set("0123456789.eE-")

# The punctuation a value can follow in a Lua data file: an assignment, a table
# constructor, a separator, an index, a call. Nothing else introduces a number.
VALUE_STARTERS = set("={,[(")


def _starts_a_value(text: str, i: int) -> bool:
    """Is position i somewhere a Lua value may begin?

    Walks back over whitespace to the previous significant character. Start of
    file counts, so a file that is a bare value is not silently skipped.
    """
    k = i - 1
    while k >= 0 and text[k] in " \t\r\n":
        k -= 1
    return k < 0 or text[k] in VALUE_STARTERS


def bad_number_positions(text: str):
    """Yield (line, col, token) for every number token that is not a number.

    Mirrors the parser's own scanner: a token beginning with a digit, '-' or
    '.' is accumulated over [0-9.eE-] and then converted. We report only what
    float() would refuse, so this does not flag `.1`, `1.`, or `1e-3`.

    A SCANNER WITHOUT THE PARSER'S GRAMMAR IS NOT THE PARSER'S SCANNER.

    Corrected 12 Aug 2026, after running this against a clean DCS install for
    the first time: 32,251 findings across 19,840 files, the first of them
    A-10A.lua -- a stock file that is not broken at all, presented under "the
    first is almost certainly your problem" with instructions to move its
    folder out of DCS. Following this tool's advice would have damaged an
    install to fix a problem it does not have.

    The cause is a copied scanner without its context. pydcs's scanner is only
    ever entered where a VALUE is expected, so a leading '.' there really must
    begin a number. Run across whole files it also meets Lua's concatenation
    operator (`"a" .. b`) and field access after a bracket (`).x`), neither of
    which is a number and both of which are everywhere in DCS's Lua.

    So the token must now START A VALUE -- the character before it, ignoring
    whitespace, has to be one of = { , [ ( or the file's start. That is the
    grammar context the parser has and the raw scan lacked, and it matches
    what this file's own header always claimed to look for.

    The lesson is not about Lua. An instrument was written by mirroring code
    out of the place that gave it meaning, then verified against a fixture
    containing the fault -- which proves it can fire, never that it is silent
    when it should be. It needed one run against a healthy machine.
    """
    i, n = 0, len(text)
    line, col = 1, 1
    in_str = None
    while i < n:
        c = text[i]
        if in_str:
            if c == in_str and text[i - 1] != "\\":
                in_str = None
        elif c in ('"', "'"):
            in_str = c
        elif c == "-" and i + 1 < n and text[i + 1] == "-":
            while i < n and text[i] != "\n":                  # a Lua comment
                i += 1
            line, col = line + 1, 1
            i += 1
            continue
        elif c in NUMSTART:
            prev = text[i - 1] if i else " "
            if not (prev.isalnum() or prev == "_"):           # not mid-identifier
                j, tok = i, ""
                while j < n and text[j] in NUMBODY:
                    tok += text[j]
                    j += 1
                # ... and it has to be somewhere a value can appear. See the
                # docstring: without this the scan reports every string
                # concatenation in DCS as a malformed number.
                if tok and tok not in ("-", "...") and _starts_a_value(text, i):
                    try:
                        float(tok)
                    except ValueError:
                        yield line, col, tok
                    i, col = j, col + (j - i)
                    continue
        if c == "\n":
            line, col = line + 1, 1
        else:
            col += 1
        i += 1


def check_bytes(raw: bytes, where: str, out: list) -> None:
    try:
        text = raw.decode("utf-8", "replace")
    except Exception:                                          # noqa: BLE001
        return
    for ln, cl, tok in bad_number_positions(text):
        out.append((where, ln, cl, tok))


def is_livery_path(p: str) -> bool:
    """Is this file one that gets parsed AS A LIVERY?

    Exactly one filename is: `description.lua`, loose or inside a .zip, which
    the cache opens BY THAT NAME. Nothing else on the machine is ever read as a
    livery, so nothing else can produce the failure this script diagnoses.

    IT KEYS ON THE FILENAME, NOT THE FOLDER, AND THAT IS THE WHOLE POINT.

    The first version of this fix asked whether the path contained a `Liveries`
    directory. That is true of every livery in a normal install and it made the
    clean-install run silent -- but it quietly broke the promise the site makes
    one paragraph above the download link: "You cannot pass the wrong one."
    Someone keeping skins in `D:\\Skins` and passing that folder would have had
    every file skipped, been told "no malformed numbers found in any of them",
    and gone away with a real problem and a clean bill of health.

    That is the exact failure this file's own header describes from an earlier
    round -- a correct diagnosis made to look wrong by the tool meant to give
    it -- reintroduced while fixing something else. Caught by checking the
    published page against the changed behaviour rather than the other way
    round.

    Keying on the filename is both narrower and location-independent: it works
    wherever the user keeps their liveries, and it cannot drag in A-10A.lua.

    THE SCOPE WAS WRONG AND IT MATTERED. The search walked the whole DCS
    installation -- 10,839 files at the root alone -- which meant flight
    models, weapon definitions and the Mission Editor's own tables were all
    searched for a fault that only liveries can cause. Every finding outside a
    Liveries folder was noise by construction, however the scanner behaved.

    Two independent fixes for one bad report is deliberate. The grammar fix
    alone would have quietened this one install; a different machine with an
    oddly-written stock .lua would have flooded again, and the user would have
    been told to move a core DCS folder. Narrow what you look at AND look
    correctly.
    """
    name = os.path.basename(p).lower()
    return name == "description.lua" or name.endswith(".zip")


def walk(root: str, out: list, seen: list) -> None:
    if not root or not os.path.isdir(root):
        return
    for dirpath, _dirs, files in os.walk(root):
        for fn in files:
            low = fn.lower()
            p = os.path.join(dirpath, fn)
            if not is_livery_path(p):
                continue
            if low.endswith(".lua"):
                seen.append(p)
                try:
                    with open(p, "rb") as fh:
                        check_bytes(fh.read(), p, out)
                except OSError:
                    pass
            elif low.endswith(".zip"):
                seen.append(p)
                try:
                    with zipfile.ZipFile(p) as z:
                        # description.lua only -- the exact name the cache
                        # opens. A zipped livery may carry other .lua files
                        # that are never read as liveries.
                        for nm in z.namelist():
                            if os.path.basename(nm).lower() == "description.lua":
                                check_bytes(z.read(nm), f"{p} :: {nm}", out)
                except Exception:                              # noqa: BLE001
                    pass


def livery_roots(paths: list) -> list:
    """Every location worth searching: the given paths, plus autodetection.

    Each supplied path is treated BOTH as a DCS installation and as a livery
    tree in its own right, so a user who passes the "wrong" kind of path still
    gets a correct answer. See the module docstring for why that matters.
    """
    roots: list = []

    def add(p: str) -> None:
        if p and p not in roots:
            roots.append(p)

    supplied = [p.rstrip("\\/") for p in paths]
    if not supplied:
        for guess in (
            r"C:\Program Files\Eagle Dynamics\DCS World",
            r"C:\Program Files\Eagle Dynamics\DCS World OpenBeta",
            r"C:\Program Files\Eagle Dynamics\DCS World Server",
        ):
            if os.path.isdir(guess):
                supplied.append(guess)

    for base in supplied:
        add(os.path.join(base, "Bazar", "Liveries"))
        add(os.path.join(base, "CoreMods"))
        add(os.path.join(base, "Mods"))
        add(os.path.join(base, "Liveries"))
        add(base)                                    # and the folder itself

    # Saved Games is ALWAYS searched, whatever was passed -- it is where users
    # install their own liveries, and the likeliest home of a broken one.
    sg = os.path.join(os.path.expanduser("~"), "Saved Games")
    if os.path.isdir(sg):
        for entry in sorted(os.listdir(sg)):
            if entry.upper().startswith("DCS"):
                add(os.path.join(sg, entry, "Liveries"))
    return roots


def main() -> int:
    args = [a for a in sys.argv[1:] if a.strip()]
    roots = livery_roots(args)

    print("=" * 70)
    print("SEARCHED THESE LOCATIONS")
    print("=" * 70)

    out: list = []
    total = 0
    any_real = False
    saved_games_had_files = False
    for r in roots:
        if not os.path.isdir(r):
            print(f"    not found   {r}")
            continue
        any_real = True
        seen: list = []
        walk(r, out, seen)
        total += len(seen)
        if seen and "saved games" in r.lower():
            saved_games_had_files = True
        print(f"  {len(seen):5} files   {r}")
    print()

    if not any_real:
        print("NONE of those locations exist, so nothing was searched.")
        print("This is NOT a clean result.\n")
        print("Pass the folder your liveries are in, for example:")
        print(r'   python find_broken_livery.py "C:\Program Files\Eagle Dynamics\DCS World"')
        return 2

    if total == 0:
        print("Those locations exist but hold no .lua or .zip files, so nothing")
        print("was actually checked. This is NOT a clean result -- point the")
        print("script at the folder your liveries are in.")
        return 2

    # DEDUPLICATE. The roots deliberately overlap -- a supplied path is
    # searched as <base>/Bazar/Liveries AND as <base> itself -- so one file can
    # be read twice. Without this, a single bad livery is reported as "FOUND 2
    # malformed number(s)" and the user goes looking for a second one that does
    # not exist. Found by running the copy actually served from the site.
    seen_hits = set()
    deduped = []
    for hit in out:
        if hit not in seen_hits:
            seen_hits.add(hit)
            deduped.append(hit)
    out = deduped

    print(f"Read {total} file(s) in total.\n")

    if not out:
        print("No malformed numbers found in any of them.")
        print()
        # A "clean" result is only as good as the search behind it. If no
        # Saved Games livery tree turned up anything, say so plainly rather
        # than letting the absence pass as evidence -- that is the likeliest
        # home of a broken livery, and a non-default Saved Games location is
        # the one thing this script cannot autodetect.
        if not saved_games_had_files:
            print("BUT NOTE: no livery files were found under Saved Games, which")
            print("is where downloaded liveries normally live. If yours are")
            print("somewhere else, this search has not seen them. You can pass")
            print("more than one folder:")
            print()
            print(r'   python find_broken_livery.py "C:\...\DCS World" "D:\My DCS\Liveries"')
            print()
        print("If aircraft creation still fails, the cause may be elsewhere. Send")
        print("the WHOLE of this output back, including the list of locations")
        print("above, so we can see where the search did and did not reach.")
        return 0

    print(f"FOUND {len(out)} malformed number(s). "
          f"The first is almost certainly your problem:\n")
    for where, ln, cl, tok in out:
        print(f"  {where}")
        print(f"      line {ln}, column {cl}:  {tok!r} is not a number\n")
    print("To fix: move that livery's FOLDER out of the Liveries directory")
    print("(do not just rename the file), then restart Claude Desktop.")
    return 1


if __name__ == "__main__":
    raise SystemExit(main())
