"""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.
"""

from os import path
from pathlib import Path

import black
import tomli

from flake8 import utils as stdin_utils
from flake8 import LOG


__version__ = "0.3.3"

black_prefix = "BLK"


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 a black configuration TOML file (or return defaults) as FileMode."""
    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 = tomli.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.FileMode(
        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):
        """Adding black-config option."""
        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):
        """Adding black-config option."""
        # 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"
                source = stdin_utils.stdin_get_value()
            else:
                with open(self.filename, "rb") as buf:
                    source, _, _ = black.decode_bytes(buf.read())
        except Exception as e:
            source = ""
            msg = "900 Failed to load file: %s" % e

        if not source and not msg:
            # Empty file (good)
            return
        elif source:
            # Call black...
            try:
                file_mode = self._file_mode
                file_mode.is_pyi = self.filename and self.filename.endswith(".pyi")
                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 as err:
                msg = "997 Invalid TOML file: %s" % err
            except Exception as err:
                msg = "999 Unexpected exception: %s" % 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)
