#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; version 2 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see
# https://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
"""
Copyright (C) 2010-2011  Lucas De Marchi <lucas.de.marchi@gmail.com>
Copyright (C) 2011  ProFUSION embedded systems
"""

import argparse
import configparser
import ctypes
import fnmatch
import itertools
import os
import re
import shlex
import sys
import textwrap
from collections.abc import Iterable, Sequence
from re import Match, Pattern
from typing import (
    Any,
    Optional,
    TextIO,
)

if sys.platform == "win32":
    from ctypes import wintypes

    ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
    STD_OUTPUT_HANDLE = wintypes.HANDLE(-11)

from ._spellchecker import Misspelling, build_dict
from ._text_util import fix_case

# autogenerated by setuptools_scm
from ._version import (  # type: ignore[import-not-found]
    __version__ as VERSION,  # noqa: N812
)

word_regex_def = r"[\w\-'’]+"  # noqa: RUF001
# While we want to treat characters like ( or " as okay for a starting break,
# these may occur unescaped in URIs, and so we are more restrictive on the
# endpoint.  Emails are more restrictive, so the endpoint remains flexible.
uri_regex_def = (
    r"(\b(?:https?|[ts]?ftp|file|git|smb)://[^\s]+(?=$|\s)|\b[\w.%+-]+@[\w.-]+\b)"
)
codespell_ignore_tag = "codespell:ignore"
inline_ignore_regex = re.compile(
    rf"[^\w\s]\s*{codespell_ignore_tag}\b(\s+(?P<words>[\w,]*))?"
)
USAGE = """
\t%prog [OPTIONS] [file1 file2 ... fileN]
"""

supported_languages_en = ("en", "en_GB", "en_US", "en_CA", "en_AU")
supported_languages = supported_languages_en

# Users might want to link this file into /usr/local/bin, so we resolve the
# symbolic link path to the real path if necessary.
_data_root = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data")
_builtin_dictionaries = (
    # name, desc, name, err in aspell, correction in aspell, \
    # err dictionary array, rep dictionary array
    # The arrays must contain the names of aspell dictionaries
    # The aspell tests here aren't the ideal state, but the None's are
    # realistic for obscure words
    ("clear", "for unambiguous errors", "", False, None, supported_languages_en, None),
    (
        "rare",
        "for rare (but valid) words that are likely to be errors",
        "_rare",
        None,
        None,
        None,
        None,
    ),
    (
        "informal",
        "for making informal words more formal",
        "_informal",
        True,
        True,
        supported_languages_en,
        supported_languages_en,
    ),
    (
        "usage",
        "for replacing phrasing with recommended terms",
        "_usage",
        None,
        None,
        None,
        None,
    ),
    (
        "code",
        "for words from code and/or mathematics that are likely to be typos in other contexts (such as uint)",  # noqa: E501
        "_code",
        None,
        None,
        None,
        None,
    ),
    (
        "names",
        "for valid proper names that might be typos",
        "_names",
        None,
        None,
        None,
        None,
    ),
    (
        "en-GB_to_en-US",
        "for corrections from en-GB to en-US",
        "_en-GB_to_en-US",
        True,
        True,
        ("en_GB",),
        ("en_US",),
    ),
    (
        "en_to_en-OX",
        "for corrections from en to en-OX",
        "_en_to_en-OX_AUTOGENERATED",
        None,
        None,
        None,
        None,
    ),
)
_builtin_default = "clear,rare"

# docs say os.EX_USAGE et al. are only available on Unix systems, so to be safe
# we protect and just use the values they are on macOS and Linux
EX_OK = 0
EX_USAGE = 64
EX_DATAERR = 65
EX_CONFIG = 78

# OPTIONS:
#
# ARGUMENTS:
#    dict_filename       The file containing the dictionary of misspellings.
#                        If set to '-', it will be read from stdin
#    file1 .. fileN      Files to check spelling


class QuietLevels:
    NONE = 0
    ENCODING = 1
    BINARY_FILE = 2
    DISABLED_FIXES = 4
    NON_AUTOMATIC_FIXES = 8
    FIXES = 16
    CONFIG_FILES = 32


class GlobMatch:
    def __init__(self, pattern: list[str]) -> None:
        self.pattern_list: list[str] = pattern

    def match(self, filename: str) -> bool:
        return any(fnmatch.fnmatch(filename, p) for p in self.pattern_list)


class TermColors:
    def __init__(self) -> None:
        self.FILE = "\033[33m"
        self.WWORD = "\033[31m"
        self.FWORD = "\033[32m"
        self.DISABLE = "\033[0m"

    def disable(self) -> None:
        self.FILE = ""
        self.WWORD = ""
        self.FWORD = ""
        self.DISABLE = ""


class Summary:
    def __init__(self) -> None:
        self.summary: dict[str, int] = {}

    def update(self, wrongword: str) -> None:
        if wrongword in self.summary:
            self.summary[wrongword] += 1
        else:
            self.summary[wrongword] = 1

    def __str__(self) -> str:
        keys = list(self.summary.keys())
        keys.sort()

        return "\n".join(
            [f"{key}{self.summary.get(key):{15 - len(key)}}" for key in keys]
        )


class FileOpener:
    def __init__(
        self,
        use_chardet: bool,
        quiet_level: int,
        ignore_multiline_regex: Optional[Pattern[str]],
    ) -> None:
        self.use_chardet = use_chardet
        if use_chardet:
            self.init_chardet()
        self.quiet_level = quiet_level
        self.ignore_multiline_regex = ignore_multiline_regex

    def init_chardet(self) -> None:
        try:
            import chardet  # noqa: F401
        except ImportError:
            msg = (
                "There's no chardet installed to import from. "
                "Please, install it and check your PYTHONPATH "
                "environment variable"
            )
            raise ImportError(msg)
        try:
            from chardet import UniversalDetector
        except ImportError:  # chardet < 7
            from chardet.universaldetector import UniversalDetector

        self.encdetector = UniversalDetector()

    def open(self, filename: str) -> tuple[list[tuple[bool, int, list[str]]], str]:
        if self.use_chardet:
            return self.open_with_chardet(filename)
        return self.open_with_internal(filename)

    def open_with_chardet(
        self, filename: str
    ) -> tuple[list[tuple[bool, int, list[str]]], str]:
        self.encdetector.reset()
        with open(filename, "rb") as fb:
            for line in fb:
                self.encdetector.feed(line)
                if self.encdetector.done:
                    break
        self.encdetector.close()
        encoding = self.encdetector.result["encoding"]

        try:
            f = open(filename, encoding=encoding, newline="")
        except UnicodeDecodeError:
            print(f"ERROR: Could not detect encoding: {filename}", file=sys.stderr)
            raise
        except LookupError:
            print(
                f"ERROR: Don't know how to handle encoding {encoding}: {filename}",
                file=sys.stderr,
            )
            raise
        else:
            lines = self.get_lines(f)
            f.close()

        return lines, f.encoding

    def open_with_internal(
        self, filename: str
    ) -> tuple[list[tuple[bool, int, list[str]]], str]:
        encoding = None
        first_try = True
        for encoding in ("utf-8", "iso-8859-1"):
            if first_try:
                first_try = False
            elif not self.quiet_level & QuietLevels.ENCODING:
                print(f'WARNING: Trying next encoding "{encoding}"', file=sys.stderr)
            with open(filename, encoding=encoding, newline="") as f:
                try:
                    lines = self.get_lines(f)
                except UnicodeDecodeError:
                    if not self.quiet_level & QuietLevels.ENCODING:
                        print(
                            f'WARNING: Cannot decode file using encoding "{encoding}": '
                            f"{filename}",
                            file=sys.stderr,
                        )
                else:
                    break
        else:
            # reading with encoding "iso-8859-1" cannot fail with UnicodeDecodeError
            msg = "Unknown encoding"
            raise RuntimeError(msg)  # pragma: no cover

        return lines, encoding

    def get_lines(self, f: TextIO) -> list[tuple[bool, int, list[str]]]:
        fragments = []
        line_number = 0
        if self.ignore_multiline_regex:
            text = f.read()
            pos = 0
            for m in re.finditer(self.ignore_multiline_regex, text):
                lines = text[pos : m.start()].splitlines(True)
                fragments.append((False, line_number, lines))
                line_number += len(lines)
                lines = m.group().splitlines(True)
                fragments.append((True, line_number, lines))
                line_number += len(lines) - 1
                pos = m.end()
            lines = text[pos:].splitlines(True)
            fragments.append((False, line_number, lines))
        else:
            fragments.append((False, line_number, f.readlines()))
        return fragments


# -.-:-.-:-.-:-.:-.-:-.-:-.-:-.-:-.:-.-:-.-:-.-:-.-:-.:-.-:-


# If someday this breaks, we can just switch to using RawTextHelpFormatter,
# but it has the disadvantage of not wrapping our long lines.


class NewlineHelpFormatter(argparse.HelpFormatter):
    """Help formatter that preserves newlines and deals with lists."""

    def _split_lines(self, text: str, width: int) -> list[str]:
        parts = text.split("\n")
        out = []
        for part in parts:
            # Eventually we could allow others...
            indent_start = "- "
            offset = len(indent_start) if part.startswith(indent_start) else 0
            part = part[offset:]
            part = self._whitespace_matcher.sub(" ", part).strip()
            parts = textwrap.wrap(part, width - offset)
            parts = [" " * offset + p for p in parts]
            if offset:
                parts[0] = indent_start + parts[0][offset:]
            out.extend(parts)
        return out


def _toml_to_parseconfig(toml_dict: dict[str, Any]) -> dict[str, Any]:
    """Convert a dict read from a TOML file to the parseconfig.read_dict() format."""
    return {
        k: "" if v is True else ",".join(v) if isinstance(v, list) else v
        for k, v in toml_dict.items()
        if v is not False
    }


def _supports_ansi_colors() -> bool:
    if sys.platform == "win32":
        # Windows Terminal enables ANSI escape codes by default. In other cases
        # it is disabled.
        # See https://ss64.com/nt/syntax-ansi.html for more information.
        kernel32 = ctypes.WinDLL("kernel32")

        # fmt: off
        kernel32.GetConsoleMode.argtypes = (
            wintypes.HANDLE,   # _In_  hConsoleHandle
            wintypes.LPDWORD,  # _Out_ lpMode
        )
        # fmt: on
        kernel32.GetConsoleMode.restype = wintypes.BOOL

        mode = wintypes.DWORD()
        handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
        if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
            # TODO: print a warning with the error message on stderr?
            return False

        return (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0
    elif sys.platform == "wasi":
        # WASI disables ANSI escape codes for security reasons.
        # See https://github.com/WebAssembly/WASI/issues/162.
        return False
    elif sys.stdout.isatty():
        return True

    return False


def parse_options(
    args: Sequence[str],
) -> tuple[argparse.Namespace, argparse.ArgumentParser, list[str]]:
    # Split lines read from `@PATH` using shlex.split(), otherwise default
    # behaviour is to have one arg per line. See:
    # https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.convert_arg_line_to_args
    class ArgumentParser2(argparse.ArgumentParser):
        def convert_arg_line_to_args(self, arg_line: str) -> list[str]:
            if sys.platform == "win32":
                # On Windows, shlex.split() seems to be messed up by back
                # slashes. Temporarily changing them to forward slashes seems
                # to make things work better.
                arg_line = arg_line.replace("\\", "/")
                ret = shlex.split(arg_line)
                ret = [p.replace("/", "\\") for p in ret]
            else:
                ret = shlex.split(arg_line)
            return ret

    parser = ArgumentParser2(
        formatter_class=NewlineHelpFormatter,
        fromfile_prefix_chars="@",
        epilog="Use @PATH to read additional arguments from file PATH.",
    )

    parser.set_defaults(colors=_supports_ansi_colors())
    parser.add_argument("--version", action="version", version=VERSION)

    parser.add_argument(
        "-d",
        "--disable-colors",
        action="store_false",
        dest="colors",
        help="disable colors, even when printing to terminal",
    )
    parser.add_argument(
        "-c",
        "--enable-colors",
        action="store_true",
        dest="colors",
        help="enable colors, even when not printing to terminal",
    )

    parser.add_argument(
        "-w",
        "--write-changes",
        action="store_true",
        default=False,
        help="write changes in place if possible",
    )

    parser.add_argument(
        "-D",
        "--dictionary",
        action="append",
        help="comma-separated list of custom dictionary files that "
        "contain spelling corrections. If this flag is not specified "
        'or equals "-" then the default dictionary is used.',
    )
    builtin_opts = "\n- ".join(
        [""] + [f"{d[0]!r} {d[1]}" for d in _builtin_dictionaries]
    )
    parser.add_argument(
        "--builtin",
        dest="builtin",
        default=_builtin_default,
        metavar="BUILTIN-LIST",
        help="comma-separated list of builtin dictionaries "
        'to include (when "-D -" or no "-D" is passed). '
        "Current options are:" + builtin_opts + "\n"
        "The default is %(default)r.",
    )
    parser.add_argument(
        "--ignore-regex",
        action="store",
        type=str,
        help="regular expression that is used to find "
        "patterns to ignore by treating as whitespace. "
        "When writing regular expressions, consider "
        "ensuring there are boundary non-word chars, "
        'e.g., "\\bmatch\\b". Defaults to '
        "empty/disabled.",
    )
    parser.add_argument(
        "--ignore-multiline-regex",
        action="store",
        type=str,
        help="regular expression that is used to ignore "
        "text that may span multi-line regions. "
        "The regex is run with re.DOTALL. For example to "
        "allow skipping of regions of Python code using "
        "begin/end comments one could use: "
        "--ignore-multiline-regex "
        "'# codespell:ignore-begin *\\n.*# codespell:ignore-end *\\n'. "
        "Defaults to empty/disabled.",
    )
    parser.add_argument(
        "-I",
        "--ignore-words",
        action="append",
        metavar="FILES",
        help="comma-separated list of files that contain "
        "words to be ignored by codespell. Files must contain "
        "1 word per line. Words are case sensitive based on "
        "how they are written in the dictionary file.",
    )
    parser.add_argument(
        "-L",
        "--ignore-words-list",
        action="append",
        metavar="WORDS",
        help="comma-separated list of words to be ignored "
        "by codespell. Words are case sensitive based on "
        "how they are written in the dictionary file.",
    )
    parser.add_argument(
        "--uri-ignore-words-list",
        action="append",
        metavar="WORDS",
        help="comma-separated list of words to be ignored "
        "by codespell in URIs and emails only. Words are "
        "case sensitive based on how they are written in "
        'the dictionary file. If set to "*", all '
        "misspelling in URIs and emails will be ignored.",
    )
    parser.add_argument(
        "-r",
        "--regex",
        action="store",
        type=str,
        help="regular expression that is used to find words. "
        "By default any alphanumeric character, the "
        "underscore, the hyphen, and the apostrophe are "
        "used to build words. This option cannot be "
        "specified together with --write-changes.",
    )
    parser.add_argument(
        "--uri-regex",
        action="store",
        type=str,
        help="regular expression that is used to find URIs "
        "and emails. A default expression is provided.",
    )
    parser.add_argument(
        "-s",
        "--summary",
        action="store_true",
        default=False,
        help="print summary of fixes",
    )

    parser.add_argument(
        "--count",
        action="store_true",
        default=False,
        help="print the number of errors as the last line of stderr",
    )

    parser.add_argument(
        "-S",
        "--skip",
        action="append",
        help="comma-separated list of files to skip. It "
        "accepts globs as well. E.g.: if you want "
        "codespell to skip .eps and .txt files, "
        'you\'d give "*.eps,*.txt" to this option.',
    )

    parser.add_argument(
        "-x",
        "--exclude-file",
        action="append",
        type=str,
        metavar="FILES",
        help="ignore whole lines that match those in "
        "the comma-separated list of files EXCLUDE. "
        "The lines in these files should match the "
        "to-be-excluded lines exactly",
    )

    parser.add_argument(
        "-i",
        "--interactive",
        action="store",
        type=int,
        default=0,
        choices=range(0, 4),
        help="set interactive mode when writing changes:\n"
        "- 0: no interactivity.\n"
        "- 1: ask for confirmation.\n"
        "- 2: ask user to choose one fix when more than one is available.\n"
        "- 3: both 1 and 2",
        metavar="MODE",
    )

    parser.add_argument(
        "-q",
        "--quiet-level",
        action="store",
        type=int,
        default=34,
        choices=range(0, 64),
        help="bitmask that allows suppressing messages:\n"
        "- 0: print all messages.\n"
        "- 1: disable warnings about wrong encoding.\n"
        "- 2: disable warnings about binary files.\n"
        "- 4: omit warnings about automatic fixes that were disabled in the dictionary.\n"  # noqa: E501
        "- 8: don't print anything for non-automatic fixes.\n"
        "- 16: don't print the list of fixed files.\n"
        "- 32: don't print configuration files.\n"
        "As usual with bitmasks, these levels can be "
        "combined; e.g. use 3 for levels 1+2, 7 for "
        "1+2+4, 23 for 1+2+4+16, etc. "
        "The default mask is %(default)s.",
        metavar="LEVEL",
    )

    parser.add_argument(
        "-e",
        "--hard-encoding-detection",
        action="store_true",
        default=False,
        help="use chardet to detect the encoding of each "
        "file. This can slow down codespell, but is more "
        "reliable in detecting encodings other than "
        "utf-8, iso8859-1, and ascii.",
    )

    parser.add_argument(
        "-f",
        "--check-filenames",
        action="store_true",
        default=False,
        help="check file names as well",
    )

    parser.add_argument(
        "-H",
        "--check-hidden",
        action="store_true",
        default=False,
        help='check hidden files and directories (those starting with ".") as well.',
    )
    parser.add_argument(
        "-A",
        "--after-context",
        type=int,
        metavar="LINES",
        help="print LINES of trailing context",
    )
    parser.add_argument(
        "-B",
        "--before-context",
        type=int,
        metavar="LINES",
        help="print LINES of leading context",
    )
    parser.add_argument(
        "-C",
        "--context",
        type=int,
        metavar="LINES",
        help="print LINES of surrounding context",
    )
    parser.add_argument(
        "--stdin-single-line",
        action="store_true",
        help="output just a single line for each misspelling in stdin mode",
    )
    parser.add_argument("--config", type=str, help="path to config file.")
    parser.add_argument("--toml", type=str, help="path to a pyproject.toml file.")
    parser.add_argument("files", nargs="*", help="files or directories to check")

    # Parse command line options.
    options = parser.parse_args(list(args))

    # Load config files and look for ``codespell`` options.
    cfg_files = ["setup.cfg", ".codespellrc"]
    if options.config:
        cfg_files.append(options.config)
    config = configparser.ConfigParser(interpolation=None)

    # Read toml before other config files.
    toml_files = []
    tomllib_raise_error = False
    if os.path.isfile("pyproject.toml"):
        toml_files.append("pyproject.toml")
    if options.toml:
        toml_files.append(options.toml)
        tomllib_raise_error = True
    if toml_files:
        if sys.version_info >= (3, 11):
            import tomllib
        else:
            try:
                import tomli as tomllib  # type: ignore[no-redef]
            except ImportError as e:
                if tomllib_raise_error:
                    msg = (
                        f"tomllib or tomli are required to read pyproject.toml "
                        f"but could not be imported, got: {e}"
                    )
                    raise ImportError(msg) from None
                tomllib = None  # type: ignore[assignment]
        if tomllib is not None:
            for toml_file in toml_files:
                with open(toml_file, "rb") as f:
                    data = tomllib.load(f).get("tool", {})
                if "codespell" in data:
                    data["codespell"] = _toml_to_parseconfig(data["codespell"])
                config.read_dict(data)

    # Collect which config files are going to be used
    used_cfg_files = []
    for cfg_file in cfg_files:
        _cfg = configparser.ConfigParser()
        _cfg.read(cfg_file)
        if _cfg.has_section("codespell"):
            used_cfg_files.append(cfg_file)

    # Use config files
    config.read(used_cfg_files)
    if config.has_section("codespell"):
        # Build a "fake" argv list using option name and value.
        cfg_args = []
        for key in config["codespell"]:
            # Add option as arg.
            cfg_args.append(f"--{key}")
            # If value is blank, skip.
            val = config["codespell"][key]
            if val:
                cfg_args.append(val)

        # Parse config file options.
        options = parser.parse_args(cfg_args)

        # Re-parse command line options to override config.
        options = parser.parse_args(list(args), namespace=options)

    if not options.files:
        options.files.append(".")

    return options, parser, used_cfg_files


def process_ignore_words(
    words: Iterable[str], ignore_words: set[str], ignore_words_cased: set[str]
) -> None:
    for word in words:
        word = word.strip()
        if word == word.lower():
            ignore_words.add(word)
        else:
            ignore_words_cased.add(word)


def parse_ignore_words_option(
    ignore_words_option: list[str],
) -> tuple[set[str], set[str]]:
    ignore_words: set[str] = set()
    ignore_words_cased: set[str] = set()
    if ignore_words_option:
        for comma_separated_words in ignore_words_option:
            process_ignore_words(
                (word.strip() for word in comma_separated_words.split(",")),
                ignore_words,
                ignore_words_cased,
            )
    return (ignore_words, ignore_words_cased)


def build_exclude_hashes(filename: str, exclude_lines: set[str]) -> None:
    with open(filename, encoding="utf-8") as f:
        exclude_lines.update(line.rstrip() for line in f)


def build_ignore_words(
    filename: str, ignore_words: set[str], ignore_words_cased: set[str]
) -> None:
    with open(filename, encoding="utf-8") as f:
        process_ignore_words(
            (line.strip() for line in f), ignore_words, ignore_words_cased
        )


def is_hidden(filename: str, check_hidden: bool) -> bool:
    bfilename = os.path.basename(filename)

    return bfilename not in ("", ".", "..") and (
        not check_hidden and bfilename[0] == "."
    )


def is_text_file(filename: str) -> bool:
    with open(filename, mode="rb") as f:
        s = f.read(1024)
    return b"\x00" not in s


def ask_for_word_fix(
    line: str,
    match: Match[str],
    misspelling: Misspelling,
    interactivity: int,
    colors: TermColors,
) -> tuple[bool, str]:
    wrongword = match.group()
    if interactivity <= 0:
        return misspelling.fix, fix_case(wrongword, misspelling.data)

    line_ui = (
        f"{line[: match.start()]}"
        f"{colors.WWORD}{wrongword}{colors.DISABLE}"
        f"{line[match.end() :]}"
    )

    if misspelling.fix and interactivity & 1:
        r = ""
        fixword = fix_case(wrongword, misspelling.data)
        while not r:
            print(f"{line_ui}\t{wrongword} ==> {fixword} (Y/n) ", end="", flush=True)
            r = sys.stdin.readline().strip().upper()
            if not r:
                r = "Y"
            if r not in ("Y", "N"):
                print("Say 'y' or 'n'")
                r = ""

        if r == "N":
            misspelling.fix = False

    elif (interactivity & 2) and not misspelling.reason:
        # if it is not disabled, i.e. it just has more than one possible fix,
        # we ask the user which word to use

        r = ""
        opt = [w.strip() for w in misspelling.data.split(",")]
        while not r:
            print(f"{line_ui} Choose an option (blank for none): ", end="")
            for i, o in enumerate(opt):
                fixword = fix_case(wrongword, o)
                print(f" {i}) {fixword}", end="")
            print(": ", end="", flush=True)

            n = sys.stdin.readline().strip()
            if not n:
                break

            try:
                i = int(n)
                r = opt[i]
            except (ValueError, IndexError):
                print("Not a valid option\n")

        if r:
            misspelling.fix = True
            misspelling.data = r

    return misspelling.fix, fix_case(wrongword, misspelling.data)


def print_context(
    lines: list[str],
    index: int,
    context: tuple[int, int],
) -> None:
    # context = (context_before, context_after)
    for i in range(index - context[0], index + context[1] + 1):
        if 0 <= i < len(lines):
            print(f"{'>' if i == index else ':'} {lines[i].rstrip()}")


def _ignore_word_sub(
    text: str,
    ignore_word_regex: Optional[Pattern[str]],
) -> str:
    if ignore_word_regex:
        text = ignore_word_regex.sub(" ", text)
    return text


def extract_words(
    text: str,
    word_regex: Pattern[str],
    ignore_word_regex: Optional[Pattern[str]],
) -> list[str]:
    return word_regex.findall(_ignore_word_sub(text, ignore_word_regex))


def extract_words_iter(
    text: str,
    word_regex: Pattern[str],
    ignore_word_regex: Optional[Pattern[str]],
) -> list[Match[str]]:
    return list(word_regex.finditer(_ignore_word_sub(text, ignore_word_regex)))


def apply_uri_ignore_words(
    check_matches: list[Match[str]],
    line: str,
    word_regex: Pattern[str],
    ignore_word_regex: Optional[Pattern[str]],
    uri_regex: Pattern[str],
    uri_ignore_words: set[str],
) -> list[Match[str]]:
    if not uri_ignore_words:
        return check_matches
    for uri in uri_regex.findall(line):
        for uri_word in extract_words(uri, word_regex, ignore_word_regex):
            if uri_word in uri_ignore_words:
                # determine/remove only the first among matches
                for i, match in enumerate(check_matches):
                    if match.group() == uri_word:
                        check_matches = check_matches[:i] + check_matches[i + 1 :]
                        break
    return check_matches


def _format_colored_output(
    filename: str,
    colors: TermColors,
    line_num: int,
    wrong: str,
    right: str,
) -> tuple[str, str, str, str]:
    """Format colored strings for output.

    Args:
        filename: The filename being processed.
        colors: TermColors instance for color formatting.
        line_num: Line number (1-based) where the misspelling was found.
        wrong: The misspelled word.
        right: The correct word.

    Returns:
        Tuple of (filename, line_num, wrong_word, right_word) with color codes.
    """
    cfilename = f"{colors.FILE}{filename}{colors.DISABLE}"
    cline = f"{colors.FILE}{line_num}{colors.DISABLE}"
    cwrongword = f"{colors.WWORD}{wrong}{colors.DISABLE}"
    crightword = f"{colors.FWORD}{right}{colors.DISABLE}"
    return cfilename, cline, cwrongword, crightword


def parse_lines(
    fragment: tuple[bool, int, list[str]],
    filename: str,
    colors: TermColors,
    summary: Optional[Summary],
    misspellings: dict[str, Misspelling],
    ignore_words_cased: set[str],
    exclude_lines: set[str],
    word_regex: Pattern[str],
    ignore_word_regex: Optional[Pattern[str]],
    uri_regex: Pattern[str],
    uri_ignore_words: set[str],
    context: Optional[tuple[int, int]],
    options: argparse.Namespace,
) -> tuple[int, bool, list[tuple[int, str, str]]]:
    bad_count = 0
    changed = False
    changes_made: list[tuple[int, str, str]] = []

    _, fragment_line_number, lines = fragment

    for i, line in enumerate(lines):
        line = line.rstrip()
        if not line or line in exclude_lines:
            continue
        line_number = fragment_line_number + i

        extra_words_to_ignore = set()
        match = (
            inline_ignore_regex.search(line) if codespell_ignore_tag in line else None
        )
        if match:
            extra_words_to_ignore = set(
                filter(None, (match.group("words") or "").split(","))
            )
            if not extra_words_to_ignore:
                continue

        fixed_words = set()
        asked_for = set()

        # If all URI spelling errors will be ignored, erase any URI before
        # extracting words. Otherwise, apply ignores after extracting words.
        # This ensures that if a URI ignore word occurs both inside a URI and
        # outside, it will still be a spelling error.
        if "*" in uri_ignore_words:
            line = uri_regex.sub(" ", line)
        check_matches = extract_words_iter(line, word_regex, ignore_word_regex)
        if "*" not in uri_ignore_words:
            check_matches = apply_uri_ignore_words(
                check_matches,
                line,
                word_regex,
                ignore_word_regex,
                uri_regex,
                uri_ignore_words,
            )
        for match in check_matches:
            word = match.group()
            if word in ignore_words_cased:
                continue
            lword = word.lower()
            if lword in misspellings and lword not in extra_words_to_ignore:
                # Sometimes we find a 'misspelling' which is actually a valid word
                # preceded by a string escape sequence.  Ignore such cases as
                # they're usually false alarms; see issue #17 among others.
                char_before_idx = match.start() - 1
                if (
                    char_before_idx >= 0
                    and line[char_before_idx] == "\\"
                    # bell, backspace, formfeed, newline, carriage-return, tab, vtab.
                    and word.startswith(("a", "b", "f", "n", "r", "t", "v"))
                    and lword[1:] not in misspellings
                ):
                    continue

                context_shown = False
                fix = misspellings[lword].fix
                fixword = fix_case(word, misspellings[lword].data)

                if options.interactive and lword not in asked_for:
                    if context is not None:
                        context_shown = True
                        print_context(lines, i, context)
                    fix, fixword = ask_for_word_fix(
                        lines[i],
                        match,
                        misspellings[lword],
                        options.interactive,
                        colors=colors,
                    )
                    asked_for.add(lword)

                if summary and fix:
                    summary.update(lword)

                if word in fixed_words:  # can skip because of re.sub below
                    continue

                if options.write_changes and fix:
                    changed = True
                    lines[i] = re.sub(rf"\b{word}\b", fixword, lines[i])
                    fixed_words.add(word)
                    changes_made.append((line_number + 1, word, fixword))
                    continue

                # otherwise warning was explicitly set by interactive mode
                if (
                    options.interactive & 2
                    and not fix
                    and not misspellings[lword].reason
                ):
                    continue

                cfilename, cline, cwrongword, crightword = _format_colored_output(
                    filename, colors, line_number + 1, word, fixword
                )

                reason = misspellings[lword].reason
                if reason:
                    if options.quiet_level & QuietLevels.DISABLED_FIXES:
                        continue
                    creason = f"  | {colors.FILE}{reason}{colors.DISABLE}"
                else:
                    if options.quiet_level & QuietLevels.NON_AUTOMATIC_FIXES:
                        continue
                    creason = ""

                # If we get to this point (uncorrected error) we should change
                # our bad_count and thus return value
                bad_count += 1

                if (not context_shown) and (context is not None):
                    print_context(lines, i, context)
                if filename != "-":
                    print(
                        f"{cfilename}:{cline}: {cwrongword} ==> {crightword}{creason}"
                    )
                elif options.stdin_single_line:
                    print(f"{cline}: {cwrongword} ==> {crightword}{creason}")
                else:
                    print(
                        f"{cline}: {line.strip()}\n\t{cwrongword} "
                        f"==> {crightword}{creason}"
                    )

    return bad_count, changed, changes_made


def parse_file(
    filename: str,
    colors: TermColors,
    summary: Optional[Summary],
    misspellings: dict[str, Misspelling],
    ignore_words_cased: set[str],
    exclude_lines: set[str],
    file_opener: FileOpener,
    word_regex: Pattern[str],
    ignore_word_regex: Optional[Pattern[str]],
    uri_regex: Pattern[str],
    uri_ignore_words: set[str],
    context: Optional[tuple[int, int]],
    options: argparse.Namespace,
) -> int:
    bad_count = 0
    fragments = None

    # Read lines.
    if filename == "-":
        f = sys.stdin
        encoding = "utf-8"
        fragments = file_opener.get_lines(f)
    else:
        if options.check_filenames:
            for word in extract_words(filename, word_regex, ignore_word_regex):
                if word in ignore_words_cased:
                    continue
                lword = word.lower()
                if lword not in misspellings:
                    continue
                fix = misspellings[lword].fix
                fixword = fix_case(word, misspellings[lword].data)

                if summary and fix:
                    summary.update(lword)

                cfilename, _, cwrongword, crightword = _format_colored_output(
                    filename, colors, 0, word, fixword
                )

                reason = misspellings[lword].reason
                if reason:
                    if options.quiet_level & QuietLevels.DISABLED_FIXES:
                        continue
                    creason = f"  | {colors.FILE}{reason}{colors.DISABLE}"
                else:
                    if options.quiet_level & QuietLevels.NON_AUTOMATIC_FIXES:
                        continue
                    creason = ""

                bad_count += 1

                print(f"{cfilename}: {cwrongword} ==> {crightword}{creason}")

        # ignore irregular files
        if not os.path.isfile(filename):
            return bad_count

        try:
            text = is_text_file(filename)
        except PermissionError as e:
            print(f"WARNING: {e.strerror}: {filename}", file=sys.stderr)
            return bad_count
        except OSError:
            return bad_count

        if not text:
            if not options.quiet_level & QuietLevels.BINARY_FILE:
                print(f"WARNING: Binary file: {filename}", file=sys.stderr)
            return bad_count
        try:
            fragments, encoding = file_opener.open(filename)
        except OSError:
            return bad_count

    # Parse lines.
    changed = False
    changes_made: list[tuple[int, str, str]] = []
    for fragment in fragments:
        ignore, _, _ = fragment
        if ignore:
            continue

        bad_count_update, changed_update, changes_made_update = parse_lines(
            fragment,
            filename,
            colors,
            summary,
            misspellings,
            ignore_words_cased,
            exclude_lines,
            word_regex,
            ignore_word_regex,
            uri_regex,
            uri_ignore_words,
            context,
            options,
        )
        bad_count += bad_count_update
        changed = changed or changed_update
        changes_made.extend(changes_made_update)

    # Write out lines, if changed.
    if changed:
        if filename == "-":
            print("---")
            for _, _, lines in fragments:
                for line in lines:
                    print(line, end="")
        else:
            if not options.quiet_level & QuietLevels.FIXES:
                print(
                    f"{colors.FWORD}FIXED:{colors.DISABLE} {filename}",
                    file=sys.stderr,
                )
                for line_num, wrong, right in changes_made:
                    cfilename, cline, cwrongword, crightword = _format_colored_output(
                        filename, colors, line_num, wrong, right
                    )
                    print(
                        f"  {cfilename}:{cline}: {cwrongword} ==> {crightword}",
                        file=sys.stderr,
                    )
            with open(filename, "w", encoding=encoding, newline="") as f:
                for _, _, lines in fragments:
                    f.writelines(lines)

    return bad_count


def flatten_clean_comma_separated_arguments(
    arguments: Iterable[str],
) -> list[str]:
    """
    >>> flatten_clean_comma_separated_arguments(["a, b ,\n c, d,", "e"])
    ['a', 'b', 'c', 'd', 'e']
    >>> flatten_clean_comma_separated_arguments([])
    []
    """
    return [
        item.strip() for argument in arguments for item in argument.split(",") if item
    ]


def _script_main() -> int:
    """Wrap to main() for setuptools."""
    try:
        return main(*sys.argv[1:])
    except KeyboardInterrupt:
        # User has typed CTRL+C
        sys.stdout.write("\n")
        return 130


def _usage_error(parser: argparse.ArgumentParser, message: str) -> int:
    parser.print_usage()
    print(message, file=sys.stderr)
    return EX_USAGE


def main(*args: str) -> int:
    """Contains flow control"""
    try:
        options, parser, used_cfg_files = parse_options(args)
    except configparser.Error as e:
        print(
            f"ERROR: ill-formed config file: {e.message}",
            file=sys.stderr,
        )
        return EX_CONFIG

    # Report used config files
    if not options.quiet_level & QuietLevels.CONFIG_FILES:
        if len(used_cfg_files) > 0:
            print("Used config files:")
        for ifile, cfg_file in enumerate(used_cfg_files, start=1):
            print(f"    {ifile}: {cfg_file}")

    if options.interactive > 0:
        options.write_changes = True

    if options.regex and options.write_changes:
        return _usage_error(
            parser,
            "ERROR: --write-changes cannot be used together with --regex",
        )
    word_regex = options.regex or word_regex_def
    try:
        word_regex = re.compile(word_regex)
    except re.error as e:
        return _usage_error(
            parser,
            f'ERROR: invalid --regex "{word_regex}" ({e})',
        )

    if options.ignore_regex:
        try:
            ignore_word_regex = re.compile(options.ignore_regex)
        except re.error as e:
            return _usage_error(
                parser,
                f'ERROR: invalid --ignore-regex "{options.ignore_regex}" ({e})',
            )
    else:
        ignore_word_regex = None

    if options.ignore_multiline_regex:
        try:
            ignore_multiline_regex = re.compile(
                options.ignore_multiline_regex, re.DOTALL
            )
        except re.error as e:
            return _usage_error(
                parser,
                f"ERROR: invalid --ignore-multiline-regex "
                f'"{options.ignore_multiline_regex}" ({e})',
            )
    else:
        ignore_multiline_regex = None

    ignore_words, ignore_words_cased = parse_ignore_words_option(
        options.ignore_words_list
    )
    if options.ignore_words:
        ignore_words_files = flatten_clean_comma_separated_arguments(
            options.ignore_words
        )
        for ignore_words_file in ignore_words_files:
            if not os.path.isfile(ignore_words_file):
                return _usage_error(
                    parser,
                    f"ERROR: cannot find ignore-words file: {ignore_words_file}",
                )
            build_ignore_words(ignore_words_file, ignore_words, ignore_words_cased)

    uri_regex = options.uri_regex or uri_regex_def
    try:
        uri_regex = re.compile(uri_regex)
    except re.error as e:
        return _usage_error(
            parser,
            f'ERROR: invalid --uri-regex "{uri_regex}" ({e})',
        )

    uri_ignore_words = set(
        itertools.chain(*parse_ignore_words_option(options.uri_ignore_words_list))
    )

    dictionaries = flatten_clean_comma_separated_arguments(options.dictionary or ["-"])

    use_dictionaries = []
    for dictionary in dictionaries:
        if dictionary == "-":
            # figure out which builtin dictionaries to use
            use = sorted(set(options.builtin.split(",")))
            for u in use:
                for builtin in _builtin_dictionaries:
                    if builtin[0] == u:
                        use_dictionaries.append(
                            os.path.join(_data_root, f"dictionary{builtin[2]}.txt")
                        )
                        break
                else:
                    return _usage_error(
                        parser,
                        f"ERROR: Unknown builtin dictionary: {u}",
                    )
        else:
            if not os.path.isfile(dictionary):
                return _usage_error(
                    parser,
                    f"ERROR: cannot find dictionary file: {dictionary}",
                )
            use_dictionaries.append(dictionary)
    misspellings: dict[str, Misspelling] = {}
    for dictionary in use_dictionaries:
        build_dict(dictionary, misspellings, ignore_words)
    colors = TermColors()
    if not options.colors:
        colors.disable()

    summary = Summary() if options.summary else None

    context = None
    if options.context is not None:
        if (options.before_context is not None) or (options.after_context is not None):
            return _usage_error(
                parser,
                "ERROR: --context/-C cannot be used together with "
                "--context-before/-B or --context-after/-A",
            )
        context_both = max(0, options.context)
        context = (context_both, context_both)
    elif (options.before_context is not None) or (options.after_context is not None):
        context_before = 0
        context_after = 0
        if options.before_context is not None:
            context_before = max(0, options.before_context)
        if options.after_context is not None:
            context_after = max(0, options.after_context)
        context = (context_before, context_after)

    exclude_lines: set[str] = set()
    if options.exclude_file:
        exclude_files = flatten_clean_comma_separated_arguments(options.exclude_file)
        for exclude_file in exclude_files:
            build_exclude_hashes(exclude_file, exclude_lines)

    file_opener = FileOpener(
        options.hard_encoding_detection,
        options.quiet_level,
        ignore_multiline_regex,
    )

    glob_match = GlobMatch(
        flatten_clean_comma_separated_arguments(options.skip) if options.skip else []
    )
    try:
        glob_match.match("/random/path")  # does not need a real path
    except re.error:
        return _usage_error(
            parser,
            "ERROR: --skip/-S has been fed an invalid glob, "
            "try escaping special characters",
        )

    bad_count = 0
    for filename in sorted(options.files):
        # ignore hidden files
        if is_hidden(filename, options.check_hidden):
            continue

        if os.path.isdir(filename):
            for root, dirs, files in os.walk(filename):
                if glob_match.match(root):  # skip (absolute) directories
                    dirs.clear()
                    continue
                if is_hidden(root, options.check_hidden):  # dir itself hidden
                    continue
                for file_ in sorted(files):
                    # ignore hidden files in directories
                    if is_hidden(file_, options.check_hidden):
                        continue
                    if glob_match.match(file_):  # skip files
                        continue
                    fname = os.path.join(root, file_)
                    if glob_match.match(fname):  # skip paths
                        continue
                    bad_count += parse_file(
                        fname,
                        colors,
                        summary,
                        misspellings,
                        ignore_words_cased,
                        exclude_lines,
                        file_opener,
                        word_regex,
                        ignore_word_regex,
                        uri_regex,
                        uri_ignore_words,
                        context,
                        options,
                    )

                # skip (relative) directories
                dirs[:] = [
                    dir_
                    for dir_ in dirs
                    if not glob_match.match(dir_)
                    and not is_hidden(dir_, options.check_hidden)
                ]

        elif not glob_match.match(filename):  # skip files
            bad_count += parse_file(
                filename,
                colors,
                summary,
                misspellings,
                ignore_words_cased,
                exclude_lines,
                file_opener,
                word_regex,
                ignore_word_regex,
                uri_regex,
                uri_ignore_words,
                context,
                options,
            )

    if summary:
        print("\n-------8<-------\nSUMMARY:")
        print(summary)
    if options.count:
        print(bad_count, file=sys.stderr)
    return EX_DATAERR if bad_count else EX_OK
