#     Copyright 2026, Kay Hayen, mailto:kay.hayen@gmail.com find license text at end of file


"""Read/write source code from files.

Reading is tremendously more complex than one might think, due to encoding
issues and version differences of Python versions.
"""

import codecs
import os
import re
import sys

from nuitka.__past__ import unicode
from nuitka.containers.OrderedSets import OrderedSet
from nuitka.format.FileFormatting import formatC
from nuitka.options.Options import (
    shallGenerateReadableCode,
    shallShowSourceModifications,
)
from nuitka.plugins.Hooks import onModuleSourceCode
from nuitka.PythonVersions import (
    getSourceDecodeErrorReason2,
    python_version,
    python_version_str,
)
from nuitka.SourceCodeReferences import makeSourceReferenceFromFilename
from nuitka.Tracing import general, inclusion_logger, my_print
from nuitka.utils.Diffs import getUnifiedDiff
from nuitka.utils.FileOperations import (
    getReportPath,
    hasFilenameExtension,
    putTextFileContents,
    stripFileContentsBOM,
)
from nuitka.utils.ModuleNames import ModuleName, checkModuleName
from nuitka.utils.Shebang import getShebangFromSource, parseShebang
from nuitka.utils.Utils import isWin32OrPosixWindows

from .SyntaxErrors import raiseSyntaxError

_fstrings_installed = False


def _installFutureFStrings():
    """Install fake UTF8 handle just as future-fstrings does."""

    # Singleton, pylint: disable=global-statement
    global _fstrings_installed

    if _fstrings_installed:
        return

    # TODO: Not supporting anything before that.
    if python_version >= 0x360:
        # Play trick for of "future_strings" PyPI package support. It's not needed,
        # but some people use it even on newer Python.
        try:
            codecs.lookup("future-fstrings")
        except LookupError:
            import encodings

            utf8 = encodings.search_function("utf8")
            codec_map = {"future-fstrings": utf8, "future_fstrings": utf8}
            codecs.register(codec_map.get)
    else:
        try:
            import future_fstrings
        except ImportError:
            pass
        else:
            future_fstrings.register()

    _fstrings_installed = True


def _readSourceCodeFromFilename3(source_filename):
    # Only using this for Python3, for Python2 it's too buggy.
    import tokenize

    _installFutureFStrings()

    try:
        with tokenize.open(source_filename) as source_file:
            return source_file.read()
    except UnicodeDecodeError as e:
        # Match the parser wording for declared but incompatible source
        # encodings.
        raiseSyntaxError(
            "encoding problem: %s" % e.encoding,
            makeSourceReferenceFromFilename(source_filename),
            display_line=False,
        )


def _detectEncoding2(source_file):
    # Detect the encoding.
    encoding = "ascii"
    encoding_declared = False

    line1 = source_file.readline()
    line1, bom = stripFileContentsBOM(line1)

    if bom:
        # BOM marker makes it clear.
        encoding = "utf-8"
        encoding_declared = True
    else:
        line1_match = re.search(b"coding[:=]\\s*([-\\w.]+)", line1)

        if line1_match:
            encoding = line1_match.group(1)
            encoding_declared = True
        else:
            line2 = source_file.readline()

            line2_match = re.search(b"coding[:=]\\s*([-\\w.]+)", line2)

            if line2_match:
                encoding = line2_match.group(1)
                encoding_declared = True

    source_file.seek(0)

    return encoding, encoding_declared


def _readSourceCodeFromFilename2(source_filename):
    _installFutureFStrings()

    # Detect the encoding, we do not know it, pylint: disable=unspecified-encoding
    with open(source_filename, "rU") as source_file:
        encoding, encoding_declared = _detectEncoding2(source_file)

        source_code = source_file.read()

        # Try and detect SyntaxError from missing or wrong encodings.
        if type(source_code) is not unicode:
            try:
                _source_code = source_code.decode(encoding)
            except UnicodeDecodeError as e:
                if encoding_declared:
                    raiseSyntaxError(
                        getSourceDecodeErrorReason2(source_filename),
                        makeSourceReferenceFromFilename(source_filename),
                        display_line=False,
                    )
                else:
                    lines = source_code.split("\n")
                    so_far = 0

                    for count, line in enumerate(lines):
                        so_far += len(line) + 1

                        if so_far > e.args[2]:
                            break
                    else:
                        # Cannot happen, decode error implies non-empty.
                        count = -1

                    wrong_byte = re.search(
                        "byte 0x([a-f0-9]{2}) in position", str(e)
                    ).group(1)

                    raiseSyntaxError(
                        """\
Non-ASCII character '\\x%s' in file %s on line %d, but no encoding declared; \
see http://python.org/dev/peps/pep-0263/ for details"""
                        % (wrong_byte, source_filename, count + 1),
                        makeSourceReferenceFromFilename(source_filename).atLineNumber(
                            count + 1
                        ),
                        display_line=False,
                    )

    return source_code


def getSourceCodeDiff(source_code, source_code_modified):

    diff = getUnifiedDiff(
        old_lines=source_code.splitlines(),
        new_lines=source_code_modified.splitlines(),
        old_filename="original",
        new_filename="modified",
        num_lines=3,
    )

    return list(diff)


_source_code_cache = {}


def readSourceCodeFromFilenameWithInformation(
    module_name, source_filename, pre_load=False
):
    key = (module_name, source_filename)

    if key in _source_code_cache:
        if pre_load:
            return _source_code_cache[key]
        else:
            return _source_code_cache.pop(key)

    if python_version < 0x300:
        source_code = _readSourceCodeFromFilename2(source_filename)
    else:
        source_code = _readSourceCodeFromFilename3(source_filename)

    # Allow plugins to mess with source code. Test code calls this without a
    # module and doesn't want any changes from plugins in that case.
    if module_name is not None:
        source_code_modified, contributing_plugins = onModuleSourceCode(
            module_name=module_name,
            source_filename=source_filename,
            source_code=source_code,
        )
    else:
        source_code_modified = source_code
        contributing_plugins = ()

    if (
        module_name is not None
        and shallShowSourceModifications(module_name)
        and source_code_modified != source_code
    ):
        source_diff = getSourceCodeDiff(source_code, source_code_modified)

        if source_diff:
            my_print("%s:" % module_name.asString())
            for line in source_diff:
                my_print(line, end="\n" if not line.startswith("---") else "")

    result = source_code_modified, source_code, contributing_plugins

    if pre_load:
        _source_code_cache[key] = result

    return result


def readSourceCodeFromFilename(module_name, source_filename, pre_load=False):
    return readSourceCodeFromFilenameWithInformation(
        module_name=module_name,
        source_filename=source_filename,
        pre_load=pre_load,
    )[0]


def checkPythonVersionFromCode(source_code):
    # There is a lot of cases to consider, pylint: disable=too-many-branches

    shebang = getShebangFromSource(source_code)

    if shebang is not None:
        binary, _args = parseShebang(shebang)

        if not isWin32OrPosixWindows():
            try:
                if os.path.samefile(sys.executable, binary):
                    return True
            except OSError:  # Might not exist
                pass

        basename = os.path.basename(binary)

        # Not sure if we should do that.
        if basename == "python":
            result = python_version < 0x300
        elif basename == "python3":
            result = python_version >= 0x300
        elif basename == "python2":
            result = python_version < 0x300
        elif basename == "python2.7":
            result = python_version < 0x300
        elif basename == "python2.6":
            result = python_version < 0x270
        elif basename == "python3.2":
            result = 0x330 > python_version >= 0x300
        elif basename == "python3.3":
            result = 0x340 > python_version >= 0x330
        elif basename == "python3.4":
            result = 0x350 > python_version >= 0x340
        elif basename == "python3.5":
            result = 0x360 > python_version >= 0x350
        elif basename == "python3.6":
            result = 0x370 > python_version >= 0x360
        elif basename == "python3.7":
            result = 0x380 > python_version >= 0x370
        elif basename == "python3.8":
            result = 0x390 > python_version >= 0x380
        elif basename == "python3.9":
            result = 0x3A0 > python_version >= 0x390
        elif basename == "python3.10":
            result = 0x3B0 > python_version >= 0x3A0
        elif basename == "python3.11":
            result = 0x3C0 > python_version >= 0x3B0
        elif basename == "python3.12":
            result = 0x3D0 > python_version >= 0x3C0
        elif basename == "python3.13":
            result = 0x3E0 > python_version >= 0x3D0
        elif basename == "python3.14":
            result = 0x3F0 > python_version >= 0x3E0
        else:
            result = None

        if result is False:
            return general.sysexit("""\
The program you compiled wants to be run with: %s.

Nuitka is currently running with Python version '%s', which seems to not
match that. Nuitka cannot guess the Python version of your source code. You
therefore might want to specify: '%s -m nuitka'.

That will make use the correct Python version for Nuitka.
""" % (shebang, python_version_str, binary))


def readSourceLine(source_ref):
    """Read a single source line, mainly for use in error reporting only."""
    import linecache

    return linecache.getline(
        filename=source_ref.getFilename(), lineno=source_ref.getLineNumber()
    )


def readSourceLines(source_ref):
    """Read a source lines with linecache, for use with cached function source finding."""
    import linecache

    return linecache.getlines(source_ref.filename)


def writeSourceCode(filename, source_code, logger, assume_yes_for_downloads):
    # Prevent accidental overwriting. When this happens the collision detection
    # or something else has failed.
    assert not os.path.isfile(filename), filename

    putTextFileContents(filename=filename, contents=source_code, encoding="latin1")

    if hasFilenameExtension(filename, (".c", ".h")) and shallGenerateReadableCode():
        formatC(
            logger=logger,
            filename=filename,
            effective_filename=filename,
            check_only=False,
            assume_yes_for_downloads=assume_yes_for_downloads,
            reject_message=None,
        )


def _checkAndAddModuleName(pyi_deps, pyi_filename, line_number, candidate):
    if type(candidate) is not ModuleName:
        if (
            not checkModuleName(candidate)
            or candidate.startswith(" ")
            or candidate.endswith(" ")
        ):
            inclusion_logger.warning(
                "Encountered wrong '.pyi' parsing result in '%s:%d' giving illegal module name '%s'."
                % (getReportPath(pyi_filename), line_number, candidate)
            )
            return None

    pyi_deps.add(ModuleName(candidate))


def parsePyIFile(module_name, package_name, pyi_filename):
    """Parse a pyi file for the given module name and extract imports made."""

    # Complex stuff, pylint: disable=too-many-branches,too-many-statements

    pyi_deps = OrderedSet()

    # Flag signalling multiline import handling
    in_import = False
    in_import_part = ""
    in_quote = None

    pyi_contents = readSourceCodeFromFilename(
        # Do not pass module name, or else plugins modify it, which they
        # should not.
        module_name=None,
        source_filename=pyi_filename,
    )

    for line_number, line in enumerate(pyi_contents.splitlines(), start=1):
        line = line.strip()

        if in_quote:
            if line.endswith(in_quote):
                in_quote = None

            continue

        if line.startswith('"""') and not line.endswith('"""'):
            in_quote = '"""'
            continue

        if line.startswith("'''") and not line.endswith("'''"):
            in_quote = "'''"
            continue

        if not in_import:
            if line.startswith("import "):
                _checkAndAddModuleName(
                    pyi_deps=pyi_deps,
                    pyi_filename=pyi_filename,
                    line_number=line_number,
                    candidate=line[7:].split("#", 1)[0].strip(),
                )
            elif line.startswith("from "):
                parts = line.split(None, 3)
                assert parts[0] == "from"
                if parts[2] != "import":
                    continue

                origin_name = parts[1]

                # These are never submodules.
                if origin_name in ("typing", "__future__"):
                    continue

                if origin_name == ".":
                    origin_name = package_name
                else:
                    dot_count = 0
                    while origin_name.startswith("."):
                        origin_name = origin_name[1:]
                        dot_count += 1

                    if dot_count > 0:
                        if origin_name:
                            origin_name = package_name.getRelativePackageName(
                                level=dot_count - 1
                            ).getChildNamed(origin_name)
                        else:
                            origin_name = package_name.getRelativePackageName(
                                level=dot_count - 1
                            )

                if origin_name != module_name:
                    _checkAndAddModuleName(
                        pyi_deps=pyi_deps,
                        pyi_filename=pyi_filename,
                        line_number=line_number,
                        candidate=origin_name,
                    )

                imported = parts[3].split("#", 1)[0].strip()
                if imported.startswith("("):
                    # Handle multiline imports
                    if not imported.endswith(")"):
                        in_import = True
                        imported = imported[1:]
                        in_import_part = origin_name
                        assert in_import_part, (
                            "Multiline part in file %s cannot be empty" % pyi_filename
                        )
                    else:
                        in_import = False
                        imported = imported[1:-1]
                        assert imported

                if imported == "*":
                    continue

                for name in imported.split(","):
                    if name:
                        name = name.strip()
                        _checkAndAddModuleName(
                            pyi_deps=pyi_deps,
                            pyi_filename=pyi_filename,
                            line_number=line_number,
                            candidate=origin_name + "." + name,
                        )

        else:  # In import
            imported = line
            if imported.endswith(")"):
                imported = imported[0:-1]
                in_import = False

            for name in imported.split(","):
                name = name.strip()
                if name:
                    _checkAndAddModuleName(
                        pyi_deps=pyi_deps,
                        pyi_filename=pyi_filename,
                        line_number=line_number,
                        candidate=in_import_part + "." + name,
                    )

    # Rejected module names have become None.
    if None in pyi_deps:
        pyi_deps.remove(None)

    return pyi_deps


#     Part of "Nuitka", an optimizing Python compiler that is compatible and
#     integrates with CPython, but also works on its own.
#
#     Licensed under the GNU Affero General Public License, Version 3 (the "License");
#     you may not use this file except in compliance with the License.
#     You may obtain a copy of the License at
#
#        https://www.gnu.org/licenses/agpl-3.0.txt
#
#     See also: "Nuitka Runtime Library Exception, Version 1.0" in file
#     "LICENSE-RUNTIME.txt" for additional permissions granted under Section 7.
#
#     Unless required by applicable law or agreed to in writing, software
#     distributed under the License is distributed on an "AS IS" BASIS,
#     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#     See the License for the specific language governing permissions and
#     limitations under the License.
