#!/usr/bin/python3
# -*- coding: utf-8 -*-
# pylint: disable=too-many-lines

from __future__ import annotations

import locale
import re
from pathlib import Path

import urllib3  # pylint: disable=import-error

from sbo_create.handlers.fallback import SboResolver
from sbo_create.handlers.files import FileHandler
from sbo_create.handlers.fix import FixHandler
from sbo_create.models import (EMAIL_RE, PROG, SOURCE_EXTENSIONS, UNSUPPORTED,
                               AppContext, colorize)


class ValidateHandler:
    """Handle validation of SlackBuild package files.

    Provides individual check methods for each SBo submission requirement and
    aggregates them into grouped reports. Auto-fix operations are delegated to
    FixHandler.
    """

    def __init__(self, ctx: AppContext, files: FileHandler,
                 resolver: SboResolver) -> None:
        self.ctx = ctx
        self.files = files
        self.resolver = resolver
        self.fixer = FixHandler(ctx, files)

    def _check_slackbuild_consistency(self, check_sources: bool = False) -> list[str]:
        """Check PRGNAM, VERSION, permissions and mandatory SBo fields of .SlackBuild.

        Verifies shebang, BUILD, TAG (_SBo), PKGTYPE (tgz) variables and VERSION
        consistency with the .info file. Optionally checks that the file is not
        executable (required for SBo submission).

        Args:
            check_sources: If True, also verify the .SlackBuild is not executable.

        Returns:
            List of error message strings (empty if all checks pass).
        """
        errors: list[str] = []
        name: str = self.ctx.prg_name
        slackbuild_name: str = f'{name}.SlackBuild'
        slackbuild: Path = Path(self.ctx.current_folder, f'{name}.SlackBuild')
        if not slackbuild.is_file():
            return errors
        if check_sources and (slackbuild.stat().st_mode & 0o111):
            errors.append(f'{colorize(slackbuild_name, "red")}: must not be executable for SBo submission')
        content: str = slackbuild.read_text(encoding='utf-8')

        if self.ctx.info.version:
            version_match = re.search(r'\$\{VERSION:-([^}]+)\}', content)
            if version_match and version_match.group(1) != self.ctx.info.version:
                errors.append(
                    f'{colorize(slackbuild_name, "red")}: VERSION mismatch:'
                    f' .info={self.ctx.info.version}'
                    f', .SlackBuild={version_match.group(1)}'
                )

        if not content.startswith('#!/bin/bash'):
            errors.append(f'{colorize(slackbuild_name, "red")}: shebang must be #!/bin/bash')
        if not re.search(r'BUILD=\$\{BUILD:-', content):
            errors.append(f'{colorize(slackbuild_name, "red")}: BUILD variable not found')
        if not re.search(r'TAG=\$\{TAG:-_SBo\}', content):
            errors.append(f'{colorize(slackbuild_name, "red")}: TAG must be set to _SBo')
        if not re.search(r'PKGTYPE=\$\{PKGTYPE:-tgz\}', content):
            errors.append(f'{colorize(slackbuild_name, "red")}: PKGTYPE must be set to tgz')

        return errors

    def _validate_info_variables(self, info_lines: list[str]) -> list[str]:
        """Check that all required SBo variables are present and non-empty in .info.

        Validates PRGNAM, VERSION, HOMEPAGE, DOWNLOAD, MD5SUM, DOWNLOAD_x86_64
        (or DOWNLOAD_ARM64), MD5SUM_x86_64 (or MD5SUM_ARM64), REQUIRES, MAINTAINER
        and EMAIL. Also checks EMAIL format, HOMEPAGE URL format and PRGNAM match.

        Args:
            info_lines: Lines of the .info file.

        Returns:
            List of error message strings (empty if all checks pass).
        """
        errors: list[str] = []
        info_name: str = f'{self.ctx.prg_name}.info'
        is_arm = any('DOWNLOAD_ARM64=' in line or 'MD5SUM_ARM64=' in line for line in info_lines)

        for key, must_not_be_empty, value in [
            ('PRGNAM', True, getattr(self.ctx, 'prg_name', '')),
            ('VERSION', True, self.ctx.info.version),
            ('HOMEPAGE', True, self.ctx.info.homepage),
            ('DOWNLOAD', False, ''),
            ('MD5SUM', False, ''),
            ('DOWNLOAD_ARM64' if is_arm else 'DOWNLOAD_x86_64', False, ''),
            ('MD5SUM_ARM64' if is_arm else 'MD5SUM_x86_64', False, ''),
            ('REQUIRES', False, ''),
            ('MAINTAINER', True, self.ctx.info.maintainer),
            ('EMAIL', True, self.ctx.info.email),
        ]:
            if not any(line.startswith(f"{key}=") for line in info_lines):
                errors.append(f"{colorize(info_name, 'red')}: {key} variable not found")
            elif must_not_be_empty and not value and key != 'PRGNAM':
                errors.append(f"{colorize(info_name, 'red')}: {key} is empty")

        email: str = self.ctx.info.email
        if email and not EMAIL_RE.match(email):
            errors.append(f"{colorize(info_name, 'red')}: EMAIL format is invalid")

        homepage: str = self.ctx.info.homepage
        if homepage and not re.match(r'^https?://', homepage):
            errors.append(f"{colorize(info_name, 'red')}: HOMEPAGE is not a valid URL")

        # 2. Check PRGNAM value specifically
        info_prgnam = ''
        for line in info_lines:
            if line.startswith('PRGNAM='):
                val = line.split('=', 1)[1].strip()
                if val.startswith('"') and val.endswith('"') and len(val) >= 2:
                    info_prgnam = val.strip('"')
                break
        if info_prgnam and info_prgnam != self.ctx.prg_name:
            errors.append(f"{colorize(info_name, 'red')}: PRGNAM mismatch (directory={self.ctx.prg_name}, info={info_prgnam})")

        # 3. Check combined DOWNLOAD emptiness
        if not self.ctx.info.download_x86 and not self.ctx.info.download_x86_64:
            if any(line.startswith(("DOWNLOAD=", "DOWNLOAD_x86_64=", "DOWNLOAD_ARM64=")) for line in info_lines):
                # Only complain it's empty if at least one of the tags is actually present
                errors.append(f"{colorize(info_name, 'red')}: DOWNLOAD is empty")

        return errors

    def _check_info_consistency(self) -> list[str]:
        """Check MD5SUM presence, URL format and count consistency in .info.

        For each DOWNLOAD/MD5SUM pair, verifies that URLs use http/https/ftp,
        that each URL has a filename, that MD5SUM is non-empty, that the count
        of checksums matches the count of URLs, and that each checksum is a
        valid 32-character hex string.

        Returns:
            List of error message strings (empty if all checks pass).
        """
        errors: list[str] = []
        info_name: str = f'{self.ctx.prg_name}.info'
        info_file: Path = Path(self.ctx.current_folder, info_name)

        if not info_file.is_file():
            return errors

        for downloads, md5sums, label in [
            (self.ctx.info.download_x86, self.ctx.info.md5sum_x86, 'MD5SUM'),
            (self.ctx.info.download_x86_64, self.ctx.info.md5sum_x86_64, 'MD5SUM_x86_64'),
        ]:
            if not downloads or downloads in UNSUPPORTED:
                continue
            urls = downloads.split()
            sums = md5sums.split()
            for url in urls:
                if not re.match(r'^(https?|ftp)://', url):
                    errors.append(f"{colorize(info_name, 'red')}: invalid URL format ({url})")
                elif not url.split('/')[-1]:
                    errors.append(f"{colorize(info_name, 'red')}: DOWNLOAD URL has no filename ({url})")
            if not md5sums:
                errors.append(f"{colorize(info_name, 'red')}: {label} is empty while DOWNLOAD is set")
            elif len(urls) != len(sums):
                errors.append(f"{colorize(info_name, 'red')}: {label} count mismatch"
                              f" ({len(sums)} checksums, {len(urls)} URLs)")
            else:
                for checksum in sums:
                    if not re.match(r'^[0-9a-f]{32}$', checksum):
                        errors.append(f"{colorize(info_name, 'red')}: {label} contains"
                                      f" invalid checksum '{checksum}'")
                        break
        return errors

    def _check_slack_desc(self) -> list[str]:
        """Check the slack-desc content against SBo formatting rules.

        Verifies that lines 9–19 contain exactly 11 description lines, all starting
        with 'name:', with exactly one space after the colon, none exceeding 70
        characters, the handy-ruler aligned to the package name length, and the
        first line containing a short description in parentheses.

        Returns:
            List of error message strings (empty if all checks pass).
        """
        errors: list[str] = []
        desc_name: str = 'slack-desc'
        slack_desc: Path = Path(self.ctx.current_folder, desc_name)
        if not slack_desc.is_file():
            return errors
        name: str = self.ctx.prg_name
        all_lines: list[str] = slack_desc.read_text(encoding='utf-8').splitlines()

        desc_lines: list[str] = [line for line in all_lines[8:19] if line.startswith(f'{name}:')]
        if len(desc_lines) != 11:
            errors.append(f"{colorize(desc_name, 'red')}: must have exactly 11 description lines (found {len(desc_lines)})")

        bad_prefix = next((ln for ln in all_lines[8:19] if not ln.startswith(f'{name}:')), None)
        if bad_prefix is not None:
            errors.append(f"{colorize(desc_name, 'red')}: line does not start with '{name}:': {bad_prefix!r}")

        max_len: int = len(name) + 72
        for line in all_lines[8:19]:
            if len(line) > max_len:
                errors.append(f"{colorize(desc_name, 'red')}: description line exceeds 70 characters")
                break

        prefix: str = f'{name}:'
        for line in all_lines[8:19]:
            if line.startswith(prefix):
                rest: str = line[len(prefix):]
                if rest and not rest.startswith(' '):
                    errors.append(f"{colorize(desc_name, 'red')}: missing space after '{name}:'")
                    break
                if rest.startswith('  '):
                    errors.append(f"{colorize(desc_name, 'red')}: extra space after '{name}:'")
                    break

        expected_ruler: str = ' ' * len(name) + '|-----handy-ruler' + '-' * 54 + '|'
        if len(all_lines) > 7 and all_lines[7] != expected_ruler:
            errors.append(f"{colorize(desc_name, 'red')}: handy-ruler is missing or misaligned")

        if len(all_lines) > 8:
            first_desc: str = all_lines[8]
            if first_desc.startswith(f'{name}:') and not re.search(r'\(.+\)', first_desc):
                errors.append(f"{colorize(desc_name, 'red')}: first line must be"
                              f" '{name}: {name} (short description)'")

        return errors

    def _check_required_files(self) -> list[str]:
        """Check that all required SBo submission files are present.

        Checks for <prgnam>.SlackBuild, <prgnam>.info, slack-desc and README.

        Returns:
            List of error message strings (empty if all files exist).
        """
        errors: list[str] = []
        name: str = self.ctx.prg_name
        folder: Path = self.ctx.current_folder
        for filename in [f'{name}.SlackBuild', f'{name}.info', 'slack-desc', 'README']:
            if not Path(folder, filename).is_file():
                errors.append(f"{colorize(filename, 'red')}: required file missing")
        return errors

    def _check_readme(self) -> tuple[list[str], list[str]]:
        """Check that README exists and is not empty.

        Returns:
            A tuple of (errors, warnings).
        """
        errors: list[str] = []
        warnings: list[str] = []
        readme: Path = Path(self.ctx.current_folder, 'README')
        if readme.is_file():
            content: str = readme.read_text(encoding='utf-8')
            if not content.strip():
                errors.append(f"{colorize('README', 'red')}: is empty")
        return errors, warnings

    def _check_trailing_newlines(self) -> list[str]:
        """Check that no submission file ends with trailing blank lines or missing newline.

        Checks .info, .SlackBuild, slack-desc and README.

        Returns:
            List of error message strings (empty if all checks pass).
        """
        errors: list[str] = []
        name: str = self.ctx.prg_name
        for filename in [f'{name}.info', f'{name}.SlackBuild', 'slack-desc', 'README']:
            path: Path = Path(self.ctx.current_folder, filename)
            if not path.is_file():
                continue
            content: str = path.read_text(encoding='utf-8')
            if not content.strip():
                continue
            if not content.endswith('\n'):
                errors.append(
                    f"{colorize(filename, 'red')}: must end with a newline"
                    " (run --fix to add)")
            elif content.endswith('\n\n'):
                errors.append(
                    f"{colorize(filename, 'red')}: ends with trailing blank line(s)"
                    " (run --fix to remove)")
        return errors

    def _check_no_sources(self) -> list[str]:
        """Check that no source tarballs are present in the package folder.

        SBo submission packages must not include source archives.

        Returns:
            List with one error string if any tarball is found, otherwise empty.
        """
        errors: list[str] = []
        for f in self.ctx.current_folder.iterdir():
            if f.is_file() and any(f.name.endswith(ext) for ext in SOURCE_EXTENSIONS):
                errors.append(f"{colorize('Sources', 'red')}: source tarball(s) must not be included")
                break
        return errors

    def _check_trailing_whitespace(self) -> list[str]:
        """Check that no submission file contains lines with trailing whitespace.

        Checks .info, .SlackBuild, slack-desc and README.

        Returns:
            List of error message strings (empty if all checks pass).
        """
        errors: list[str] = []
        name: str = self.ctx.prg_name
        for filename in [f'{name}.info', f'{name}.SlackBuild', 'slack-desc', 'README']:
            path: Path = Path(self.ctx.current_folder, filename)
            if not path.is_file():
                continue
            lines: list[str] = path.read_text(encoding='utf-8').splitlines()
            if any(line != line.rstrip() for line in lines):
                errors.append(
                    f"{colorize(filename, 'red')}: contains trailing whitespace")
        return errors

    def _check_ascii(self) -> list[str]:
        """Check that all submission files contain only plain ASCII characters.

        SBo policy requires ASCII-only content in .SlackBuild, .info,
        slack-desc and README.

        Returns:
            List of error message strings (empty if all checks pass).
        """
        errors: list[str] = []
        name: str = self.ctx.prg_name
        for filename in [f'{name}.SlackBuild', f'{name}.info', 'slack-desc', 'README']:
            path: Path = Path(self.ctx.current_folder, filename)
            if not path.is_file():
                continue
            try:
                path.read_text(encoding='ascii')
            except UnicodeDecodeError:
                errors.append(f"{colorize(filename, 'red')}: contains non-ASCII characters")
        return errors

    def _check_info_quotes(self) -> list[str]:  # pylint: disable=too-many-branches
        """Check that all .info field values are properly double-quoted.

        Verifies each field value starts and ends with a double quote, contains
        no embedded quotes, and that multi-line values use backslash continuation
        and a proper closing quote.

        Returns:
            List of error message strings (empty if all checks pass).
        """
        errors: list[str] = []
        name: str = self.ctx.prg_name
        info_name: str = f'{name}.info'
        info_file: Path = Path(self.ctx.current_folder, info_name)
        if not info_file.is_file():
            return errors

        in_multiline: bool = False
        current_key: str = ''
        lines: list[str] = info_file.read_text(encoding='utf-8').splitlines()
        for idx, line in enumerate(lines):
            if line.strip().startswith('#'):
                continue
            if not in_multiline:
                if '=' not in line:
                    continue
                key, _, raw = line.partition('=')
                current_key = key.strip()
                value: str = raw.strip()
                if not value.startswith('"'):
                    errors.append(f"{colorize(info_name, 'red')}: {current_key}"
                                  " value not enclosed in double quotes")
                elif value.endswith('"') and value.count('"') >= 2:
                    if value.count('"') > 2:
                        errors.append(f"{colorize(info_name, 'red')}: {current_key}"
                                      " value contains embedded double quotes")
                elif raw.rstrip().endswith('\\'):
                    in_multiline = True
                else:
                    errors.append(f"{colorize(info_name, 'red')}: {current_key}"
                                  " missing closing quote")
            else:
                stripped: str = line.rstrip()
                if stripped.endswith('"'):
                    in_multiline = False
                elif not stripped.endswith('\\'):
                    next_line: str = lines[idx + 1].strip() if idx + 1 < len(lines) else ''
                    if '=' in next_line and not next_line.startswith('#'):
                        errors.append(f"{colorize(info_name, 'red')}: {current_key}"
                                      " missing closing quote")
                    else:
                        errors.append(f"{colorize(info_name, 'red')}: {current_key}"
                                      " missing backslash in multi-line value")
                    in_multiline = False
        return errors

    def _check_requires_existence(self) -> list[str]:
        """Verify that all REQUIRES dependencies exist in the SBo repository.

        Also flags self-references and the deprecated '%README%' placeholder.
        Skips the check if the SBo package list is not available.

        Returns:
            List of error message strings (empty if all checks pass).
        """
        errors: list[str] = []
        requires = self.ctx.info.requires.strip()
        if not requires:
            return errors

        info_name: str = f'{self.ctx.prg_name}.info'
        deps: list[str] = requires.split()

        for dep in deps:
            if dep == self.ctx.prg_name:
                errors.append(f"{colorize(info_name, 'red')}: REQUIRES contains"
                              f" self-reference ('{dep}')")
            elif dep == '%README%':
                errors.append(f"{colorize(info_name, 'red')}: REQUIRES contains"
                              " deprecated placeholder '%README%'")

        sbo_deps: list[str] = [d for d in deps if d not in (self.ctx.prg_name, '%README%')]
        if not sbo_deps:
            return errors

        self.resolver.load()
        if not self.ctx.sbo_packages:
            return errors

        for dep in sbo_deps:
            if dep not in self.ctx.sbo_packages:
                errors.append(f"{colorize(info_name, 'red')}: dependency '{dep}'"
                              " not found in SBo repository")
        return errors

    def _check_md5sums(self, show_warnings: bool) -> tuple[list[str], list[str]]:
        """Verify MD5 checksums for locally downloaded source files.

        Computes the actual MD5 of each source file present on disk and compares
        it to the expected value in the .info file. Missing files are counted and
        optionally reported as warnings.

        Args:
            show_warnings: If True, emit per-file warnings for missing or untracked
                source files.

        Returns:
            A tuple of (errors, warnings) where errors contains checksum mismatch
            messages and warnings contains informational messages about missing or
            untracked sources.
        """
        errors: list[str] = []
        warnings: list[str] = []
        expected_sources: set[str] = set()
        missing_count: int = 0

        for downloads, md5sums in [
            (self.ctx.info.download_x86, self.ctx.info.md5sum_x86),
            (self.ctx.info.download_x86_64, self.ctx.info.md5sum_x86_64),
        ]:
            if not downloads or downloads in UNSUPPORTED:
                continue
            urls, sums = downloads.split(), md5sums.split()
            if len(urls) != len(sums):
                continue
            for src, md5 in zip(urls, sums):
                filename: str = src.split('/')[-1]
                expected_sources.add(filename)
                actual: str = self.files.source_check_sum(filename)
                if actual:
                    if actual != md5:
                        errors.append(f"{colorize(filename, 'red')}: MD5SUM mismatch")
                else:
                    missing_count += 1
                    if show_warnings:
                        warnings.append(f"{colorize(filename, 'yellow')}: source not found (skipped MD5SUM)")

        self._check_untracked_sources(expected_sources, show_warnings, missing_count, warnings)
        return errors, warnings

    def _check_untracked_sources(self, expected_sources: set[str], show_warnings: bool,
                                 missing_count: int, warnings: list[str]) -> None:
        """Append warnings for untracked source files found in the package folder.

        When show_warnings is True, any source tarball present on disk that is not
        listed in the .info DOWNLOAD fields is reported as untracked. When False,
        a single summary warning is emitted if any checksums were skipped.

        Args:
            expected_sources: Set of filenames that are expected (from DOWNLOAD).
            show_warnings: If True, report each untracked file individually.
            missing_count: Number of expected source files not found on disk.
            warnings: Mutable list to which warning strings are appended.
        """
        if show_warnings:
            for f in self.ctx.current_folder.iterdir():
                if f.is_file() and any(f.name.endswith(ext) for ext in SOURCE_EXTENSIONS):
                    if f.name not in expected_sources:
                        warnings.append(f"{colorize(f.name, 'yellow')}: untracked source tarball found")
        elif missing_count > 0:
            warnings.append(f"{missing_count} checksum(s) skipped (pass '--md5' option for details)")

    def _check_permissions(self) -> list[str]:
        """Check that the .SlackBuild file is not executable.

        SBo policy requires the SlackBuild script to not have the executable bit set.

        Returns:
            List with one error string if executable, otherwise empty.
        """
        name: str = self.ctx.prg_name
        slackbuild: Path = Path(self.ctx.current_folder, f'{name}.SlackBuild')
        if slackbuild.is_file() and (slackbuild.stat().st_mode & 0o111):
            return [f'{colorize(f"{name}.SlackBuild", "red")}: must not be executable']
        return []

    def _check_https_urls(self) -> list[str]:
        """Check that all DOWNLOAD URLs use HTTPS instead of plain HTTP.

        Returns:
            List of error message strings (empty if all URLs use HTTPS or FTP).
        """
        errors: list[str] = []
        info_name: str = f'{self.ctx.prg_name}.info'
        for downloads in [self.ctx.info.download_x86, self.ctx.info.download_x86_64]:
            if downloads and downloads not in UNSUPPORTED:
                for url in downloads.split():
                    if url.startswith('http://'):
                        errors.append(
                            f'{colorize(info_name, "red")}: URL uses HTTP, not HTTPS: {url}')
        return errors

    def _check_version(self) -> list[str]:
        """Check that VERSION contains no spaces.

        Returns:
            List with one error string if VERSION has spaces, otherwise empty.
        """
        version: str = self.ctx.info.version
        if version and ' ' in version:
            return [f'{colorize(f"{self.ctx.prg_name}.info", "red")}: VERSION contains spaces: "{version}"']
        return []

    def _check_homepage(self) -> list[str]:
        """Check that HOMEPAGE uses HTTPS and responds with a successful HTTP status.

        Makes a HEAD request to the HOMEPAGE URL with a 10-second timeout.
        Accepts HTTP 200 and 405 (Method Not Allowed) as success codes.

        Returns:
            List of error message strings (empty if HOMEPAGE is valid and reachable).
        """
        errors: list[str] = []
        info_name: str = f'{self.ctx.prg_name}.info'
        homepage: str = self.ctx.info.homepage
        if not homepage:
            return errors
        if homepage.startswith('http://'):
            errors.append(
                f'{colorize(info_name, "red")}: HOMEPAGE uses HTTP, not HTTPS: {homepage}')
        http = urllib3.PoolManager()
        try:
            resp = http.request('HEAD', homepage, timeout=10.0, redirect=True)
            if resp.status not in (200, 405):
                errors.append(
                    f'{colorize(info_name, "red")}: HOMEPAGE returned HTTP {resp.status}')
        except Exception:  # pylint: disable=broad-except
            errors.append(f'{colorize(info_name, "red")}: HOMEPAGE is not accessible')
        return errors

    def _check_slack_desc_homepage(self) -> list[str]:
        """Check that the Homepage: URL in slack-desc uses HTTPS and is reachable.

        Looks for a line of the form 'prgnam: Homepage: URL' in the slack-desc
        file. If found, performs the same HTTPS and HTTP HEAD checks as
        _check_homepage(). If no Homepage line is present, returns no errors.

        Returns:
            List of error message strings (empty if valid or not present).
        """
        errors: list[str] = []
        slack_desc: Path = self.ctx.current_folder / 'slack-desc'
        if not slack_desc.is_file():
            return errors
        prefix: str = f'{self.ctx.prg_name}:'
        url: str = ''
        for line in slack_desc.read_text(encoding='utf-8', errors='replace').splitlines():
            if line.startswith(prefix):
                rest: str = line[len(prefix):].strip()
                if rest.lower().startswith('homepage:'):
                    url = rest[len('homepage:'):].strip()
                    break
        if not url:
            return errors
        if url.startswith('http://'):
            errors.append(
                f'{colorize("slack-desc", "red")}: Homepage uses HTTP, not HTTPS: {url}')
        http = urllib3.PoolManager()
        try:
            resp = http.request('HEAD', url, timeout=10.0, redirect=True)
            if resp.status not in (200, 405):
                errors.append(
                    f'{colorize("slack-desc", "red")}: Homepage returned HTTP {resp.status}')
        except Exception:  # pylint: disable=broad-except
            errors.append(f'{colorize("slack-desc", "red")}: Homepage is not accessible')
        return errors

    def _check_slack_desc_lines(self) -> list[str]:
        """Check that slack-desc has exactly 19 lines total.

        Returns:
            List with one error string if the line count is wrong, otherwise empty.
        """
        slack_desc: Path = Path(self.ctx.current_folder, 'slack-desc')
        if slack_desc.is_file():
            line_count: int = len(slack_desc.read_text(encoding='utf-8').splitlines())
            if line_count != 19:
                return [f'{colorize("slack-desc", "red")}: must have exactly 19 lines (found {line_count})']
        return []

    def _check_maintainer_info(self) -> list[str]:
        """Check that MAINTAINER and EMAIL in .info match the configured values.

        Only runs when both configured name and email are non-empty.

        Returns:
            List of error message strings (empty if all values match or not configured).
        """
        cfg_name: str = self.ctx.maintainer.name
        cfg_email: str = self.ctx.maintainer.email
        if not cfg_name or not cfg_email:
            return []
        info_name: str = f'{self.ctx.prg_name}.info'
        info_name_val: str = self.ctx.info.maintainer
        info_email_val: str = self.ctx.info.email
        errors: list[str] = []
        if info_name_val and info_name_val != cfg_name:
            errors.append(
                f'{colorize(info_name, "red")}: MAINTAINER "{info_name_val}"'
                f' does not match configured name "{cfg_name}"')
        if info_email_val and info_email_val != cfg_email:
            errors.append(
                f'{colorize(info_name, "red")}: EMAIL "{info_email_val}"'
                f' does not match configured email "{cfg_email}"')
        return errors

    def _check_copyright_year(self) -> str:
        """Return a warning string if the copyright year in .SlackBuild is outdated.

        Checks for lines matching:
          # Copyright YYYY-YYYY  <maintainer name>  ...  (range format)
          # Copyright YYYY  <maintainer name>  ...       (single-year format)

        Returns an empty string when the maintainer name is not configured,
        the .SlackBuild does not exist, no matching copyright line is found,
        or the year is already current.

        Returns:
            Warning string, or '' if nothing to report.
        """
        maintainer: str = self.ctx.maintainer.name
        if not maintainer:
            return ''
        slackbuild: Path = Path(self.ctx.current_folder, f'{self.ctx.prg_name}.SlackBuild')
        if not slackbuild.is_file():
            return ''
        current_year: int = self.ctx.year
        escaped: str = re.escape(maintainer)
        content: str = slackbuild.read_text(encoding='utf-8')
        # Range format: # Copyright YYYY-YYYY  Name
        m = re.search(rf'# Copyright \d{{4}}-(\d{{4}})[^\S\n]+{escaped}', content)
        if m and int(m.group(1)) < current_year:
            return (f'{self.ctx.prg_name}.SlackBuild: copyright year {m.group(1)}'
                    f' is outdated — run --fix to update to {current_year}')
        # Single-year format (only matches when followed by whitespace, not '-')
        m = re.search(rf'# Copyright (\d{{4}})[^\S\n]+{escaped}', content)
        if m and int(m.group(1)) < current_year:
            return (f'{self.ctx.prg_name}.SlackBuild: copyright year {m.group(1)}'
                    f' is outdated — run --fix to update to {current_year}')
        return ''

    # ------------------------------------------------------------------
    # Fix delegation — thin wrappers so callers can use validate._fix_*
    # ------------------------------------------------------------------

    def _fix_file(self, filepath: Path, fix_newline: bool = False) -> tuple[bool, bool]:
        """Delegate to FixHandler._fix_file."""
        return self.fixer._fix_file(filepath, fix_newline)  # pylint: disable=protected-access

    def _fix_info_quotes(self, filepath: Path) -> bool:
        """Delegate to FixHandler._fix_info_quotes."""
        return self.fixer._fix_info_quotes(filepath)  # pylint: disable=protected-access

    def _fix_slack_desc_prefix(self, filepath: Path) -> bool:
        """Delegate to FixHandler._fix_slack_desc_prefix."""
        return self.fixer._fix_slack_desc_prefix(filepath)  # pylint: disable=protected-access

    def _fix_slack_desc_ruler(self, filepath: Path) -> bool:
        """Delegate to FixHandler._fix_slack_desc_ruler."""
        return self.fixer._fix_slack_desc_ruler(filepath)  # pylint: disable=protected-access

    def _fix_slackbuild_executable(self, filepath: Path) -> bool:
        """Delegate to FixHandler._fix_slackbuild_executable."""
        return self.fixer._fix_slackbuild_executable(filepath)  # pylint: disable=protected-access

    def _fix_info_variables(self, filepath: Path) -> dict[str, bool]:
        """Delegate to FixHandler._fix_info_variables."""
        return self.fixer._fix_info_variables(filepath)  # pylint: disable=protected-access

    def _fix_files(self) -> None:
        """Delegate to FixHandler._fix_files."""
        self.fixer._fix_files()  # pylint: disable=protected-access

    def _collect_md5_mismatches(self) -> list[tuple[str, str, str]]:
        """Delegate to FixHandler._collect_md5_mismatches."""
        return self.fixer._collect_md5_mismatches()  # pylint: disable=protected-access

    def _fix_md5sums(self) -> bool:
        """Delegate to FixHandler._fix_md5sums."""
        return self.fixer._fix_md5sums()  # pylint: disable=protected-access

    def _fix_version_mismatch(self) -> bool:
        """Delegate to FixHandler._fix_version_mismatch."""
        return self.fixer._fix_version_mismatch()  # pylint: disable=protected-access

    def _fix_copyright_year(self) -> bool:
        """Delegate to FixHandler._fix_copyright_year."""
        return self.fixer._fix_copyright_year()  # pylint: disable=protected-access

    def _fix_homepage_http(self) -> bool:
        """Delegate to FixHandler._fix_homepage_http."""
        return self.fixer._fix_homepage_http()  # pylint: disable=protected-access

    def _fix_sources(self) -> bool:
        """Delegate to FixHandler._fix_sources."""
        return self.fixer._fix_sources()  # pylint: disable=protected-access

    def _fix_strict_files(self) -> None:
        """Delegate to FixHandler._fix_strict_files."""
        self.fixer._fix_strict_files()  # pylint: disable=protected-access

    def collect_validation_report(
            self, show_warnings: bool = False,
            check_sources: bool = False,
            strict: bool = False,
    ) -> tuple[list[tuple[str, list[str]]], list[str]]:
        """Collect all validation results grouped by check category.

        If any required file is missing, returns early with only the Required files
        group to avoid misleading results for checks that depend on those files.

        Args:
            show_warnings: If True, include per-file source warnings in the report.
            check_sources: If True, also check for source tarballs in the folder.
            strict: If True, run additional pre-submission checks (HTTPS, HOMEPAGE
                accessibility, executable bit, VERSION spaces, slack-desc line count).

        Returns:
            A tuple of (groups, warnings) where groups is a list of
            (label, errors) tuples and warnings is a list of warning strings.
        """
        required_errors: list[str] = self._check_required_files()
        if required_errors:
            return [('Required files', required_errors)], []

        info_var_errors: list[str] = []
        info_file: Path = Path(self.ctx.current_folder, f'{self.ctx.prg_name}.info')
        if info_file.is_file():
            info_lines: list[str] = info_file.read_text(encoding='utf-8').splitlines()
            info_var_errors = self._validate_info_variables(info_lines)

        readme_errors, readme_warnings = self._check_readme()
        md5_errors, warnings = self._check_md5sums(show_warnings)
        warnings = readme_warnings + warnings
        copyright_warn: str = self._check_copyright_year()
        if copyright_warn:
            warnings.append(copyright_warn)

        groups: list[tuple[str, list[str]]] = [
            ('Required files', required_errors),
            ('README', readme_errors),
            ('.info variables', info_var_errors),
            ('.info consistency', self._check_info_consistency()),
            ('.info quotes', self._check_info_quotes()),
            ('.SlackBuild', self._check_slackbuild_consistency(check_sources)),
            ('slack-desc', self._check_slack_desc()),
            ('REQUIRES', self._check_requires_existence()),
            ('Trailing whitespace', self._check_trailing_whitespace()),
            ('Trailing newlines', self._check_trailing_newlines()),
            ('ASCII encoding', self._check_ascii()),
            ('MD5SUM', md5_errors),
        ]

        if check_sources or strict:
            groups.append(('Source tarballs', self._check_no_sources()))
        if strict:
            groups.extend([
                ('Permissions', self._check_permissions()),
                ('HTTPS URLs', self._check_https_urls()),
                ('VERSION', self._check_version()),
                ('HOMEPAGE', self._check_homepage()),
                ('slack-desc Homepage', self._check_slack_desc_homepage()),
                ('slack-desc lines', self._check_slack_desc_lines()),
                ('Maintainer info', self._check_maintainer_info()),
            ])

        return groups, warnings

    def collect_validation_errors(self, show_warnings: bool = False,
                                  check_sources: bool = False,
                                  strict: bool = False) -> tuple[list[str], list[str]]:
        """Collect all validation errors as a flat list.

        Convenience wrapper around collect_validation_report that flattens
        all group errors into a single list.

        Args:
            show_warnings: Passed through to collect_validation_report.
            check_sources: Passed through to collect_validation_report.
            strict: Passed through to collect_validation_report.

        Returns:
            A tuple of (errors, warnings) where errors is a flat list of all
            error strings across all check groups.
        """
        groups, warnings = self.collect_validation_report(
            show_warnings=show_warnings, check_sources=check_sources, strict=strict)
        errors: list[str] = [err for _, errs in groups for err in errs]
        return errors, warnings

    def validate_info(self,  # pylint: disable=too-many-locals,too-many-branches
                      check_md5: bool = False, fix: bool = False,
                      strict: bool = False, path: str | None = None) -> None:
        """Validate all package files and print a grouped check report.

        Optionally auto-fixes common issues before running validation. Prints a
        ✓/✗ summary per check group followed by all errors. Exits with code 1
        on failure, code 0 on success.

        Args:
            check_md5: If True, show detailed per-file source status (missing,
                untracked, checksum mismatch).
            fix: If True, run all auto-fixers before validating.
            strict: If True, include additional pre-submission checks.
            path: If given, validate this directory instead of current_folder.
        """
        if path:
            target: Path = Path(path).expanduser().resolve()
            if not target.is_dir():
                print(colorize(f"{PROG}: path not found: {target}", 'red'))
                raise SystemExit(1)
            self.ctx.current_folder = target
            self.ctx.prg_name = target.name
        self.files.read_info_file()
        if fix:
            self._fix_files()
        if fix and strict:
            self._fix_strict_files()
            self.files.read_info_file()
        if fix and self._fix_md5sums():
            self.files.read_info_file()
        groups, warnings = self.collect_validation_report(show_warnings=check_md5,
                                                          strict=strict)
        all_errors: list[str] = [err for _, errs in groups for err in errs]

        _utf8: bool = locale.getpreferredencoding(False).upper() in ('UTF-8', 'UTF8')
        print(f"Validation for '{self.ctx.prg_name}':\n")
        for label, errs in groups:
            icon: str = (colorize('✓ ' if _utf8 else 'OK  ', 'green')
                         if not errs else colorize('✗ ' if _utf8 else 'FAIL', 'red'))
            print(f"  {icon}  {label}")

        if all_errors:
            print(f"\n  {len(all_errors)} error(s) found:\n")
            for err in all_errors:
                print(f"  - {err}")
            if warnings:
                print()
            for warn in warnings:
                print(f"  {colorize('WARNING:', 'yellow')} {warn}")
            if not fix:
                tip: str = "'--fix' to auto-fix common issues"
                if not strict:
                    tip += "; '--strict --fix' to also correct --strict issues"
                print(f"\n  {colorize('Tip:', 'yellow')} pass {tip}.")
            elif not strict:
                print(f"\n  {colorize('Tip:', 'yellow')} pass '--strict --fix' to also correct --strict issues.")
            raise SystemExit(1)

        print(colorize('\n  All checks passed.', 'green'))
        for warn in warnings:
            print(f"  {colorize('Note:', 'yellow')} {warn}")
        raise SystemExit(0)

    def validate_all(self,  # pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-arguments,too-many-positional-arguments
                     check_md5: bool = False, fix: bool = False,
                     strict: bool = False, maintainer: str | None = None,
                     path: str | None = None) -> None:
        """Validate all packages in the local SBo repo belonging to the configured maintainer.

        Scans every .info file under sbo_repo_path (or path if given), filters by
        MAINTAINER= match, validates each package, and prints a one-line summary:

            network/myapp     OK
            python/mylib      2 errors, 1 warning

        Exits with code 1 if any package fails, code 0 if all pass.

        Args:
            check_md5: If True, include MD5 source checks.
            fix: If True, run auto-fixers on each package before validating.
            strict: If True, run additional pre-submission checks.
            maintainer: Override the configured maintainer name.
            path: Override the configured sbo_repo_path.
        """
        if path:
            repo_path: Path = Path(path).expanduser().resolve()
            if not repo_path.is_dir():
                print(colorize(f"{PROG}: path not found: {repo_path}", 'red'))
                raise SystemExit(1)
        else:
            repo_path_str: str = self.ctx.maintainer.sbo_repo_path
            if not repo_path_str:
                print(colorize(
                    f"{PROG}: SBo repo path not configured. Run '{PROG} maintainer'.", 'red'))
                raise SystemExit(1)
            repo_path = Path(repo_path_str).expanduser().resolve()
            if not repo_path.is_dir():
                print(colorize(f"{PROG}: SBo repo path not found: {repo_path}", 'red'))
                raise SystemExit(1)

        cfg_name: str = maintainer or self.ctx.maintainer.name
        if not cfg_name:
            print(colorize(
                f"{PROG}: Maintainer name not configured. Run '{PROG} maintainer'.", 'red'))
            raise SystemExit(1)

        packages: list[tuple[Path, str, str]] = []  # (pkg_dir, category, pkg_name)
        for info_file in sorted(repo_path.rglob('*.info')):
            try:
                content: str = info_file.read_text(encoding='utf-8')
            except OSError:
                continue
            for line in content.splitlines():
                if line.startswith('MAINTAINER='):
                    value: str = line.split('=', 1)[1].strip().strip('"')
                    if value == cfg_name:
                        category: str = info_file.parent.parent.name
                        packages.append((info_file.parent, category, info_file.stem))
                    break

        if not packages:
            print(colorize(
                f"{PROG}: No packages found for maintainer '{cfg_name}'.", 'red'))
            raise SystemExit(1)

        original_folder: Path = self.ctx.current_folder
        original_name: str = self.ctx.prg_name

        col_width: int = max(len(f'{cat}/{name}') for _, cat, name in packages)

        print(f"Packages for maintainer '{colorize(cfg_name, 'cyan')}':\n")

        n_ok: int = 0
        n_failed: int = 0
        n_with_warnings: int = 0
        for pkg_dir, cat, pkg_name in packages:
            self.ctx.current_folder = pkg_dir
            self.ctx.prg_name = pkg_name
            self.files.read_info_file()

            if fix:
                self._fix_files()
            if fix and strict:
                self._fix_strict_files()
                self.files.read_info_file()
            if fix and self._fix_md5sums():
                self.files.read_info_file()

            groups, warnings = self.collect_validation_report(
                show_warnings=check_md5, strict=strict)
            n_errors: int = sum(len(errs) for _, errs in groups)
            n_warnings: int = len(warnings)

            label: str = f'{cat}/{pkg_name}'
            if n_errors == 0:
                n_ok += 1
                parts: list[str] = [colorize('OK', 'green')]
                if n_warnings:
                    n_with_warnings += 1
                    parts.append(colorize(f'{n_warnings} warning(s)', 'yellow'))
                status: str = ', '.join(parts)
            else:
                n_failed += 1
                if n_warnings:
                    n_with_warnings += 1
                parts = [colorize(f'{n_errors} error(s)', 'red')]
                if n_warnings:
                    parts.append(colorize(f'{n_warnings} warning(s)', 'yellow'))
                status = ', '.join(parts)

            print(f"  {label:<{col_width}}  {status}")

        self.ctx.current_folder = original_folder
        self.ctx.prg_name = original_name

        total: int = len(packages)
        summary_parts: list[str] = [
            f"{total} scanned",
            colorize(f"{n_ok} OK", 'green'),
        ]
        if n_failed:
            summary_parts.append(colorize(f"{n_failed} failed", 'red'))
        if n_with_warnings:
            summary_parts.append(colorize(f"{n_with_warnings} with warnings", 'yellow'))
        print(f"\n  {' · '.join(summary_parts)}")

        raise SystemExit(1 if n_failed else 0)
