#!/usr/bin/python3
# -*- coding: utf-8 -*-

from __future__ import annotations

import difflib
import re
from pathlib import Path

from sbo_create.handlers.files import FileHandler
from sbo_create.models import (SOURCE_EXTENSIONS, UNSUPPORTED, AppContext,
                               colorize, confirm)


class FixHandler:  # pylint: disable=too-few-public-methods
    """Auto-fix common SlackBuild package file issues.

    Provides fixers for trailing whitespace, bad quotes, field-name corruption,
    executable bit, slack-desc prefix/ruler and MD5SUM mismatches.
    """

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

    def _fix_file(self, filepath: Path, fix_newline: bool = False) -> tuple[bool, bool]:
        """Auto-fix trailing whitespace in a file, optionally fixing the final newline.

        Strips trailing whitespace from each line while preserving backslash
        continuations. Optionally ensures the file ends with exactly one newline,
        stripping any extra trailing blank lines (for README).

        Args:
            filepath: Path to the file to fix.
            fix_newline: If True, ensure the file ends with exactly '\\n' (strips
                extra trailing blank lines).

        Returns:
            A tuple of (ws_fixed, newline_fixed) — True for each fix that was applied.
        """
        original: str = filepath.read_text(encoding='utf-8')
        lines: list[str] = original.splitlines()

        fixed_lines: list[str] = []
        ws_fixed: int = 0
        for line in lines:
            if line.endswith('\\'):
                stripped: str = line.rstrip()[:-1].rstrip() + ' \\'
            else:
                stripped = line.rstrip()
            if stripped != line:
                ws_fixed += 1
            fixed_lines.append(stripped)

        content: str = '\n'.join(fixed_lines)
        # splitlines() strips trailing newlines — restore them so the check is accurate
        if original.endswith('\n'):
            content += '\n'

        newline_fixed: bool = False
        if fix_newline:
            correct: str = content.rstrip('\n') + '\n'
            newline_fixed = content != correct
            if newline_fixed:
                content = correct

        if ws_fixed or newline_fixed:
            filepath.write_text(content, encoding='utf-8')

        return bool(ws_fixed), newline_fixed

    def _fix_info_quotes(self, filepath: Path) -> bool:
        """Ensure all values in the .info file are properly double-quoted.

        Handles single-line, empty and multi-line (backslash-continuation) values.
        Adds missing opening or closing quotes where needed.

        Args:
            filepath: Path to the .info file to fix.

        Returns:
            True if any fix was applied, False otherwise.
        """
        lines: list[str] = filepath.read_text(encoding='utf-8').splitlines(keepends=True)
        fixed_lines: list[str] = []
        changed: bool = False
        in_multiline: bool = False

        for line in lines:
            eol: str = '\n' if line.endswith('\n') else ''
            body: str = line.rstrip('\n').rstrip()

            if in_multiline:
                if body.endswith('\\'):
                    pass  # continuation
                elif body.endswith('"'):
                    in_multiline = False  # properly closed
                else:
                    line = f'{body}"{eol}'  # add missing closing quote
                    changed = True
                    in_multiline = False
            elif '=' in line and not line.strip().startswith('#'):
                key, sep, rest = line.partition('=')
                if re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', key.strip()):
                    stripped: str = rest.rstrip('\n').strip()
                    if stripped.endswith('\\'):
                        in_multiline = True
                        if not stripped.startswith('"'):
                            line = f'{key}{sep}"{stripped}{eol}'
                            changed = True
                    else:
                        clean: str = stripped.strip('"')
                        quoted: str = f'"{clean}"'
                        if stripped != quoted:
                            line = f'{key}{sep}{quoted}{eol}'
                            changed = True

            fixed_lines.append(line)

        if changed:
            filepath.write_text(''.join(fixed_lines), encoding='utf-8')
        return changed

    def _fix_slack_desc_prefix(self, filepath: Path) -> bool:
        """Fix description lines whose prefix does not match the package name.

        Replaces the wrong prefix before ':' with the correct package name and
        normalises spacing after the colon (exactly one space required).

        Args:
            filepath: Path to the slack-desc file to fix.

        Returns:
            True if any line was changed, False otherwise.
        """
        lines: list[str] = filepath.read_text(encoding='utf-8').splitlines(keepends=True)
        name: str = self.ctx.prg_name
        changed: bool = False
        for i in range(8, min(19, len(lines))):
            body: str = lines[i].rstrip('\n')
            if not body.startswith(f'{name}:'):
                colon: int = body.find(':')
                if colon >= 0:
                    body = f'{name}{body[colon:]}'
                    changed = True
                elif body.startswith(name):
                    body = f'{name}:{body[len(name):]}'
                    changed = True
                else:
                    body = f'{name}: {body.lstrip()}'
                    changed = True
            # Normalize spacing after name:
            rest: str = body[len(name) + 1:]
            if rest and not rest.startswith(' '):
                body = f'{name}: {rest.lstrip()}'
                changed = True
            elif rest.startswith('  '):
                body = f'{name}: {rest.lstrip()}'
                changed = True
            lines[i] = body + '\n'
        if changed:
            filepath.write_text(''.join(lines), encoding='utf-8')
        return changed

    def _fix_slack_desc_ruler(self, filepath: Path) -> bool:
        """Fix the handy-ruler line in slack-desc to match the package name length.

        The ruler is on line 8 (index 7) and must be padded with spaces equal to
        the length of the package name followed by the standard ruler pattern.

        Args:
            filepath: Path to the slack-desc file to fix.

        Returns:
            True if the ruler was changed, False if it was already correct.
        """
        lines: list[str] = filepath.read_text(encoding='utf-8').splitlines(keepends=True)
        if len(lines) <= 7:
            return False
        name: str = self.ctx.prg_name
        expected: str = ' ' * len(name) + '|-----handy-ruler' + '-' * 54 + '|'
        eol: str = '\n' if lines[7].endswith('\n') else ''
        if lines[7].rstrip('\n') == expected:
            return False
        lines[7] = expected + eol
        filepath.write_text(''.join(lines), encoding='utf-8')
        return True

    def _fix_slackbuild_executable(self, filepath: Path) -> bool:
        """Remove the executable bit from the SlackBuild file.

        SBo policy requires that .SlackBuild files are not executable.

        Args:
            filepath: Path to the .SlackBuild file.

        Returns:
            True if the executable bit was removed, False if it was already clear.
        """
        mode: int = filepath.stat().st_mode
        if not mode & 0o111:
            return False
        filepath.chmod(mode & ~0o111)
        return True

    def _fix_info_variables(self, filepath: Path) -> dict[str, bool]:  # pylint: disable=too-many-locals,too-many-branches
        """Parse .info fields, fuzzy-match corrupted names and rewrite all fields in order.

        Extracts all key=value pairs (including multi-line ones), maps them to the
        canonical field list using exact then fuzzy matching (difflib), corrects
        PRGNAM to the current package name, and rewrites the file.

        Args:
            filepath: Path to the .info file to fix.

        Returns:
            A dict mapping each canonical field name to True if the field was
            renamed or its value changed, False if it was already correct.
        """
        content: str = filepath.read_text(encoding='utf-8')

        # Step 1: extract all key=value pairs (preserving multi-line values)
        parsed: dict[str, str] = {}
        current_key: str = ''
        current_lines: list[str] = []
        for line in content.splitlines():
            if re.match(r'^[A-Za-z_][A-Za-z0-9_]*=', line):
                if current_key:
                    parsed[current_key] = '\n'.join(current_lines)
                current_key, _, rest = line.partition('=')
                current_lines = [rest]
            elif current_key:
                current_lines.append(line)
        if current_key:
            parsed[current_key] = '\n'.join(current_lines)

        # Step 2: determine field order (x86_64 or ARM64)
        is_arm: bool = any(k in parsed for k in ('DOWNLOAD_ARM64', 'MD5SUM_ARM64'))
        dl_arch: str = 'DOWNLOAD_ARM64' if is_arm else 'DOWNLOAD_x86_64'
        md_arch: str = 'MD5SUM_ARM64' if is_arm else 'MD5SUM_x86_64'
        known: list[str] = [
            'PRGNAM', 'VERSION', 'HOMEPAGE', 'DOWNLOAD', 'MD5SUM',
            dl_arch, md_arch, 'REQUIRES', 'MAINTAINER', 'EMAIL',
        ]

        # Step 3: map parsed keys to known fields (exact then fuzzy)
        field_values: dict[str, str] = {}
        matched_keys: dict[str, str] = {}  # known_field → parsed_key used
        for field in known:
            if field in parsed:
                field_values[field] = parsed[field]
                matched_keys[field] = field
            else:
                close = difflib.get_close_matches(field, list(parsed.keys()), n=1, cutoff=0.6)
                if close:
                    field_values[field] = parsed[close[0]]
                    matched_keys[field] = close[0]
                else:
                    field_values[field] = '""'
                    matched_keys[field] = ''

        # Step 4: apply derivable corrections (PRGNAM only)
        original_values: dict[str, str] = dict(field_values)
        field_values['PRGNAM'] = f'"{self.ctx.prg_name}"'

        # Step 5: reconstruct and write
        new_content: str = '\n'.join(f'{f}={field_values[f]}' for f in known) + '\n'
        if new_content != content:
            filepath.write_text(new_content, encoding='utf-8')

        # Step 6: report what changed (key renamed or value changed)
        fixed: dict[str, bool] = {
            f: (matched_keys[f] != f or field_values[f] != original_values[f])
            for f in known
        }
        return fixed

    def _fix_version_mismatch(self) -> bool:
        """Sync the VERSION default in .SlackBuild to match the .info file.

        If the ${VERSION:-x.y.z} default in .SlackBuild differs from VERSION
        in .info, overwrites it with the .info value.

        Returns:
            True if the .SlackBuild was updated, False otherwise.
        """
        name: str = self.ctx.prg_name
        slackbuild: Path = Path(self.ctx.current_folder, f'{name}.SlackBuild')
        if not slackbuild.is_file() or not self.ctx.info.version:
            return False
        content: str = slackbuild.read_text(encoding='utf-8')
        new_content: str = re.sub(
            r'\$\{VERSION:-[^}]+\}', f'${{VERSION:-{self.ctx.info.version}}}', content)
        if new_content == content:
            return False
        slackbuild.write_text(new_content, encoding='utf-8')
        return True

    def _fix_copyright_year(self) -> bool:
        """Update the copyright end-year in .SlackBuild to the current year.

        Matches lines of the form:
          # Copyright YYYY-YYYY  <maintainer name>  ...  → updates end year
          # Copyright YYYY  <maintainer name>  ...       → updates the single year

        Only updates the line when ctx.maintainer.name is set, appears on the
        copyright line, the format is one of the two patterns above, and the
        year found is less than the current year.

        Returns:
            True if the .SlackBuild was updated, False otherwise.
        """
        maintainer: str = self.ctx.maintainer.name
        if not maintainer:
            return False
        slackbuild: Path = Path(self.ctx.current_folder, f'{self.ctx.prg_name}.SlackBuild')
        if not slackbuild.is_file():
            return False

        current_year: int = self.ctx.year
        escaped: str = re.escape(maintainer)
        content: str = slackbuild.read_text(encoding='utf-8')

        def _update_range(m: re.Match[str]) -> str:
            if int(m.group('end')) >= current_year:
                return m.group(0)
            return m.group('pre') + str(current_year) + m.group('tail')

        def _update_single(m: re.Match[str]) -> str:
            if int(m.group('yr')) >= current_year:
                return m.group(0)
            return m.group('pre') + str(current_year) + m.group('tail')

        new_content: str = re.sub(
            rf'(?P<pre># Copyright \d{{4}}-)(?P<end>\d{{4}})(?P<tail>[^\S\n]+{escaped})',
            _update_range, content,
        )
        # Single-year: only matches when followed by whitespace (not '-'), so
        # range lines like '# Copyright 2014-2026  Name' are not affected.
        new_content = re.sub(
            rf'(?P<pre># Copyright )(?P<yr>\d{{4}})(?P<tail>[^\S\n]+{escaped})',
            _update_single, new_content,
        )
        if new_content == content:
            return False
        slackbuild.write_text(new_content, encoding='utf-8')
        return True

    def _fix_homepage_http(self) -> bool:
        """Replace http:// with https:// in HOMEPAGE field of the .info file.

        Returns:
            True if the .info file was updated, False otherwise.
        """
        name: str = self.ctx.prg_name
        info_file: Path = Path(self.ctx.current_folder, f'{name}.info')
        if not info_file.is_file():
            return False
        content: str = info_file.read_text(encoding='utf-8')
        new_content: str = re.sub(
            r'(HOMEPAGE="?)http://', r'\1https://', content)
        if new_content == content:
            return False
        info_file.write_text(new_content, encoding='utf-8')
        return True

    def _fix_slack_desc_homepage_http(self) -> bool:
        """Replace http:// with https:// in the Homepage: line of slack-desc.

        Returns:
            True if slack-desc was updated, False otherwise.
        """
        name: str = self.ctx.prg_name
        slack_desc: Path = Path(self.ctx.current_folder, 'slack-desc')
        if not slack_desc.is_file():
            return False
        prefix: str = f'{name}:'
        content: str = slack_desc.read_text(encoding='utf-8')
        new_content: str = re.sub(
            rf'(?m)^({re.escape(prefix)}\s*[Hh]omepage:\s*)http://',
            r'\1https://',
            content,
        )
        if new_content == content:
            return False
        slack_desc.write_text(new_content, encoding='utf-8')
        return True

    def _fix_maintainer_info(self) -> bool:
        """Replace MAINTAINER and EMAIL in .info with the configured maintainer values.

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

        Returns:
            True if the .info file was updated, False otherwise.
        """
        name: str = self.ctx.maintainer.name
        email: str = self.ctx.maintainer.email
        if not name or not email:
            return False
        info_file: Path = Path(self.ctx.current_folder, f'{self.ctx.prg_name}.info')
        if not info_file.is_file():
            return False
        content: str = info_file.read_text(encoding='utf-8')
        new_content: str = re.sub(r'MAINTAINER="[^"]*"', f'MAINTAINER="{name}"', content)
        new_content = re.sub(r'EMAIL="[^"]*"', f'EMAIL="{email}"', new_content)
        if new_content == content:
            return False
        info_file.write_text(new_content, encoding='utf-8')
        return True

    def _fix_sources(self) -> bool:
        """Remove source tarballs from the package directory after user confirmation.

        Returns:
            True if at least one file was removed, False otherwise.
        """
        tarballs: list[Path] = sorted(
            f for f in self.ctx.current_folder.iterdir()
            if f.is_file() and f.name.endswith(SOURCE_EXTENSIONS)
        )
        if not tarballs:
            return False
        print(f"Source tarballs to remove ({len(tarballs)}):\n")
        for f in tarballs:
            print(f"  {f.name}")
        if not confirm(f"Remove {colorize(str(len(tarballs)), 'red')} file(s)?", before_newline=True, after_newline=True):
            return False
        for f in tarballs:
            f.unlink()
            print(f"  {colorize('REMOVED', 'red')}  {f.name}")
        print()
        return True

    def _fix_files(self) -> None:  # pylint: disable=too-many-locals
        """Auto-fix trailing whitespace, quotes, newlines and field names in package files.

        Iterates over all submission files (.info, README, .SlackBuild, slack-desc)
        and applies non-strict fixers: whitespace stripping, quote normalisation,
        field-name correction (for .info), VERSION sync, copyright year (.SlackBuild),
        and prefix/ruler repair (slack-desc). Prints a per-file status report.

        Strict-only fixers (executable bit, HOMEPAGE http→https, source tarballs,
        MAINTAINER/EMAIL) are handled separately by _fix_strict_files.
        """
        name: str = self.ctx.prg_name
        folder: Path = self.ctx.current_folder
        candidates: list[tuple[str, bool]] = [
            (f'{name}.info', True),
            ('README', True),
            (f'{name}.SlackBuild', True),
            ('slack-desc', True),
        ]
        width: int = max(
            (len(filename)
             for filename, _ in candidates
             if Path(folder, filename).is_file()),
            default=0,
        )
        print("Fixing files:\n")
        for filename, fix_nl in candidates:
            path: Path = Path(folder, filename)
            if not path.is_file():
                continue
            ws_fixed, nl_fixed = self._fix_file(path, fix_newline=fix_nl)
            ws_status = colorize('FIXED', 'green') if ws_fixed else colorize('OK', 'green')
            label: str = f"{filename + ':':<{width + 1}}"
            cw: int = 21
            print(f"  {label} {'trailing whitespace':<{cw}} {ws_status}")
            if fix_nl:
                nl_status = colorize('FIXED', 'green') if nl_fixed else colorize('OK', 'green')
                print(f"  {label} {'final newline':<{cw}} {nl_status}")
            if filename.endswith('.info'):
                q_fixed = self._fix_info_quotes(path)
                q_status = colorize('FIXED', 'green') if q_fixed else colorize('OK', 'green')
                print(f"  {label} {'quotes':<{cw}} {q_status}")
                for field, was_fixed in self._fix_info_variables(path).items():
                    status = colorize('FIXED', 'green') if was_fixed else colorize('OK', 'green')
                    print(f"  {label} {field:<{cw}} {status}")
            if filename.endswith('.SlackBuild'):
                v_fixed = self._fix_version_mismatch()
                v_status = colorize('FIXED', 'green') if v_fixed else colorize('OK', 'green')
                print(f"  {label} {'VERSION':<{cw}} {v_status}")
                y_fixed = self._fix_copyright_year()
                y_status = colorize('FIXED', 'green') if y_fixed else colorize('OK', 'green')
                print(f"  {label} {'copyright year':<{cw}} {y_status}")
            if filename == 'slack-desc':
                px_fixed = self._fix_slack_desc_prefix(path)
                px_status = colorize('FIXED', 'green') if px_fixed else colorize('OK', 'green')
                print(f"  {label} {'line prefix':<{cw}} {px_status}")
                r_fixed = self._fix_slack_desc_ruler(path)
                r_status = colorize('FIXED', 'green') if r_fixed else colorize('OK', 'green')
                print(f"  {label} {'handy-ruler':<{cw}} {r_status}")
        print()

    def _fix_strict_files(self) -> None:  # pylint: disable=too-many-locals
        """Auto-fix strict-only issues: HOMEPAGE scheme, executable bit, source tarballs.

        Runs the fixers that correspond to --strict validation checks.
        Called only when both --fix and --strict are active.
        Prints a per-file status report in the same format as _fix_files.
        """
        name: str = self.ctx.prg_name
        folder: Path = self.ctx.current_folder
        info_path: Path = Path(folder, f'{name}.info')
        sb_path: Path = Path(folder, f'{name}.SlackBuild')
        slack_desc_path: Path = Path(folder, 'slack-desc')
        candidates: list[str] = [
            f'{name}.info',
            f'{name}.SlackBuild',
            'slack-desc',
        ]
        width: int = max(
            (len(f) for f in candidates
             if Path(folder, f).is_file()),
            default=0,
        )
        cw: int = 21
        print("Fixing strict issues:\n")
        if info_path.is_file():
            label: str = f"{f'{name}.info:':<{width + 1}}"
            h_fixed = self._fix_homepage_http()
            h_status = colorize('FIXED', 'green') if h_fixed else colorize('OK', 'green')
            print(f"  {label} {'HOMEPAGE (http→https)':<{cw}} {h_status}")
            m_fixed = self._fix_maintainer_info()
            m_status = colorize('FIXED', 'green') if m_fixed else colorize('OK', 'green')
            print(f"  {label} {'MAINTAINER/EMAIL':<{cw}} {m_status}")
        if sb_path.is_file():
            label = f"{f'{name}.SlackBuild:':<{width + 1}}"
            x_fixed = self._fix_slackbuild_executable(sb_path)
            x_status = colorize('FIXED', 'green') if x_fixed else colorize('OK', 'green')
            print(f"  {label} {'executable bit':<{cw}} {x_status}")
        if slack_desc_path.is_file():
            label = f"{'slack-desc:':<{width + 1}}"
            sd_fixed = self._fix_slack_desc_homepage_http()
            sd_status = colorize('FIXED', 'green') if sd_fixed else colorize('OK', 'green')
            print(f"  {label} {'Homepage (http→https)':<{cw}} {sd_status}")
        print()
        self._fix_sources()

    def _collect_md5_mismatches(self) -> list[tuple[str, str, str]]:
        """Find local source files whose actual MD5 differs from the .info value.

        Returns:
            List of (filename, expected_md5, actual_md5) tuples for each mismatch.
        """
        mismatches: list[tuple[str, str, str]] = []
        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]
                actual: str = self.files.source_check_sum(filename)
                if actual and actual != md5:
                    mismatches.append((filename, md5, actual))
        return mismatches

    def _fix_md5sums(self) -> bool:
        """Recompute MD5SUMs in .info from local source files if mismatches are found.

        Displays the mismatches and prompts the user before applying any changes.

        Returns:
            True if the .info file was updated, False if there were no mismatches
            or the user declined.
        """
        mismatches = self._collect_md5_mismatches()
        if not mismatches:
            return False

        print("MD5SUM mismatches found (local sources present):\n")
        for filename, old_md5, new_md5 in mismatches:
            print(f"  {colorize(filename, 'yellow')}:")
            print(f"    {old_md5} -> {new_md5}")
        print()

        if not confirm("Recompute MD5SUMs from local sources?", after_newline=True, before_newline=False):
            return False

        info_path: Path = Path(self.ctx.current_folder, f'{self.ctx.prg_name}.info')
        content: str = info_path.read_text(encoding='utf-8')
        for _, old_md5, new_md5 in mismatches:
            content = content.replace(old_md5, new_md5)
        info_path.write_text(content, encoding='utf-8')

        info_name: str = f'{self.ctx.prg_name}.info:'
        for filename, _, _ in mismatches:
            print(f"  {info_name} MD5SUM ({filename})  {colorize('FIXED', 'green')}")
        print()
        return True
