#
# 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 fnmatch
import os
import re
import sys
import textwrap
from typing import Any, Dict, List, Match, Optional, Pattern, Sequence, Set, Tuple

# autogenerated by setuptools_scm
from ._version import __version__ as VERSION  # type: ignore  # noqa: N812

word_regex_def = r"[\w\-'’]+"
# 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 = (
    "(\\b(?:https?|[ts]?ftp|file|git|smb)://[^\\s]+(?=$|\\s)|"
    "\\b[\\w.%+-]+@[\\w.-]+\\b)"
)
# Pass all misspellings through this translation table to generate
# alternative misspellings and fixes.
alt_chars = (("'", "’"),)
encodings = ("utf-8", "iso-8859-1")
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",  # noqa: E501
        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",  # noqa: E501
        True,
        True,
        ("en_GB",),
        ("en_US",),
    ),
)
_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

# 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: Optional[str]) -> None:
        self.pattern_list: Optional[List[str]]
        if pattern:
            # Pattern might be a list of comma-delimited strings
            self.pattern_list = ",".join(pattern).split(",")
        else:
            self.pattern_list = None

    def match(self, filename: str) -> bool:
        if self.pattern_list is None:
            return False

        return any(fnmatch.fnmatch(filename, p) for p in self.pattern_list)


class Misspelling:
    def __init__(self, data: str, fix: bool, reason: str) -> None:
        self.data = data
        self.fix = fix
        self.reason = reason


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) -> None:
        self.use_chardet = use_chardet
        if use_chardet:
            self.init_chardet()
        self.quiet_level = quiet_level

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

        self.encdetector = UniversalDetector()

    def open(self, filename: str) -> Tuple[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[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 = f.readlines()
            f.close()

        return lines, f.encoding

    def open_with_internal(self, filename: str) -> Tuple[List[str], str]:
        encoding = None
        first_try = True
        for encoding in encodings:
            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 = f.readlines()
                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:
            raise Exception("Unknown encoding")

        return lines, encoding


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


# 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 = "- "
            if part.startswith(indent_start):
                offset = len(indent_start)
            else:
                offset = 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 v for k, v in toml_dict.items() if v is not False}


def parse_options(
    args: Sequence[str],
) -> Tuple[argparse.Namespace, argparse.ArgumentParser, List[str]]:
    parser = argparse.ArgumentParser(formatter_class=NewlineHelpFormatter)

    parser.set_defaults(colors=sys.stdout.isatty())
    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 "
        "(always set for Windows)",
    )
    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="custom dictionary file that contains spelling "
        "corrections. If this flag is not specified or "
        'equals "-" then the default dictionary is used. '
        "This option can be specified multiple times.",
    )
    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(
        "-I",
        "--ignore-words",
        action="append",
        metavar="FILE",
        help="file that contains words that will be ignored "
        "by codespell. File 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",
        type=str,
        metavar="FILE",
        help="ignore whole lines that match those "
        "in the file FILE. The lines in FILE "
        "should match the to-be-excluded lines exactly",
    )

    parser.add_argument(
        "-i",
        "--interactive",
        action="store",
        type=int,
        default=0,
        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"  # noqa: E501
        "- 3: both 1 and 2",
    )

    parser.add_argument(
        "-q",
        "--quiet-level",
        action="store",
        type=int,
        default=34,
        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"  # noqa: E501
        "- 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.",
    )

    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("--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:
                    raise ImportError(
                        f"tomllib or tomli are required to read pyproject.toml "
                        f"but could not be imported, got: {e}"
                    ) 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(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 parse_ignore_words_option(ignore_words_option: List[str]) -> Set[str]:
    ignore_words = set()
    if ignore_words_option:
        for comma_separated_words in ignore_words_option:
            for word in comma_separated_words.split(","):
                ignore_words.add(word.strip())
    return ignore_words


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


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


def add_misspelling(
    key: str,
    data: str,
    misspellings: Dict[str, Misspelling],
) -> None:
    data = data.strip()

    if "," in data:
        fix = False
        data, reason = data.rsplit(",", 1)
        reason = reason.lstrip()
    else:
        fix = True
        reason = ""

    misspellings[key] = Misspelling(data, fix, reason)


def build_dict(
    filename: str,
    misspellings: Dict[str, Misspelling],
    ignore_words: Set[str],
) -> None:
    with open(filename, encoding="utf-8") as f:
        translate_tables = [(x, str.maketrans(x, y)) for x, y in alt_chars]
        for line in f:
            [key, data] = line.split("->")
            # TODO for now, convert both to lower. Someday we can maybe add
            # support for fixing caps.
            key = key.lower()
            data = data.lower()
            if key not in ignore_words:
                add_misspelling(key, data, misspellings)
            # generate alternative misspellings/fixes
            for x, table in translate_tables:
                if x in key:
                    alt_key = key.translate(table)
                    alt_data = data.translate(table)
                    if alt_key not in ignore_words:
                        add_misspelling(alt_key, alt_data, misspellings)


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 fix_case(word: str, fixword: str) -> str:
    if word == word.capitalize():
        return ", ".join(w.strip().capitalize() for w in fixword.split(","))
    if word == word.upper():
        return fixword.upper()
    # they are both lower case
    # or we don't have any idea
    return fixword


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 re.findall(uri_regex, 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 parse_file(
    filename: str,
    colors: TermColors,
    summary: Optional[Summary],
    misspellings: Dict[str, Misspelling],
    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
    lines = None
    changed = False
    encoding = encodings[0]  # if not defined, use UTF-8

    if filename == "-":
        f = sys.stdin
        lines = f.readlines()
    else:
        if options.check_filenames:
            for word in extract_words(filename, word_regex, ignore_word_regex):
                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 = f"{colors.FILE}{filename}{colors.DISABLE}"
                cwrongword = f"{colors.WWORD}{word}{colors.DISABLE}"
                crightword = f"{colors.FWORD}{fixword}{colors.DISABLE}"

                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:
            lines, encoding = file_opener.open(filename)
        except OSError:
            return bad_count

    for i, line in enumerate(lines):
        if line in exclude_lines:
            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()
            lword = word.lower()
            if lword in misspellings:
                # 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)
                    continue

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

                cfilename = f"{colors.FILE}{filename}{colors.DISABLE}"
                cline = f"{colors.FILE}{i + 1}{colors.DISABLE}"
                cwrongword = f"{colors.WWORD}{word}{colors.DISABLE}"
                crightword = f"{colors.FWORD}{fixword}{colors.DISABLE}"

                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} "
                        f"==> {crightword}{creason}"
                    )
                else:
                    print(
                        f"{cline}: {line.strip()}\n\t{cwrongword} "
                        f"==> {crightword}{creason}"
                    )

    if changed:
        if filename == "-":
            print("---")
            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,
                )
            with open(filename, "w", encoding=encoding, newline="") as f:
                f.writelines(lines)
    return bad_count


def _script_main() -> int:
    """Wrap to main() for setuptools."""
    return main(*sys.argv[1:])


def main(*args: str) -> int:
    """Contains flow control"""
    options, parser, used_cfg_files = parse_options(args)

    # 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.regex and options.write_changes:
        print(
            "ERROR: --write-changes cannot be used together with --regex",
            file=sys.stderr,
        )
        parser.print_help()
        return EX_USAGE
    word_regex = options.regex or word_regex_def
    try:
        word_regex = re.compile(word_regex)
    except re.error as e:
        print(f'ERROR: invalid --regex "{word_regex}" ({e})', file=sys.stderr)
        parser.print_help()
        return EX_USAGE

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

    ignore_words_files = options.ignore_words or []
    ignore_words = parse_ignore_words_option(options.ignore_words_list)
    for ignore_words_file in ignore_words_files:
        if not os.path.isfile(ignore_words_file):
            print(
                f"ERROR: cannot find ignore-words file: {ignore_words_file}",
                file=sys.stderr,
            )
            parser.print_help()
            return EX_USAGE
        build_ignore_words(ignore_words_file, ignore_words)

    uri_regex = options.uri_regex or uri_regex_def
    try:
        uri_regex = re.compile(uri_regex)
    except re.error as e:
        print(
            f'ERROR: invalid --uri-regex "{uri_regex}" ({e})',
            file=sys.stderr,
        )
        parser.print_help()
        return EX_USAGE
    uri_ignore_words = parse_ignore_words_option(options.uri_ignore_words_list)

    dictionaries = options.dictionary if options.dictionary else ["-"]

    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:
                    print(
                        f"ERROR: Unknown builtin dictionary: {u}",
                        file=sys.stderr,
                    )
                    parser.print_help()
                    return EX_USAGE
        else:
            if not os.path.isfile(dictionary):
                print(
                    f"ERROR: cannot find dictionary file: {dictionary}",
                    file=sys.stderr,
                )
                parser.print_help()
                return EX_USAGE
            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 or sys.platform == "win32":
        colors.disable()

    if options.summary:
        summary = Summary()
    else:
        summary = None

    context = None
    if options.context is not None:
        if (options.before_context is not None) or (options.after_context is not None):
            print(
                "ERROR: --context/-C cannot be used together with "
                "--context-before/-B or --context-after/-A",
                file=sys.stderr,
            )
            parser.print_help()
            return EX_USAGE
        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:
        build_exclude_hashes(options.exclude_file, exclude_lines)

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

    glob_match = GlobMatch(options.skip)
    try:
        glob_match.match("/random/path")  # does not need a real path
    except re.error:
        print(
            "ERROR: --skip/-S has been fed an invalid glob, "
            "try escaping special characters",
            file=sys.stderr,
        )
        return EX_USAGE

    bad_count = 0
    for filename in 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
                    del dirs[:]
                    continue
                if is_hidden(root, options.check_hidden):  # dir itself hidden
                    continue
                for file_ in 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,
                        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,
                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