"""Check Python code passes black style validation via flake8.

This is a plugin for the tool flake8 tool for checking Python
source code using the tool black.
"""

import sys
from os import path
from pathlib import Path

if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib

import black

from flake8 import utils as stdin_utils
from flake8 import LOG


__version__ = "0.4.0"

black_prefix = "BLK"


try:
    black.decode_bytes(b"")
except TypeError:
    # TypeError: decode_bytes() missing required argument 'mode' (pos 2)
    decode_bytes_wrapper = black.decode_bytes
else:
    # Probably running older than black 25.9.0
    def decode_bytes_wrapper(src, mode):
        """Proxy wrapper for backward compatibility."""
        return black.decode_bytes(src)


def find_diff_start(old_src, new_src):
    """Find line number and column number where text first differs."""
    old_lines = old_src.split("\n")
    new_lines = new_src.split("\n")

    for line in range(min(len(old_lines), len(new_lines))):
        old = old_lines[line]
        new = new_lines[line]
        if old == new:
            continue
        for col in range(min(len(old), len(new))):
            if old[col] != new[col]:
                return line, col
        # Difference at the end of the line...
        return line, min(len(old), len(new))
    # Difference at the end of the file...
    return min(len(old_lines), len(new_lines)), 0


class BadBlackConfig(ValueError):
    """Bad black TOML configuration file."""

    pass


def load_black_mode(toml_filename=None):
    """Load black configuration TOML file (or return defaults) as black.Mode object."""
    if not toml_filename:
        return black.FileMode(
            target_versions=set(),
            line_length=black.DEFAULT_LINE_LENGTH,  # Expect to be 88
            string_normalization=True,
            magic_trailing_comma=True,
            preview=False,
        )

    LOG.info("flake8-black: loading black settings from %s", toml_filename)
    try:
        with toml_filename.open(mode="rb") as toml_file:
            pyproject_toml = tomllib.load(toml_file)
    except ValueError:
        LOG.info("flake8-black: invalid TOML file %s", toml_filename)
        raise BadBlackConfig(path.relpath(toml_filename))
    config = pyproject_toml.get("tool", {}).get("black", {})
    black_config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}

    # Extract the fields we care about,
    # cast to int explicitly otherwise line length could be a string
    return black.Mode(
        target_versions={
            black.TargetVersion[val.upper()]
            for val in black_config.get("target_version", [])
        },
        line_length=int(black_config.get("line_length", black.DEFAULT_LINE_LENGTH)),
        string_normalization=not black_config.get("skip_string_normalization", False),
        magic_trailing_comma=not black_config.get("skip_magic_trailing_comma", False),
        preview=bool(black_config.get("preview", False)),
    )


black_config = {None: load_black_mode()}  # None key's value is default config


class BlackStyleChecker:
    """Checker of Python code using black."""

    name = "black"
    version = __version__
    override_config = None

    STDIN_NAMES = {"stdin", "-", "(none)", None}

    def __init__(self, tree, filename="(none)"):
        """Initialise."""
        self.tree = tree
        self.filename = filename

    @property
    def _file_mode(self):
        """Return black.FileMode object, using local pyproject.toml as needed."""
        if self.override_config:
            return self.override_config

        # Unless using override, we look for pyproject.toml
        project_root = black.find_project_root(
            ("." if self.filename in self.STDIN_NAMES else self.filename,)
        )
        if isinstance(project_root, tuple):
            # black stable 22.1.0 update find_project_root return value
            # from Path to Tuple[Path, str]
            project_root = project_root[0]
        path = project_root / "pyproject.toml"

        if path in black_config:
            # Already loaded
            LOG.debug("flake8-black: %s using pre-loaded %s", self.filename, path)
            return black_config[path]
        elif path.is_file():
            # Use this pyproject.toml for this python file,
            # (unless configured with global override config)
            # This should be thread safe - does not matter even if
            # two workers load and cache this file at the same time
            black_config[path] = load_black_mode(path)
            LOG.debug("flake8-black: %s using newly loaded %s", self.filename, path)
            return black_config[path]
        else:
            # No project specific file, use default
            LOG.debug("flake8-black: %s using defaults", self.filename)
            return black_config[None]

    @classmethod
    def add_options(cls, parser):
        """Add black-config options."""
        parser.add_option(
            "--black-config",
            metavar="TOML_FILENAME",
            default=None,
            action="store",
            # type="string",  <- breaks using None as a sentinel
            # normalize_paths=True,  <- broken and breaks None as a sentinel
            # https://gitlab.com/pycqa/flake8/issues/562
            # https://gitlab.com/pycqa/flake8/merge_requests/337
            parse_from_config=True,
            help="Path to black TOML configuration file (overrides the "
            "default 'pyproject.toml' detection; use empty string '' to mean "
            "ignore all 'pyproject.toml' files).",
        )

    @classmethod
    def parse_options(cls, optmanager, options, extra_args):
        """Parse black-config options."""
        # We have one and only one flake8 plugin configuration
        if options.black_config is None:
            LOG.info("flake8-black: No black configuration set")
            cls.override_config = None
            return
        elif not options.black_config:
            LOG.info("flake8-black: Explicitly using no black configuration file")
            cls.override_config = black_config[None]  # explicitly use defaults
            return

        # Validate the path setting - handling relative paths ourselves,
        # see https://gitlab.com/pycqa/flake8/issues/562
        black_config_path = Path(options.black_config)
        if options.config:
            # Assume black config path was via flake8 config file
            base_path = Path(path.dirname(path.abspath(options.config)))
            black_config_path = base_path / black_config_path
        if not black_config_path.is_file():
            # Want flake8 to abort, see:
            # https://gitlab.com/pycqa/flake8/issues/559
            raise ValueError(
                "Plugin flake8-black could not find specified black config file: "
                "--black-config %s" % black_config_path
            )

        # Now load the TOML file, and the black section within it
        # This configuration is to override any local pyproject.toml
        try:
            cls.override_config = black_config[black_config_path] = load_black_mode(
                black_config_path
            )
        except BadBlackConfig:
            # Could raise BLK997, but view this as an abort condition
            raise ValueError(
                "Plugin flake8-black could not parse specified black config file: "
                "--black-config %s" % black_config_path
            )

    def run(self):
        """Use black to check code style."""
        msg = None
        line = 0
        col = 0

        try:
            if self.filename in self.STDIN_NAMES:
                self.filename = "stdin"
                raw_source = stdin_utils.stdin_get_value().encode("utf-8")
            else:
                with open(self.filename, "rb") as buf:
                    raw_source = buf.read()
        except Exception as e:
            raw_source = ""
            msg = "900 Failed to load file: %s" % e

        if not raw_source and not msg:
            # Empty file (good)
            return
        elif raw_source:
            # Call black...
            try:
                file_mode = self._file_mode
                file_mode.is_pyi = self.filename and self.filename.endswith(".pyi")
                source, _, _ = decode_bytes_wrapper(raw_source, file_mode)
                new_code = black.format_file_contents(
                    source, mode=file_mode, fast=False
                )
            except black.NothingChanged:
                return
            except black.InvalidInput:
                msg = "901 Invalid input."
            except (BadBlackConfig, tomllib.TOMLDecodeError):
                # Seems in black 25.9.0 the TOMLDecodeError is triggered while
                # finding project root, not trivial to get the path
                msg = "997 Invalid TOML file"
            except Exception as err:
                msg = "999 Unexpected exception: %r" % err
            else:
                assert (
                    new_code != source
                ), "Black made changes without raising NothingChanged"
                line, col = find_diff_start(source, new_code)
                line += 1  # Strange as col seems to be zero based?
                msg = "100 Black would make changes."
        # If we don't know the line or column numbers, leaving as zero.
        yield line, col, black_prefix + msg, type(self)
