#!/usr/bin/env python3

import argparse
import codecs
import collections
import fnmatch
import functools
import os.path
from pathlib import Path
import re
import sys
import time

import colorama
from helpers import filter_changed, git_ls_files, print_error_for_file, styled

sys.path.append(os.path.dirname(__file__))


def find_all(a_str, sub):
    if not a_str.find(sub):
        # Optimization: If str is not in whole text, then do not try
        # on each line
        return
    for i, line in enumerate(a_str.split("\n")):
        column = 0
        while True:
            column = line.find(sub, column)
            if column == -1:
                break
            yield i, column
            column += len(sub)


file_types = (
    ".h",
    ".c",
    ".cpp",
    ".tcc",
    ".yaml",
    ".yml",
    ".ini",
    ".txt",
    ".ico",
    ".svg",
    ".png",
    ".py",
    ".html",
    ".js",
    ".md",
    ".sh",
    ".css",
    ".proto",
    ".conf",
    ".cfg",
    ".woff",
    ".woff2",
    "",
)
cpp_include = ("*.h", "*.c", "*.cpp", "*.tcc")
py_include = ("*.py",)
ignore_types = (
    ".ico",
    ".png",
    ".woff",
    ".woff2",
    "",
    ".ttf",
    ".otf",
    ".pcf",
    ".apng",
    ".gif",
    ".webp",
    ".bin",
    ".wav",
)

LINT_FILE_CHECKS = []
LINT_CONTENT_CHECKS = []
LINT_POST_CHECKS = []
EXECUTABLE_BIT: dict[str, int] = {}

errors: collections.defaultdict[Path, list] = collections.defaultdict(list)


def add_errors(fname: Path, errs: list[tuple[int, int, str] | None]) -> None:
    if not isinstance(errs, list):
        errs = [errs]
    for err in errs:
        if err is None:
            continue
        try:
            lineno, col, msg = err
        except ValueError:
            lineno = 1
            col = 1
            msg = err
        if not isinstance(msg, str):
            raise ValueError("Error is not instance of string!")
        if not isinstance(lineno, int):
            raise ValueError("Line number is not an int!")
        if not isinstance(col, int):
            raise ValueError("Column number is not an int!")
        errors[fname].append((lineno, col, msg))


def run_check(lint_obj, fname, *args):
    include = lint_obj["include"]
    exclude = lint_obj["exclude"]
    func = lint_obj["func"]
    if include is not None:
        for incl in include:
            if fnmatch.fnmatch(fname, incl):
                break
        else:
            return None
    for excl in exclude:
        if fnmatch.fnmatch(fname, excl):
            return None
    return func(*args)


def run_checks(lints, fname, *args):
    for lint in lints:
        start = time.process_time()
        try:
            add_errors(fname, run_check(lint, fname, *args))
        except Exception:
            print(f"Check {lint['func'].__name__} on file {fname} failed:")
            raise
        duration = time.process_time() - start
        lint.setdefault("durations", []).append(duration)


def _add_check(checks, func, include=None, exclude=None):
    checks.append(
        {
            "include": include,
            "exclude": exclude or [],
            "func": func,
        }
    )


def lint_file_check(**kwargs):
    def decorator(func):
        _add_check(LINT_FILE_CHECKS, func, **kwargs)
        return func

    return decorator


def lint_content_check(**kwargs):
    def decorator(func):
        _add_check(LINT_CONTENT_CHECKS, func, **kwargs)
        return func

    return decorator


def lint_post_check(func):
    _add_check(LINT_POST_CHECKS, func)
    return func


def lint_re_check(regex, **kwargs):
    flags = kwargs.pop("flags", re.MULTILINE)
    prog = re.compile(regex, flags)
    decor = lint_content_check(**kwargs)

    def decorator(func):
        @functools.wraps(func)
        def new_func(fname, content):
            errs = []
            for match in prog.finditer(content):
                if "NOLINT" in match.group(0):
                    continue
                lineno = content.count("\n", 0, match.start()) + 1
                substr = content[: match.start()]
                col = len(substr) - substr.rfind("\n")
                err = func(fname, match)
                if err is None:
                    continue
                errs.append((lineno, col + 1, err))
            return errs

        return decor(new_func)

    return decorator


def lint_content_find_check(find, only_first=False, **kwargs):
    decor = lint_content_check(**kwargs)

    def decorator(func):
        @functools.wraps(func)
        def new_func(fname, content):
            find_ = find
            if callable(find):
                find_ = find(fname, content)
            errs = []
            for line, col in find_all(content, find_):
                err = func(fname, line, col, content)
                errs.append((line + 1, col + 1, err))
                if only_first:
                    break
            return errs

        return decor(new_func)

    return decorator


@lint_file_check(include=["*.ino"])
def lint_ino(fname):
    return "This file extension (.ino) is not allowed. Please use either .cpp or .h"


@lint_file_check(
    exclude=[f"*{f}" for f in file_types]
    + [
        ".clang-*",
        ".dockerignore",
        ".editorconfig",
        "*.gitignore",
        "LICENSE",
        "pylintrc",
        "MANIFEST.in",
        "docker/Dockerfile*",
        "docker/rootfs/*",
        "script/*",
    ]
)
def lint_ext_check(fname):
    return (
        "This file extension is not a registered file type. If this is an error, please "
        "update the script/ci-custom.py script."
    )


@lint_file_check(
    exclude=[
        "**.sh",
        "docker/ha-addon-rootfs/**",
        "docker/*.py",
        "script/*",
        "CLAUDE.md",
        "GEMINI.md",
        ".github/copilot-instructions.md",
    ]
)
def lint_executable_bit(fname: Path) -> str | None:
    ex = EXECUTABLE_BIT[fname.as_posix()]
    if ex != 100644:
        return (
            f"File has invalid executable bit {ex}. If running from a windows machine please "
            "see disabling executable bit in git."
        )
    return None


@lint_content_find_check(
    "\t",
    only_first=True,
    exclude=[
        "esphome/dashboard/static/ace.js",
        "esphome/dashboard/static/ext-searchbox.js",
    ],
)
def lint_tabs(fname, line, col, content):
    return "File contains tab character. Please convert tabs to spaces."


@lint_content_find_check("\r", only_first=True)
def lint_newline(fname, line, col, content):
    return "File contains Windows newline. Please set your editor to Unix newline mode."


@lint_content_check(exclude=["*.svg", ".clang-tidy.hash"])
def lint_end_newline(fname, content):
    if content and not content.endswith("\n"):
        return "File does not end with a newline, please add an empty line at the end of the file."
    return None


CPP_RE_EOL = r".*?(?://.*?)?$"
PY_RE_EOL = r".*?(?:#.*?)?$"


def highlight(s):
    return f"\033[36m{s}\033[0m"


@lint_re_check(
    r"^#define\s+([a-zA-Z0-9_]+)\s+(0b[10]+|0x[0-9a-fA-F]+|\d+)\s*?(?:\/\/.*?)?$",
    include=cpp_include,
    exclude=[
        "esphome/core/log.h",
        "esphome/components/socket/headers.h",
        "esphome/core/defines.h",
        "esphome/components/http_request/httplib.h",
    ],
)
def lint_no_defines(fname, match):
    s = highlight(f"static constexpr uint8_t {match.group(1)} = {match.group(2)};")
    return (
        "#define macros for integer constants are not allowed, please use "
        f"{s} style instead (replace uint8_t with the appropriate "
        "datatype). See also Google style guide."
    )


@lint_re_check(r"^\s*delay\((\d+)\);" + CPP_RE_EOL, include=cpp_include)
def lint_no_long_delays(fname, match):
    duration_ms = int(match.group(1))
    if duration_ms < 50:
        return None
    return (
        f"{highlight(match.group(0).strip())} - long calls to delay() are not allowed "
        "in ESPHome because everything executes in one thread. Calling delay() will "
        "block the main thread and slow down ESPHome.\n"
        "If there's no way to work around the delay() and it doesn't execute often, please add "
        "a '// NOLINT' comment to the line."
    )


@lint_content_check(
    include=[
        "esphome/const.py",
        "esphome/components/const/__init__.py",
    ]
)
def lint_const_ordered(fname, content):
    """Lint that value in const.py are ordered.

    Reason: Otherwise people add it to the end, and then that results in merge conflicts.
    """
    lines = content.splitlines()
    errs = []
    for start in ["CONF_", "ICON_", "UNIT_"]:
        matching = [
            (i + 1, line) for i, line in enumerate(lines) if line.startswith(start)
        ]
        ordered = list(sorted(matching, key=lambda x: x[1].replace("_", " ")))
        ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered)]
        for (mi, mline), (_, ol) in zip(matching, ordered):
            if mline == ol:
                continue
            target = next(i for i, line in ordered if line == mline)
            target_text = next(line for i, line in matching if target == i)
            errs.append(
                (
                    mi,
                    1,
                    f"Constant {highlight(mline)} is not ordered, please make sure all "
                    f"constants are ordered. See line {mi} (should go to line {target}, "
                    f"{target_text})",
                )
            )
    return errs


@lint_re_check(r'^\s*CONF_([A-Z_0-9a-z]+)\s+=\s+[\'"](.*?)[\'"]\s*?$', include=["*.py"])
def lint_conf_matches(fname, match):
    const = match.group(1)
    value = match.group(2)
    const_norm = const.lower()
    value_norm = value.replace(".", "_")
    if const_norm == value_norm:
        return None
    return (
        f"Constant {highlight('CONF_' + const)} does not match value {highlight(value)}! "
        "Please make sure the constant's name matches its value!"
    )


CONF_RE = r'^(CONF_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$'
with codecs.open("esphome/const.py", "r", encoding="utf-8") as const_f_handle:
    constants_content = const_f_handle.read()
CONSTANTS = [m.group(1) for m in re.finditer(CONF_RE, constants_content, re.MULTILINE)]

CONSTANTS_USES = collections.defaultdict(list)


@lint_re_check(CONF_RE, include=["*.py"], exclude=["esphome/const.py"])
def lint_conf_from_const_py(fname, match):
    name = match.group(1)
    if name not in CONSTANTS:
        CONSTANTS_USES[name].append(fname)
        return None
    return (
        f"Constant {highlight(name)} has already been defined in const.py - "
        "please import the constant from const.py directly."
    )


RAW_PIN_ACCESS_RE = (
    r"^\s(pinMode|digitalWrite|digitalRead)\((.*)->get_pin\(\),\s*([^)]+).*\)"
)


@lint_re_check(RAW_PIN_ACCESS_RE, include=cpp_include)
def lint_no_raw_pin_access(fname, match):
    func = match.group(1)
    pin = match.group(2)
    mode = match.group(3)
    new_func = {
        "pinMode": "pin_mode",
        "digitalWrite": "digital_write",
        "digitalRead": "digital_read",
    }[func]
    new_code = highlight(f"{pin}->{new_func}({mode})")
    return f"Don't use raw {func} calls. Instead, use the `->{new_func}` function: {new_code}"


# Functions from Arduino framework that are forbidden to use directly
ARDUINO_FORBIDDEN = [
    "digitalWrite",
    "digitalRead",
    "pinMode",
    "shiftOut",
    "shiftIn",
    "radians",
    "degrees",
    "interrupts",
    "noInterrupts",
    "lowByte",
    "highByte",
    "bitRead",
    "bitSet",
    "bitClear",
    "bitWrite",
    "bit",
    "analogRead",
    "analogWrite",
    "pulseIn",
    "pulseInLong",
    "tone",
]
ARDUINO_FORBIDDEN_RE = r"[^\w\d](" + r"|".join(ARDUINO_FORBIDDEN) + r")\(.*"


@lint_re_check(
    ARDUINO_FORBIDDEN_RE,
    include=cpp_include,
    exclude=[
        "esphome/components/mqtt/custom_mqtt_device.h",
        "esphome/components/sun/sun.cpp",
    ],
)
def lint_no_arduino_framework_functions(fname, match):
    nolint = highlight("// NOLINT")
    return (
        f"The function {highlight(match.group(1))} from the Arduino framework is forbidden to be "
        f"used directly in the ESPHome codebase. Please use ESPHome's abstractions and equivalent "
        f"C++ instead.\n"
        f"\n"
        f"(If the function is strictly necessary, please add `{nolint}` to the end of the line)"
    )


IDF_CONVERSION_FORBIDDEN = {
    "ARDUINO_ARCH_ESP32": "USE_ESP32",
    "ARDUINO_ARCH_ESP8266": "USE_ESP8266",
    "pgm_read_byte": "progmem_read_byte",
    "ICACHE_RAM_ATTR": "IRAM_ATTR",
    "esphome/core/esphal.h": "esphome/core/hal.h",
}
IDF_CONVERSION_FORBIDDEN_RE = r"(" + r"|".join(IDF_CONVERSION_FORBIDDEN) + r").*"


@lint_re_check(
    IDF_CONVERSION_FORBIDDEN_RE,
    include=cpp_include,
)
def lint_no_removed_in_idf_conversions(fname, match):
    replacement = IDF_CONVERSION_FORBIDDEN[match.group(1)]
    return (
        f"The macro {highlight(match.group(1))} can no longer be used in ESPHome directly. "
        f"Please use {highlight(replacement)} instead."
    )


@lint_re_check(
    r"[^\w\d]byte +[\w\d]+\s*=",
    include=cpp_include,
    exclude={
        "esphome/components/tuya/tuya.h",
    },
)
def lint_no_byte_datatype(fname, match):
    return (
        f"The datatype {highlight('byte')} is not allowed to be used in ESPHome. "
        f"Please use {highlight('uint8_t')} instead."
    )


@lint_re_check(
    r"(?:std\s*::\s*string_view|#include\s*<string_view>)" + CPP_RE_EOL,
    include=cpp_include,
)
def lint_no_std_string_view(fname, match):
    return (
        f"{highlight('std::string_view')} is not allowed in ESPHome. "
        f"It pulls in significant STL template machinery that bloats flash on "
        f"resource-constrained embedded targets, does not work well with ArduinoJson, "
        f"and duplicates functionality already provided by {highlight('StringRef')}.\n"
        f"Please use {highlight('StringRef')} from {highlight('esphome/core/string_ref.h')} "
        f"for non-owning string references, or {highlight('const char *')} for simple cases.\n"
        f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)"
    )


@lint_re_check(
    r"(?:"
    # `from esphome.components.const import ...`
    r"from\s+esphome\.components\.const\s+import"
    r"|"
    # `import esphome.components.const` (with optional `as` alias)
    r"import\s+esphome\.components\.const\b"
    r"|"
    # `from esphome.components import [(] ... const ... [)]`
    # Handles parenthesized + multiline import lists by allowing newlines inside
    # the parens via [^)]*. Single-line form falls back to the [^#\n]* branch.
    r"from\s+esphome\.components\s+import\s*"
    r"(?:\([^)]*\bconst\b[^)]*\)|(?:[^#\n]*[\s,])?\bconst\b)"
    r")",
    include=["*.py"],
    exclude=[
        "esphome/components/*",
        "tests/*",
        "script/ci-custom.py",
    ],
)
def lint_no_components_const_outside_components(fname, match):
    return (
        f"Constants in {highlight('esphome/components/const/__init__.py')} are intended "
        f"to be shared only between components in {highlight('esphome/components/')}. "
        f"Code outside this folder must not import from "
        f"{highlight('esphome.components.const')}.\n"
        f"For core code (used outside {highlight('esphome/components/')}), define the "
        f"constant in {highlight('esphome/const.py')} instead. When adding a new "
        f"{highlight('CONF_')} constant there, bump {highlight('CONST_PY_MAX_CONF')} "
        f"in this file accordingly (see {highlight('lint_const_py_frozen')})."
    )


@lint_post_check
def lint_constants_usage():
    errs = []
    for constant, uses in CONSTANTS_USES.items():
        if len(uses) < 3:
            continue
        errs.append(
            f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the "
            f"constant to esphome/components/const/__init__.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. "
            "See https://developers.esphome.io/contributing/code/#python"
        )
    return errs


# Maximum allowed CONF_ constants in esphome/const.py.
# This file is frozen — new constants go in esphome/components/const/__init__.py.
# Decrease this number when constants are moved out of const.py.
CONST_PY_MAX_CONF = 1012


@lint_content_check(include=["esphome/const.py"])
def lint_const_py_frozen(fname, content):
    """Block new CONF_ constants from being added to esphome/const.py.

    New constants should go in esphome/components/const/__init__.py instead.
    """
    count = sum(1 for line in content.splitlines() if line.startswith("CONF_"))
    if count > CONST_PY_MAX_CONF:
        return (
            "esphome/const.py is frozen. "
            "Add new constants to esphome/components/const/__init__.py instead."
        )
    if count < CONST_PY_MAX_CONF:
        return f"CONST_PY_MAX_CONF in ci-custom.py should be updated to {count}."
    return None


def relative_cpp_search_text(fname: Path, content) -> str:
    parts = fname.parts
    integration = parts[2]
    return f'#include "esphome/components/{integration}'


@lint_content_find_check(relative_cpp_search_text, include=["esphome/components/*.cpp"])
def lint_relative_cpp_import(fname, line, col, content):
    return (
        "Component contains absolute import - Components must always use "
        "relative imports.\n"
        "Change:\n"
        '  #include "esphome/components/abc/abc.h"\n'
        "to:\n"
        '  #include "abc.h"\n\n'
    )


def relative_py_search_text(fname: Path, content: str) -> str:
    parts = fname.parts
    integration = parts[2]
    return f"esphome.components.{integration}"


def convert_path_to_relative(abspath, current):
    """Convert an absolute path to a relative import path."""
    if abspath == current:
        return "."
    absparts = abspath.split(".")
    curparts = current.split(".")
    uplen = len(curparts)
    while absparts and curparts and absparts[0] == curparts[0]:
        absparts.pop(0)
        curparts.pop(0)
        uplen -= 1
    return "." * uplen + ".".join(absparts)


@lint_content_find_check(
    relative_py_search_text,
    include=["esphome/components/*.py"],
    exclude=[
        "esphome/components/libretiny/generate_components.py",
        "esphome/components/web_server/__init__.py",
        # const.py has absolute import in docstring example for external components
        "esphome/components/esp8266/const.py",
    ],
)
def lint_relative_py_import(fname: Path, line, col, content):
    import_line = content.splitlines()[line]
    abspath = import_line[col:].split(" ")[0]
    current = str(fname).removesuffix(".py").replace(os.path.sep, ".")
    replacement = convert_path_to_relative(abspath, current)
    newline = import_line.replace(abspath, replacement)
    return (
        "Component contains absolute import - Components must always use "
        "relative imports within the integration.\n"
        "Change:\n"
        f"    {import_line}\n"
        "to:\n"
        f"    {newline}\n"
    )


@lint_content_check(
    include=[
        "esphome/components/*.h",
        "esphome/components/*.cpp",
        "esphome/components/*.tcc",
    ],
    exclude=[
        "esphome/components/socket/headers.h",
        "esphome/components/async_tcp/async_tcp.h",
        "esphome/components/esp32/core.cpp",
        "esphome/components/esp8266/core.cpp",
        "esphome/components/rp2040/core.cpp",
        "esphome/components/libretiny/core.cpp",
        "esphome/components/host/core.cpp",
        "esphome/components/zephyr/core.cpp",
        "esphome/components/esp32/helpers.cpp",
        "esphome/components/esp8266/helpers.cpp",
        "esphome/components/rp2040/helpers.cpp",
        "esphome/components/libretiny/helpers.cpp",
        "esphome/components/host/helpers.cpp",
        "esphome/components/zephyr/helpers.cpp",
        "esphome/components/http_request/httplib.h",
    ],
)
def lint_namespace(fname: Path, content: str) -> str | None:
    expected_name = fname.parts[2]
    # Check for both old style and C++17 nested namespace syntax
    search_old = f"namespace {expected_name}"
    search_new = f"namespace esphome::{expected_name}"
    if search_old in content or search_new in content:
        return None
    return (
        "Invalid namespace found in C++ file. All integration C++ files should put all "
        "functions in a separate namespace that matches the integration's name. "
        f"Please make sure the file contains {highlight(search_old)} or {highlight(search_new)}"
    )


@lint_content_find_check('"esphome.h"', include=cpp_include, exclude=["tests/custom.h"])
def lint_esphome_h(fname, line, col, content):
    return (
        "File contains reference to 'esphome.h' - This file is "
        "auto-generated and should only be used for *custom* "
        "components. Please replace with references to the direct files."
    )


@lint_content_find_check(
    "CORE.using_esp_idf",
    include=py_include,
    exclude=["esphome/core/__init__.py", "script/ci-custom.py"],
)
def lint_using_esp_idf_deprecated(fname, line, col, content):
    return (
        f"{highlight('CORE.using_esp_idf')} is deprecated and will change behavior in 2026.6. "
        "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. "
        f"Please use {highlight('CORE.is_esp32')} and/or {highlight('CORE.using_arduino')} instead."
    )


@lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"])
def lint_pragma_once(fname, content):
    if "#pragma once" not in content:
        return (
            "Header file contains no 'pragma once' header guard. Please add a "
            "'#pragma once' line at the top of the file."
        )
    return None


def lint_inclusive_language(fname, match):
    # From https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=49decddd39e5f6132ccd7d9fdc3d7c470b0061bb
    return (
        "Avoid the use of whitelist/blacklist/slave.\n"
        "Recommended replacements for 'master / slave' are:\n"
        "    '{primary,main} / {secondary,replica,subordinate}\n"
        "    '{initiator,requester} / {target,responder}'\n"
        "    '{controller,host} / {device,worker,proxy}'\n"
        "    'leader / follower'\n"
        "    'director / performer'\n"
        "\n"
        "Recommended replacements for 'blacklist/whitelist' are:\n"
        "    'denylist / allowlist'\n"
        "    'blocklist / passlist'"
    )


lint_re_check(
    r"(whitelist|blacklist|slave)" + PY_RE_EOL,
    include=py_include,
    exclude=["script/ci-custom.py"],
    flags=re.IGNORECASE | re.MULTILINE,
)(lint_inclusive_language)


lint_re_check(
    r"(whitelist|blacklist|slave)" + CPP_RE_EOL,
    include=cpp_include,
    flags=re.IGNORECASE | re.MULTILINE,
)(lint_inclusive_language)


@lint_re_check(r"[\t\r\f\v ]+$")
def lint_trailing_whitespace(fname, match):
    return "Trailing whitespace detected"


# Heap-allocating helpers that cause fragmentation on long-running embedded devices.
# These return std::string and should be replaced with stack-based alternatives.
HEAP_ALLOCATING_HELPERS = {
    "base64_encode": "base64_encode_to() with a pre-allocated buffer",
    "format_bin": "format_bin_to() with a stack buffer",
    "format_hex": "format_hex_to() with a stack buffer",
    "format_hex_pretty": "format_hex_pretty_to() with a stack buffer",
    "format_mac_address_pretty": "format_mac_addr_upper() with a stack buffer",
    "get_mac_address": "get_mac_address_into_buffer() with a stack buffer",
    "get_mac_address_pretty": "get_mac_address_pretty_into_buffer() with a stack buffer",
    "str_lower_case": "manual tolower() with a stack buffer",
    "str_sanitize": "str_sanitize_to() with a stack buffer",
    "str_truncate": "removal (function is unused)",
    "str_until": "manual strchr()/find() with a StringRef or stack buffer",
    "str_upper_case": "removal (function is unused)",
    "str_snake_case": "removal (function is unused)",
    "str_sprintf": "snprintf() with a stack buffer",
    "str_snprintf": "snprintf() with a stack buffer",
    "value_accuracy_to_string": "value_accuracy_to_buf() with a stack buffer",
}


@lint_re_check(
    # Use negative lookahead to exclude _to/_into_buffer variants
    # format_hex(?!_) ensures we don't match format_hex_to, format_hex_pretty_to, etc.
    # get_mac_address(?!_) ensures we don't match get_mac_address_into_buffer, etc.
    # CPP_RE_EOL captures rest of line so NOLINT comments are detected
    r"[^\w]("
    r"base64_encode(?!_)|"
    r"format_bin(?!_)|"
    r"format_hex(?!_)|"
    r"format_hex_pretty(?!_)|"
    r"format_mac_address_pretty|"
    r"get_mac_address_pretty(?!_)|"
    r"get_mac_address(?!_)|"
    r"str_lower_case|"
    r"str_sanitize(?!_)|"
    r"str_truncate|"
    r"str_until|"
    r"str_upper_case|"
    r"str_snake_case|"
    r"str_sprintf|"
    r"str_snprintf|"
    r"value_accuracy_to_string"
    r")\s*\(" + CPP_RE_EOL,
    include=cpp_include,
    exclude=[
        # The definitions themselves
        "esphome/core/alloc_helpers.h",
        "esphome/core/alloc_helpers.cpp",
        # Backward compatibility re-exports (remove before 2026.11.0)
        "esphome/core/helpers.h",
        "esphome/core/helpers.cpp",
        # Vendored third-party library
        "esphome/components/http_request/httplib.h",
    ],
)
def lint_no_heap_allocating_helpers(fname, match):
    func = match.group(1)
    replacement = HEAP_ALLOCATING_HELPERS.get(func, "a stack-based alternative")
    return (
        f"{highlight(func + '()')} allocates heap memory. On long-running embedded devices, "
        f"repeated heap allocations fragment memory over time. Even infrequent allocations "
        f"become time bombs - the heap eventually cannot satisfy requests even with free "
        f"memory available.\n"
        f"Please use {replacement} instead.\n"
        f"(If strictly necessary, add `// NOLINT` to the end of the line)"
    )


@lint_re_check(
    # Match sprintf/vsprintf but not snprintf/vsnprintf
    # [^\w] ensures we don't match the safe variants
    r"[^\w](v?sprintf)\s*\(" + CPP_RE_EOL,
    include=cpp_include,
)
def lint_no_sprintf(fname, match):
    func = match.group(1)
    safe_func = func.replace("sprintf", "snprintf")
    return (
        f"{highlight(func + '()')} is not allowed in ESPHome. It has no buffer size limit "
        f"and can cause buffer overflows.\n"
        f"Please use one of these alternatives:\n"
        f"  - {highlight(safe_func + '(buf, sizeof(buf), fmt, ...)')} for general formatting\n"
        f"  - {highlight('buf_append_printf(buf, sizeof(buf), pos, fmt, ...)')} for "
        f"offset-based formatting (also stores format strings in flash on ESP8266)\n"
        f"(If strictly necessary, add `// NOLINT` to the end of the line)"
    )


@lint_re_check(
    # Match std::to_string() or unqualified to_string() calls
    # The esphome namespace has "using std::to_string;" so unqualified calls resolve to std::to_string
    # Use negative lookbehind for unqualified calls to avoid matching:
    #   - Function definitions: "const char *to_string(" or "std::string to_string("
    #   - Method definitions: "Class::to_string("
    #   - Method calls: ".to_string(" or "->to_string("
    #   - Other identifiers: "_to_string("
    # Also explicitly match std::to_string since : is in the lookbehind
    r"(?:(?<![*&.\w>:])to_string|std\s*::\s*to_string)\s*\(" + CPP_RE_EOL,
    include=cpp_include,
    exclude=[
        # Vendored library
        "esphome/components/http_request/httplib.h",
        # Deprecated helpers that return std::string
        "esphome/core/helpers.cpp",
        "esphome/core/alloc_helpers.cpp",
        # The using declaration itself
        "esphome/core/helpers.h",
        # Test fixtures - not production embedded code
        "tests/integration/fixtures/*",
    ],
)
def lint_no_std_to_string(fname, match):
    return (
        f"{highlight('std::to_string()')} (including unqualified {highlight('to_string()')}) "
        f"allocates heap memory. On long-running embedded devices, repeated heap allocations "
        f"fragment memory over time.\n"
        f"\n"
        f"For plain integer formatting, prefer the dedicated helpers in helpers.h over "
        f"{highlight('snprintf()')} — they avoid pulling in printf formatting code and are "
        f"smaller and faster:\n"
        f"  int8_t:                       {highlight('int8_to_str(buf, val)')}      (buf >= 5 bytes)\n"
        f"  uint8_t/uint16_t/uint32_t:    {highlight('uint32_to_str(buf, val)')}    (buf = UINT32_MAX_STR_SIZE; smaller types auto-widen)\n"
        f"Example: {highlight('char buf[UINT32_MAX_STR_SIZE]; uint32_to_str(buf, value);')}\n"
        f"For sensor values, use {highlight('value_accuracy_to_buf()')} from helpers.h.\n"
        f"\n"
        f"Otherwise use {highlight('snprintf()')} with a stack buffer.\n"
        f"\n"
        f"Buffer sizes and format specifiers (sizes include sign and null terminator):\n"
        f"  uint8_t:          4 chars   - %u (or PRIu8)\n"
        f"  int8_t:           5 chars   - %d (or PRId8)\n"
        f"  uint16_t:         6 chars   - %u (or PRIu16)\n"
        f"  int16_t:          7 chars   - %d (or PRId16)\n"
        f"  uint32_t:         11 chars  - %" + "PRIu32\n"
        "  int32_t:          12 chars  - %" + "PRId32\n"
        "  uint64_t:         21 chars  - %" + "PRIu64\n"
        "  int64_t:          21 chars  - %" + "PRId64\n"
        f"  float/double:     24 chars  - %.8g (15 digits + sign + decimal + e+XXX)\n"
        f"                    317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n"
        f"\n"
        f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n'
        f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)"
    )


@lint_re_check(
    # Match scanf family functions: scanf, sscanf, fscanf, vscanf, vsscanf, vfscanf
    # Also match std:: prefixed versions
    # [^\w] ensures we match function calls, not substrings
    r"[^\w]((?:std::)?v?[fs]?scanf)\s*\(" + CPP_RE_EOL,
    include=cpp_include,
)
def lint_no_scanf(fname, match):
    func = match.group(1)
    return (
        f"{highlight(func + '()')} is not allowed in new ESPHome code. The scanf family "
        f"pulls in ~7KB flash on ESP8266 and ~9KB on ESP32, and ESPHome doesn't otherwise "
        f"need this code.\n"
        f"Please use alternatives:\n"
        f"  - {highlight('parse_number<T>(str)')} for parsing integers/floats from strings\n"
        f"  - {highlight('strtol()/strtof()')} for C-style number parsing with error checking\n"
        f"  - {highlight('parse_hex()')} for hex string parsing\n"
        f"  - Manual parsing for simple fixed formats\n"
        f"(If strictly necessary, add `// NOLINT` to the end of the line)"
    )


# Base entity platforms - these are linked into most builds and should not
# pull in powf/__ieee754_powf (~2.3KB flash).
BASE_ENTITY_PLATFORMS = [
    "alarm_control_panel",
    "binary_sensor",
    "button",
    "climate",
    "cover",
    "datetime",
    "event",
    "fan",
    "light",
    "lock",
    "media_player",
    "number",
    "select",
    "sensor",
    "switch",
    "text",
    "text_sensor",
    "update",
    "valve",
    "water_heater",
]

# Directories protected from powf: core + all base entity platforms
POWF_PROTECTED_DIRS = ["esphome/core"] + [
    f"esphome/components/{p}" for p in BASE_ENTITY_PLATFORMS
]


@lint_re_check(
    r"[^\w]powf\s*\(" + CPP_RE_EOL,
    include=[
        f"{d}/*.{ext}" for d in POWF_PROTECTED_DIRS for ext in ["h", "cpp", "tcc"]
    ],
)
def lint_no_powf_in_core(fname, match):
    return (
        f"{highlight('powf()')} pulls in __ieee754_powf (~2.3KB flash) and is not allowed in "
        f"core or base entity platform code. These files are linked into every build.\n"
        f"Please use alternatives:\n"
        f"  - {highlight('pow10_int(exp)')} for integer powers of 10 (from helpers.h)\n"
        f"  - Precomputed lookup tables for gamma/non-integer exponents\n"
        f"(If powf is strictly necessary, add `// NOLINT` to the line)"
    )


@lint_re_check(
    r"[^\w]std\s*::\s*bind\s*\(" + CPP_RE_EOL,
    include=cpp_include,
)
def lint_no_std_bind(fname, match):
    return (
        f"{highlight('std::bind()')} is not allowed in new ESPHome code. "
        f"Lambdas are clearer, produce smaller binaries, and are more likely to fit within "
        f"the {highlight('std::function')} small-buffer optimization (avoiding heap allocation).\n"
        f"Please use a lambda instead.\n"
        f"  Before: {highlight('std::bind(&Class::method, this, std::placeholders::_1)')}\n"
        f"  After:  {highlight('[this](auto arg) { this->method(arg); }')}\n"
        f"(If strictly necessary, add `// NOLINT` to the end of the line)"
    )


LOG_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL)
LOG_BAD_CONTINUATION_RE = re.compile(r'\\n(?:[^ \\"\r\n\t]|"\s*\n\s*"[^ \\])')
LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)')


@lint_content_check(include=cpp_include)
def lint_log_multiline_continuation(fname, content):
    errs = []
    for log_match in LOG_MULTILINE_RE.finditer(content):
        log_text = log_match.group(0)
        for bad_match in LOG_BAD_CONTINUATION_RE.finditer(log_text):
            # %s may expand to a whitespace prefix at runtime, skip those
            if LOG_PERCENT_S_CONTINUATION_RE.match(log_text, bad_match.start()):
                continue
            # Calculate line number from position in full content
            abs_pos = log_match.start() + bad_match.start()
            lineno = content.count("\n", 0, abs_pos) + 1
            col = abs_pos - content.rfind("\n", 0, abs_pos)
            errs.append(
                (
                    lineno,
                    col,
                    "Multi-line log message has a continuation line that does "
                    "not start with a space. The log viewer uses leading "
                    "whitespace to detect continuation lines and re-add the "
                    f"log tag prefix (e.g. {highlight('[C][component:042]:')}).\n"
                    "Either start the continuation with a space/indent, or "
                    "split into separate ESP_LOG* calls.",
                )
            )
    return errs


@lint_content_find_check(
    "ESP_LOG",
    include=["*.h", "*.tcc"],
    exclude=[
        "esphome/components/binary_sensor/binary_sensor.h",
        "esphome/components/button/button.h",
        "esphome/components/climate/climate.h",
        "esphome/components/cover/cover.h",
        "esphome/components/datetime/date_entity.h",
        "esphome/components/datetime/time_entity.h",
        "esphome/components/datetime/datetime_entity.h",
        "esphome/components/display/display.h",
        "esphome/components/event/event.h",
        "esphome/components/fan/fan.h",
        "esphome/components/i2c/i2c.h",
        "esphome/components/lock/lock.h",
        "esphome/components/mqtt/mqtt_component.h",
        "esphome/components/number/number.h",
        "esphome/components/one_wire/one_wire.h",
        "esphome/components/output/binary_output.h",
        "esphome/components/output/float_output.h",
        "esphome/components/nextion/nextion_base.h",
        "esphome/components/select/select.h",
        "esphome/components/sensor/sensor.h",
        "esphome/components/spi/spi.h",
        "esphome/components/stepper/stepper.h",
        "esphome/components/switch/switch.h",
        "esphome/components/text/text.h",
        "esphome/components/text_sensor/text_sensor.h",
        "esphome/components/valve/valve.h",
        "esphome/core/component.h",
        "esphome/core/gpio.h",
        "esphome/core/log_const_en.h",
        "esphome/core/log.h",
        "tests/custom.h",
    ],
)
def lint_log_in_header(fname, line, col, content):
    return (
        "Found reference to ESP_LOG in header file. Using ESP_LOG* in header files "
        "is currently not possible - please move the definition to a source file (.cpp)"
    )


PACKAGE_BUS_RE = re.compile(
    r"^\s+(\w+):\s*!include\s+\S*test_build_components/common/(\w+)/",
    re.MULTILINE,
)


@lint_content_check(
    include=[
        "tests/components/*/test.*.yaml",
        "tests/components/*/validate.*.yaml",
    ]
)
def lint_test_package_key_matches_bus(fname, content):
    """Ensure package keys match the common bus directory name.

    For example, a package using uart_115200 includes must use
    'uart_115200' as the key, not 'uart'.
    """
    errs: list[tuple[int, int, str]] = []
    for match in PACKAGE_BUS_RE.finditer(content):
        pkg_key = match.group(1)
        bus_dir = match.group(2)
        if pkg_key != bus_dir:
            lineno = content.count("\n", 0, match.start()) + 1
            errs.append(
                (
                    lineno,
                    1,
                    f"Package key {highlight(pkg_key)} does not match bus directory "
                    f"{highlight(bus_dir)}. The package key must match the directory "
                    f"name under tests/test_build_components/common/. "
                    f"Change {highlight(pkg_key)} to {highlight(bus_dir)}.",
                )
            )
    return errs


@lint_content_find_check(
    "FINAL_VALIDATE_SCHEMA",
    include=["esphome/core/*.py"],
    exclude=["esphome/core/entity_helpers.py"],
)
def lint_final_validate_in_core(fname, line, col, content):
    return (
        "FINAL_VALIDATE_SCHEMA in esphome/core/ is not picked up by the component loader. "
        "Use CoreFinalValidateStep in esphome/config.py instead."
    )


def main():
    colorama.init()

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "files", nargs="*", default=[], help="files to be processed (regex on path)"
    )
    parser.add_argument(
        "-c", "--changed", action="store_true", help="Only run on changed files"
    )
    parser.add_argument(
        "--print-slowest", action="store_true", help="Print the slowest checks"
    )
    args = parser.parse_args()

    EXECUTABLE_BIT.update(git_ls_files())
    files = list(EXECUTABLE_BIT.keys())
    # Match against re
    file_name_re = re.compile("|".join(args.files))
    files = [p for p in files if file_name_re.search(p)]

    if args.changed:
        files = filter_changed(files)

    files.sort()

    for fname in files:
        fname = Path(fname)
        run_checks(LINT_FILE_CHECKS, fname, fname)
        if fname.suffix in ignore_types:
            continue
        try:
            with codecs.open(fname, "r", encoding="utf-8") as f_handle:
                content = f_handle.read()
        except UnicodeDecodeError:
            add_errors(
                fname,
                "File is not readable as UTF-8. Please set your editor to UTF-8 mode.",
            )
            continue
        run_checks(LINT_CONTENT_CHECKS, fname, fname, content)

    run_checks(LINT_POST_CHECKS, Path("POST"))

    for f, errs in sorted(errors.items()):
        bold = functools.partial(styled, colorama.Style.BRIGHT)
        bold_red = functools.partial(styled, (colorama.Style.BRIGHT, colorama.Fore.RED))
        err_str = (
            f"{bold(f'{f}:{lineno}:{col}:')} {bold_red('lint:')} {msg}\n"
            for lineno, col, msg in errs
        )
        print_error_for_file(f, "\n".join(err_str))

    if args.print_slowest:
        lint_times = []
        for lint in LINT_FILE_CHECKS + LINT_CONTENT_CHECKS + LINT_POST_CHECKS:
            durations = lint.get("durations", [])
            lint_times.append((sum(durations), len(durations), lint["func"].__name__))
        lint_times.sort(key=lambda x: -x[0])
        for i in range(min(len(lint_times), 10)):
            dur, invocations, name = lint_times[i]
            print(f" - '{name}' took {dur:.2f}s total (ran on {invocations} files)")
        print(f"Total time measured: {sum(x[0] for x in lint_times):.2f}s")

    return len(errors)


if __name__ == "__main__":
    sys.exit(main())
