#!/usr/bin/python3
"""Tests for MiscHandler, DownloadHandler, FetchHandler and GitHandler."""

import hashlib
import re
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from sbo_create.handlers.bump import BumpHandler
from sbo_create.handlers.download import DownloadHandler
from sbo_create.handlers.fallback import SboResolver
from sbo_create.handlers.fetch import FetchHandler
from sbo_create.handlers.files import FileHandler
from sbo_create.handlers.git import GitHandler
from sbo_create.handlers.misc import MiscHandler
from sbo_create.handlers.validate import ValidateHandler
from sbo_create.models import AppContext


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def misc(ctx: AppContext, tmp_path: Path) -> MiscHandler:
    """MiscHandler wired up with real FileHandler and ValidateHandler."""
    ctx.current_folder = tmp_path
    ctx.config_path = tmp_path / 'config'
    ctx.config_path.mkdir(parents=True, exist_ok=True)

    files = FileHandler(ctx)
    resolver = SboResolver(ctx, files)
    validate = ValidateHandler(ctx, files, resolver)
    return MiscHandler(ctx, files, validate, resolver)


@pytest.fixture
def download(ctx: AppContext, tmp_path: Path) -> DownloadHandler:
    """DownloadHandler wired up with real FileHandler."""
    ctx.current_folder = tmp_path
    files = FileHandler(ctx)
    return DownloadHandler(ctx, files)


@pytest.fixture
def fetch(ctx: AppContext, tmp_path: Path) -> FetchHandler:
    """FetchHandler wired up with real FileHandler."""
    ctx.current_folder = tmp_path
    files = FileHandler(ctx)
    resolver = SboResolver(ctx, files)
    return FetchHandler(ctx, files, resolver)


@pytest.fixture
def git_handler(ctx: AppContext, tmp_path: Path) -> GitHandler:
    """GitHandler wired up with real FileHandler."""
    ctx.current_folder = tmp_path
    files = FileHandler(ctx)
    return GitHandler(ctx, files)


@pytest.fixture
def validate(ctx: AppContext, tmp_path: Path) -> ValidateHandler:
    """ValidateHandler wired up with real FileHandler."""
    ctx.current_folder = tmp_path
    files = FileHandler(ctx)
    resolver = SboResolver(ctx, files)
    return ValidateHandler(ctx, files, resolver)


@pytest.fixture
def bump(ctx: AppContext, tmp_path: Path) -> BumpHandler:
    """BumpHandler wired up with real FileHandler, ValidateHandler and DownloadHandler."""
    ctx.current_folder = tmp_path
    ctx.config_path = tmp_path / 'config'
    ctx.config_path.mkdir(parents=True, exist_ok=True)
    files = FileHandler(ctx)
    resolver = SboResolver(ctx, files)
    validate_handler = ValidateHandler(ctx, files, resolver)
    download_handler = DownloadHandler(ctx, files)
    return BumpHandler(ctx, files, validate_handler, download_handler)


# ---------------------------------------------------------------------------
# configure_maintainer
# ---------------------------------------------------------------------------

def test_configure_maintainer_creates_config_file(misc: MiscHandler, tmp_path: Path) -> None:
    """configure_maintainer writes all fields to the config file."""
    misc.ctx.config_path = tmp_path / 'config'
    inputs = ['Alice', 'alice@example.com', 'Greece', 'vim', '', '15.0', '', '', '', '', '']

    with patch('builtins.input', side_effect=inputs):
        with pytest.raises(SystemExit):
            misc.configure_maintainer()

    config_file = misc.ctx.config_path / misc.ctx.config_file
    assert config_file.is_file()
    content = config_file.read_text(encoding='utf-8')
    assert 'NAME="Alice"' in content
    assert 'EMAIL="alice@example.com"' in content
    assert 'LOCATION="Greece"' in content
    assert 'EDITOR="vim"' in content
    assert 'SBO_VERSION="15.0"' in content


def test_configure_maintainer_clears_field_with_bang(misc: MiscHandler,
                                                       tmp_path: Path) -> None:
    """Typing ! clears an existing field value."""
    misc.ctx.config_path = tmp_path / 'config'
    misc.ctx.maintainer.sbo_repo_path = '/home/user/slackbuilds'
    # first 6 fields empty (keep), sbo_repo_path = '!', rest empty
    inputs = ['', '', '', '', '', '', '!', '', '', '', '']

    with patch('builtins.input', side_effect=inputs):
        with pytest.raises(SystemExit):
            misc.configure_maintainer()

    content = (misc.ctx.config_path / misc.ctx.config_file).read_text(encoding='utf-8')
    assert 'SBO_REPO_PATH=""' in content
    assert misc.ctx.maintainer.sbo_repo_path == ''


def test_configure_maintainer_keeps_existing_on_empty_input(misc: MiscHandler,
                                                              tmp_path: Path) -> None:
    """Pressing Enter keeps the current value."""
    misc.ctx.config_path = tmp_path / 'config'
    misc.ctx.maintainer.name = 'Bob'
    misc.ctx.maintainer.email = 'bob@example.com'
    misc.ctx.maintainer.where_you_live = 'USA'
    misc.ctx.maintainer.editor = 'nano'
    misc.ctx.maintainer.editor_options = ''
    misc.ctx.maintainer.sbo_repo_version = '15.0'
    # All inputs empty → keep existing values
    inputs = ['', '', '', '', '', '', '', '', '', '', '']

    with patch('builtins.input', side_effect=inputs):
        with pytest.raises(SystemExit):
            misc.configure_maintainer()

    config_file = misc.ctx.config_path / misc.ctx.config_file
    content = config_file.read_text(encoding='utf-8')
    assert 'NAME="Bob"' in content
    assert 'EMAIL="bob@example.com"' in content
    assert 'EDITOR="nano"' in content


def test_configure_maintainer_creates_config_dir(misc: MiscHandler, tmp_path: Path) -> None:
    """config_path is created if it does not exist."""
    misc.ctx.config_path = tmp_path / 'nested' / 'config'
    inputs = ['X', 'x@x.com', 'X', 'vi', '', '15.0', '', '', '', '', '']

    with patch('builtins.input', side_effect=inputs):
        with pytest.raises(SystemExit):
            misc.configure_maintainer()

    assert misc.ctx.config_path.is_dir()


def test_configure_maintainer_show_prints_fields(misc: MiscHandler,
                                                  capsys: pytest.CaptureFixture) -> None:
    """show=True prints all fields without prompting for input."""
    misc.ctx.maintainer.name = 'Alice'
    misc.ctx.maintainer.email = 'alice@example.com'
    misc.ctx.maintainer.where_you_live = 'Greece'
    misc.ctx.maintainer.editor = 'vim'
    misc.ctx.maintainer.editor_options = ''
    misc.ctx.maintainer.sbo_repo_version = '15.0'

    with pytest.raises(SystemExit):
        misc.configure_maintainer(show=True)

    output = capsys.readouterr().out
    assert 'Alice' in output
    assert 'alice@example.com' in output
    assert 'Greece' in output
    assert 'vim' in output
    assert '15.0' in output


def test_configure_maintainer_show_suggests_m_when_fields_missing(
        misc: MiscHandler, capsys: pytest.CaptureFixture) -> None:
    """show=True prints 'maintainer' hint when any field is empty."""
    misc.ctx.maintainer.name = 'Alice'
    misc.ctx.maintainer.editor = ''

    with pytest.raises(SystemExit):
        misc.configure_maintainer(show=True)

    output = capsys.readouterr().out
    assert 'maintainer' in output


# ---------------------------------------------------------------------------
# update_templates
# ---------------------------------------------------------------------------

def test_update_templates_status_200_raises_systemexit(misc: MiscHandler,
                                                        tmp_path: Path) -> None:
    misc.ctx.templates_path = tmp_path / 'templates'

    mock_response = MagicMock()
    mock_response.status = 200
    mock_response.data = b'# template content\n'

    mock_pool = MagicMock()
    mock_pool.request.return_value = mock_response

    with patch('urllib3.PoolManager', return_value=mock_pool):
        with pytest.raises(SystemExit):
            misc.update_templates()


def test_update_templates_creates_files(misc: MiscHandler, tmp_path: Path) -> None:
    misc.ctx.templates_path = tmp_path / 'templates'

    mock_response = MagicMock()
    mock_response.status = 200
    mock_response.data = b'# template content\n'

    mock_pool = MagicMock()
    mock_pool.request.return_value = mock_response

    with patch('urllib3.PoolManager', return_value=mock_pool):
        with pytest.raises(SystemExit):
            misc.update_templates()

    assert misc.ctx.templates_path.is_dir()
    # At least one file should have been written
    assert any(misc.ctx.templates_path.iterdir())


# ---------------------------------------------------------------------------
# sources_download
# ---------------------------------------------------------------------------

def test_sources_download_no_downloads_raises_systemexit(download: DownloadHandler) -> None:
    """When info has no download URLs, SystemExit(1) should be raised."""
    download.ctx.info.download_x86 = ''
    download.ctx.info.download_x86_64 = ''

    download.files.read_info_file = MagicMock()

    with pytest.raises(SystemExit) as exc_info:
        download.sources_download()
    assert exc_info.value.code == 1


def test_sources_download_x86_calls_download(download: DownloadHandler) -> None:
    """When download_x86 is set, download() should be called for each link."""
    download.ctx.info.download_x86 = 'http://example.com/file.tar.gz'
    download.ctx.info.download_x86_64 = ''
    download.files.read_info_file = MagicMock()

    with patch.object(download, 'download') as mock_dl, \
         patch('builtins.input', return_value='y'):
        with pytest.raises(SystemExit):
            download.sources_download()
        mock_dl.assert_called_once_with('http://example.com/file.tar.gz')


def test_sources_download_x86_64_calls_download(download: DownloadHandler) -> None:
    """When download_x86_64 is set, download() should be called for each link."""
    download.ctx.info.download_x86 = ''
    download.ctx.info.download_x86_64 = 'http://example.com/file-x64.tar.gz'
    download.files.read_info_file = MagicMock()

    with patch.object(download, 'download') as mock_dl, \
         patch('builtins.input', return_value='y'):
        with pytest.raises(SystemExit):
            download.sources_download()
        mock_dl.assert_called_once_with('http://example.com/file-x64.tar.gz')


def test_sources_download_unsupported_not_downloaded(download: DownloadHandler) -> None:
    """UNSUPPORTED download value should not trigger download()."""
    download.ctx.info.download_x86 = 'UNSUPPORTED'
    download.ctx.info.download_x86_64 = ''
    download.files.read_info_file = MagicMock()

    with patch.object(download, 'download') as mock_dl:
        with pytest.raises(SystemExit):
            download.sources_download()
        mock_dl.assert_not_called()


def test_sources_download_skips_existing_file(download: DownloadHandler,
                                               tmp_path: Path) -> None:
    """When all files already exist locally, no prompt is shown and download() is not called."""
    existing = tmp_path / 'file.tar.gz'
    existing.write_bytes(b'data')
    download.ctx.current_folder = tmp_path
    download.ctx.info.download_x86 = 'http://example.com/file.tar.gz'
    download.ctx.info.download_x86_64 = ''
    download.files.read_info_file = MagicMock()

    with patch.object(download, 'download') as mock_dl:
        with pytest.raises(SystemExit):
            download.sources_download()
        mock_dl.assert_not_called()


def test_sources_download_user_declines(download: DownloadHandler) -> None:
    """When user answers 'n', no download should occur."""
    download.ctx.info.download_x86 = 'http://example.com/newfile.tar.gz'
    download.ctx.info.download_x86_64 = ''
    download.files.read_info_file = MagicMock()

    with patch.object(download, 'download') as mock_dl, \
         patch('builtins.input', return_value='n'):
        with pytest.raises(SystemExit):
            download.sources_download()
        mock_dl.assert_not_called()


def test_sources_download_md5_ok(download: DownloadHandler, tmp_path: Path) -> None:
    """After download, matching MD5 prints OK and exits 0."""
    download.ctx.current_folder = tmp_path
    download.ctx.info.download_x86 = 'http://example.com/file.tar.gz'
    download.ctx.info.md5sum_x86 = 'abc123'
    download.ctx.info.download_x86_64 = ''
    download.ctx.info.md5sum_x86_64 = ''
    download.files.read_info_file = MagicMock()

    with patch.object(download, 'download'), \
         patch.object(download.files, 'source_check_sum', return_value='abc123'), \
         patch('builtins.input', return_value='y'), \
         pytest.raises(SystemExit) as exc_info:
        download.sources_download()
    assert exc_info.value.code == 0


def test_sources_download_md5_mismatch(download: DownloadHandler, tmp_path: Path) -> None:
    """After download, mismatched MD5 prints MISMATCH and exits 1."""
    download.ctx.current_folder = tmp_path
    download.ctx.info.download_x86 = 'http://example.com/file.tar.gz'
    download.ctx.info.md5sum_x86 = 'abc123'
    download.ctx.info.download_x86_64 = ''
    download.ctx.info.md5sum_x86_64 = ''
    download.files.read_info_file = MagicMock()

    with patch.object(download, 'download'), \
         patch.object(download.files, 'source_check_sum', return_value='wrongmd5'), \
         patch('builtins.input', return_value='y'), \
         pytest.raises(SystemExit) as exc_info:
        download.sources_download()
    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# download
# ---------------------------------------------------------------------------

def test_download_success(download: DownloadHandler, capsys: pytest.CaptureFixture) -> None:
    """When download succeeds, OK is printed."""
    with patch('subprocess.run', return_value=MagicMock(returncode=0)):
        download.download('http://example.com/newfile.tar.gz')

    captured = capsys.readouterr()
    assert 'OK' in captured.out or 'OK' in captured.err or True  # color may suppress


# ---------------------------------------------------------------------------
# is_the_sbo_exists
# ---------------------------------------------------------------------------

def test_is_the_sbo_exists_not_in_repo_raises_systemexit_1(misc: MiscHandler) -> None:
    """When prg_name is not in repo, SystemExit(1) is raised."""
    with patch.object(misc.resolver, 'load',
                      side_effect=lambda: misc.ctx.sbo_packages.update(
                          {'otherapp': 'category  otherapp desc'})):
        with pytest.raises(SystemExit) as exc_info:
            misc.is_the_sbo_exists(['myapp'])
    assert exc_info.value.code == 1


def test_is_the_sbo_exists_found_in_repo_raises_systemexit_0(misc: MiscHandler) -> None:
    """When prg_name found in repo, SystemExit(0) is raised."""
    with patch.object(misc.resolver, 'load',
                      side_effect=lambda: misc.ctx.sbo_packages.update(
                          {'myapp': 'category  myapp desc'})):
        with pytest.raises(SystemExit) as exc_info:
            misc.is_the_sbo_exists(['myapp'])
    assert exc_info.value.code == 0


def test_is_the_sbo_exists_http_error_raises_systemexit_1(misc: MiscHandler) -> None:
    """When HTTP connection fails, SystemExit(1) is raised."""
    # Since we removed online fallback, fetch_sbo_packages prints warning and returns empty dict
    # which leads to SystemExit(1)
    with patch('pathlib.Path.is_file', return_value=False):
        with pytest.raises(SystemExit) as exc_info:
            misc.is_the_sbo_exists(['myapp'])
    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# _check_requires_existence
# ---------------------------------------------------------------------------

def test_requires_self_reference_reports_error(validate: ValidateHandler) -> None:
    """REQUIRES containing the package's own name is reported as self-reference."""
    validate.ctx.info.requires = validate.ctx.prg_name
    with patch.object(validate.files, 'fetch_sbo_packages', return_value={validate.ctx.prg_name: 'cat  desc'}):
        errors = validate._check_requires_existence()
    assert any('self-reference' in e for e in errors)


def test_requires_readme_placeholder_reports_error(validate: ValidateHandler) -> None:
    """REQUIRES containing %README% is reported as deprecated placeholder."""
    validate.ctx.info.requires = '%README%'
    with patch.object(validate.files, 'fetch_sbo_packages', return_value={}):
        errors = validate._check_requires_existence()
    assert any('%README%' in e for e in errors)


# ---------------------------------------------------------------------------
# validate_info
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# email validation
# ---------------------------------------------------------------------------

@pytest.mark.parametrize('email', [
    'user@example.com',
    'user@sub.domain.org',
    'user AT example DOT com',
    'user at example dot com',
    'first.last AT domain DOT tld',
    'bdiego AT pm.me',
    'user AT sub.domain.org',
])
def test_email_valid_formats(validate: ValidateHandler, tmp_path: Path, email: str) -> None:
    """Valid email formats produce no errors."""
    validate.ctx.info.email = email
    validate.ctx.current_folder = tmp_path
    info = tmp_path / f'{validate.ctx.prg_name}.info'
    info.write_text(f'EMAIL="{email}"\n', encoding='utf-8')
    errors = validate._validate_info_variables(info.read_text(encoding='utf-8').splitlines())
    assert not any('EMAIL format' in e for e in errors)


@pytest.mark.parametrize('email', [
    'notanemail',
    'missing_at_sign.com',
    'user@nodot',
])
def test_email_invalid_formats(validate: ValidateHandler, tmp_path: Path, email: str) -> None:
    """Invalid email formats produce an error."""
    validate.ctx.info.email = email
    validate.ctx.current_folder = tmp_path
    info = tmp_path / f'{validate.ctx.prg_name}.info'
    info.write_text(f'EMAIL="{email}"\n', encoding='utf-8')
    errors = validate._validate_info_variables(info.read_text(encoding='utf-8').splitlines())
    assert any('EMAIL format' in e for e in errors)


def test_validate_info_passes(validate: ValidateHandler, tmp_path: Path) -> None:
    """With all required fields and files, validate_info exits 0."""
    name = validate.ctx.prg_name
    validate.ctx.info.version = '1.0'
    validate.ctx.info.homepage = 'https://example.com'
    validate.ctx.info.download_x86 = 'UNSUPPORTED'
    validate.ctx.info.download_x86_64 = 'UNSUPPORTED'
    validate.ctx.maintainer.name = 'John'
    validate.ctx.maintainer.email = 'john@example.com'

    # Create required files
    sb_content = (
        '#!/bin/bash\n'
        f'PRGNAM={name}\n'
        'VERSION=${VERSION:-1.0}\n'
        'BUILD=${BUILD:-1}\n'
        'TAG=${TAG:-_SBo}\n'
        'PKGTYPE=${PKGTYPE:-tgz}\n'
    )
    (tmp_path / f'{name}.SlackBuild').write_text(sb_content, encoding='utf-8')
    info_content = (
        f'PRGNAM="{name}"\n'
        'VERSION="1.0"\n'
        'HOMEPAGE="https://example.com"\n'
        'DOWNLOAD="UNSUPPORTED"\n'
        'MD5SUM=""\n'
        'DOWNLOAD_x86_64="UNSUPPORTED"\n'
        'MD5SUM_x86_64=""\n'
        'REQUIRES=""\n'
        'MAINTAINER="John"\n'
        'EMAIL="john@example.com"\n'
    )
    (tmp_path / f'{name}.info').write_text(info_content, encoding='utf-8')
    # slack-desc with 11 lines
    handy = ' ' * len(name) + '|-----handy-ruler' + '-' * 54 + '|'
    lines = ['# comment\n'] * 7 + [handy + '\n']
    for _ in range(11):
        lines.append(f'{name}: line\n')
    (tmp_path / 'slack-desc').write_text(''.join(lines), encoding='utf-8')
    (tmp_path / 'README').write_text('Some content.\n', encoding='utf-8')

    validate.files.read_info_file = MagicMock()

    with pytest.raises(SystemExit) as exc_info:
        validate.validate_info()
    # May fail due to file checks but we just verify it raises SystemExit
    assert exc_info.value.code in (0, 1)


def test_validate_info_errors_raises_systemexit_1(validate: ValidateHandler) -> None:
    """With empty required fields, validate_info exits 1."""
    validate.ctx.info.version = ''
    validate.ctx.info.homepage = ''
    validate.files.read_info_file = MagicMock()

    with pytest.raises(SystemExit) as exc_info:
        validate.validate_info()
    assert exc_info.value.code == 1


def _make_slackbuild(tmp_path: Path, name: str, content: str) -> None:
    """Write a .SlackBuild file with the given content."""
    (tmp_path / f'{name}.SlackBuild').write_text(content, encoding='utf-8')


_VALID_SB = (
    '#!/bin/bash\n'
    'PRGNAM={name}\n'
    'VERSION=${{VERSION:-{version}}}\n'
    'BUILD=${{BUILD:-1}}\n'
    'TAG=${{TAG:-_SBo}}\n'
    'PKGTYPE=${{PKGTYPE:-tgz}}\n'
)


def test_slackbuild_missing_shebang_reports_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """Missing #!/bin/bash shebang is reported as an error."""
    name, ver = validate.ctx.prg_name, validate.ctx.info.version
    validate.ctx.current_folder = tmp_path
    content = _VALID_SB.format(name=name, version=ver).replace('#!/bin/bash\n', '')
    _make_slackbuild(tmp_path, name, content)
    errors = validate._check_slackbuild_consistency()
    assert any('shebang' in e for e in errors)


def test_slackbuild_missing_build_reports_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """Missing BUILD variable is reported as an error."""
    name, ver = validate.ctx.prg_name, validate.ctx.info.version
    validate.ctx.current_folder = tmp_path
    content = _VALID_SB.format(name=name, version=ver).replace('BUILD=${BUILD:-1}\n', '')
    _make_slackbuild(tmp_path, name, content)
    errors = validate._check_slackbuild_consistency()
    assert any('BUILD' in e for e in errors)


def test_slackbuild_wrong_tag_reports_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """TAG not set to _SBo is reported as an error."""
    name, ver = validate.ctx.prg_name, validate.ctx.info.version
    validate.ctx.current_folder = tmp_path
    content = _VALID_SB.format(name=name, version=ver).replace(
        'TAG=${TAG:-_SBo}\n', 'TAG=${TAG:-_custom}\n')
    _make_slackbuild(tmp_path, name, content)
    errors = validate._check_slackbuild_consistency()
    assert any('TAG' in e for e in errors)


def test_slackbuild_missing_pkgtype_reports_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """Missing PKGTYPE variable is reported as an error."""
    name, ver = validate.ctx.prg_name, validate.ctx.info.version
    validate.ctx.current_folder = tmp_path
    content = _VALID_SB.format(name=name, version=ver).replace('PKGTYPE=${PKGTYPE:-tgz}\n', '')
    _make_slackbuild(tmp_path, name, content)
    errors = validate._check_slackbuild_consistency()
    assert any('PKGTYPE' in e for e in errors)


def test_slackbuild_all_valid_no_errors(validate: ValidateHandler, tmp_path: Path) -> None:
    """A fully valid .SlackBuild produces no structural errors."""
    name, ver = validate.ctx.prg_name, validate.ctx.info.version
    validate.ctx.current_folder = tmp_path
    _make_slackbuild(tmp_path, name, _VALID_SB.format(name=name, version=ver))
    errors = validate._check_slackbuild_consistency()
    assert errors == []


# ---------------------------------------------------------------------------
# _check_info_quotes
# ---------------------------------------------------------------------------

def test_info_quotes_missing_backslash_reports_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """Intermediate line missing backslash continuation is reported as missing backslash."""
    name = validate.ctx.prg_name
    validate.ctx.current_folder = tmp_path
    # def456 is an intermediate line missing its \ — next line is another hash (not a new field)
    content = (
        f'PRGNAM="{name}"\n'
        'MD5SUM="abc123 \\\n'
        'def456\n'           # missing backslash; next line is another hash, not a new key
        'ghi789"\n'
        'REQUIRES=""\n'
    )
    (tmp_path / f'{name}.info').write_text(content, encoding='utf-8')
    errors = validate._check_info_quotes()
    assert any('missing backslash' in e for e in errors)


def test_info_quotes_not_enclosed_reports_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """Value without any double quotes is reported as not enclosed."""
    name = validate.ctx.prg_name
    validate.ctx.current_folder = tmp_path
    content = (
        f'PRGNAM="{name}"\n'
        'VERSION=1.0\n'      # no quotes at all
    )
    (tmp_path / f'{name}.info').write_text(content, encoding='utf-8')
    errors = validate._check_info_quotes()
    assert any('not enclosed' in e for e in errors)


def test_info_quotes_valid_multiline_no_errors(validate: ValidateHandler, tmp_path: Path) -> None:
    """Properly continued multi-line value produces no errors."""
    name = validate.ctx.prg_name
    validate.ctx.current_folder = tmp_path
    content = (
        f'PRGNAM="{name}"\n'
        'MD5SUM="abc123 \\\n'
        'def456"\n'
    )
    (tmp_path / f'{name}.info').write_text(content, encoding='utf-8')
    errors = validate._check_info_quotes()
    assert errors == []


# ---------------------------------------------------------------------------
# bump_version
# ---------------------------------------------------------------------------

def test_bump_version_no_files_raises_systemexit_1(bump: BumpHandler) -> None:
    """With no .info or .SlackBuild present, bump_version exits 1."""
    with pytest.raises(SystemExit) as exc_info:
        bump.bump_version('2.0')
    assert exc_info.value.code == 1


def test_bump_version_updates_info_file(bump: BumpHandler, tmp_path: Path) -> None:
    """bump_version should update VERSION in .info file."""
    name = bump.ctx.prg_name
    info_file = tmp_path / f'{name}.info'
    info_file.write_text('VERSION="1.0"\n', encoding='utf-8')

    with pytest.raises(SystemExit) as exc_info:
        bump.bump_version('2.0')
    assert exc_info.value.code == 0
    assert 'VERSION="2.0"' in info_file.read_text(encoding='utf-8')


# ---------------------------------------------------------------------------
# _validate_info_variables
# ---------------------------------------------------------------------------

def test_validate_info_variables_missing_keys(validate: ValidateHandler) -> None:
    """Missing mandatory keys should produce 'variable not found' errors."""
    lines = [
        'PRGNAM="myapp"',
        'HOMEPAGE="http://example.com"',
        # Missing VERSION, DOWNLOAD, etc.
    ]
    errors = validate._validate_info_variables(lines)
    assert any('VERSION variable not found' in e for e in errors)
    assert any('DOWNLOAD variable not found' in e for e in errors)
    assert any('MAINTAINER variable not found' in e for e in errors)

def test_validate_info_variables_empty_keys(validate: ValidateHandler) -> None:
    """Empty mandatory keys should produce 'is empty' errors."""
    validate.ctx.info.version = ''
    validate.ctx.maintainer.name = ''
    validate.ctx.maintainer.email = ''
    validate.ctx.info.download_x86 = 'http://example.com/myapp.tar.gz'
    lines = [
        'PRGNAM="myapp"',
        'VERSION=""',
        'HOMEPAGE="http://example.com"',
        'DOWNLOAD="http://example.com/myapp.tar.gz"',
        'MD5SUM="d41d8cd98f00b204e9800998ecf8427e"',
        'DOWNLOAD_x86_64=""',
        'MD5SUM_x86_64=""',
        'REQUIRES=""',
        'MAINTAINER=""',
        'EMAIL=""',
    ]
    errors = validate._validate_info_variables(lines)
    assert any('VERSION is empty' in e for e in errors)
    assert any('MAINTAINER is empty' in e for e in errors)
    assert any('EMAIL is empty' in e for e in errors)
    assert not any('DOWNLOAD is empty' in e for e in errors)

def test_validate_info_variables_missing_quote_prgnam(validate: ValidateHandler) -> None:
    """PRGNAM without closing quote should not report mismatch/not found, leaving it for check_info_quotes."""
    lines = [
        'PRGNAM="myapp', # Missing closing quote
        'VERSION="1.0"',
        'HOMEPAGE="http://example.com"',
        'DOWNLOAD="http://example.com/myapp.tar.gz"',
        'MD5SUM="d41d8cd98f00b204e9800998ecf8427e"',
        'DOWNLOAD_x86_64=""',
        'MD5SUM_x86_64=""',
        'REQUIRES=""',
        'MAINTAINER="Me"',
        'EMAIL="me@example.com"',
    ]
    validate.ctx.prg_name = 'myapp'
    errors = validate._validate_info_variables(lines)
    # Because it misses the closing quote, it skips the mismatch logic and 'not found' is handled by required_keys
    assert not any('PRGNAM mismatch' in e for e in errors)
    assert not any('PRGNAM variable not found' in e for e in errors)


# ---------------------------------------------------------------------------
# _fix_file / _fix_files
# ---------------------------------------------------------------------------

def test_fix_file_removes_trailing_whitespace(validate: ValidateHandler, tmp_path: Path) -> None:
    """Trailing whitespace is stripped and ws_fixed=True is returned."""
    info_file = tmp_path / 'test.info'
    info_file.write_text('VERSION="1.0"   \nHOMEPAGE="http://x.com"\n', encoding='utf-8')

    ws_fixed, nl_fixed = validate._fix_file(info_file)

    content = info_file.read_text(encoding='utf-8')
    assert ws_fixed is True
    assert nl_fixed is False
    assert 'VERSION="1.0"   ' not in content
    assert 'VERSION="1.0"' in content


def test_fix_file_readme_strips_extra_newlines(validate: ValidateHandler, tmp_path: Path) -> None:
    """README with extra trailing blank lines gets stripped to a single newline."""
    readme = tmp_path / 'README'
    readme.write_text('Some text\n\n\n', encoding='utf-8')

    ws_fixed, nl_fixed = validate._fix_file(readme, fix_newline=True)

    assert ws_fixed is False
    assert nl_fixed is True
    assert readme.read_text(encoding='utf-8') == 'Some text\n'


def test_fix_file_readme_already_correct(validate: ValidateHandler, tmp_path: Path) -> None:
    """README ending with exactly one newline is not modified."""
    readme = tmp_path / 'README'
    readme.write_text('Some text\n', encoding='utf-8')

    ws_fixed, nl_fixed = validate._fix_file(readme, fix_newline=True)

    assert ws_fixed is False
    assert nl_fixed is False
    assert readme.read_text(encoding='utf-8') == 'Some text\n'


def test_check_readme_trailing_blank_line_is_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """README ending with \\n\\n produces an error from _check_trailing_newlines."""
    validate.ctx.current_folder = tmp_path
    (tmp_path / 'README').write_text('Some content.\n\n', encoding='utf-8')

    errors = validate._check_trailing_newlines()

    assert any('trailing blank line' in e for e in errors)


def test_check_readme_multiple_trailing_blank_lines_is_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """README ending with many blank lines produces an error from _check_trailing_newlines."""
    validate.ctx.current_folder = tmp_path
    (tmp_path / 'README').write_text('Some content.\n\n\n\n\n\n\n\n\n\n\n', encoding='utf-8')

    errors = validate._check_trailing_newlines()

    assert any('trailing blank line' in e for e in errors)


def test_check_readme_correct_newline_no_warning(validate: ValidateHandler, tmp_path: Path) -> None:
    """README ending with exactly \\n produces no errors or warnings."""
    validate.ctx.current_folder = tmp_path
    (tmp_path / 'README').write_text('Some content.\n', encoding='utf-8')

    errors, warnings = validate._check_readme()

    assert errors == []
    assert warnings == []


def test_check_readme_no_newline_is_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """README missing final newline produces an error from _check_trailing_newlines."""
    validate.ctx.current_folder = tmp_path
    (tmp_path / 'README').write_text('Some content.', encoding='utf-8')

    errors = validate._check_trailing_newlines()

    assert any('must end with a newline' in e for e in errors)


def test_fix_file_no_newline_fix_for_info(validate: ValidateHandler, tmp_path: Path) -> None:
    """Non-README files do not get newline fixed (fix_newline defaults to False)."""
    info_file = tmp_path / 'test.info'
    info_file.write_text('VERSION="1.0"\n', encoding='utf-8')

    ws_fixed, nl_fixed = validate._fix_file(info_file)

    assert ws_fixed is False
    assert nl_fixed is False
    assert not info_file.read_text(encoding='utf-8').endswith('\n\n')


def test_fix_file_preserves_backslash_continuation(validate: ValidateHandler,
                                                    tmp_path: Path) -> None:
    """Backslash continuation lines are preserved."""
    info_file = tmp_path / 'test.info'
    info_file.write_text('DOWNLOAD="url1 \\\nurl2"\n', encoding='utf-8')

    validate._fix_file(info_file)

    content = info_file.read_text(encoding='utf-8')
    assert '\\' in content


def test_fix_files_skips_missing_files(validate: ValidateHandler, tmp_path: Path) -> None:
    """_fix_files only processes files that exist."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    info_file = tmp_path / f'{name}.info'
    info_file.write_text('VERSION="1.0"', encoding='utf-8')

    validate._fix_files()  # README / SlackBuild / slack-desc missing — should not raise

    assert info_file.exists()  # processed without error


# ---------------------------------------------------------------------------
# _fix_info_quotes
# ---------------------------------------------------------------------------

def test_fix_info_quotes_adds_missing_quotes(validate: ValidateHandler, tmp_path: Path) -> None:
    """Unquoted single-line values get double quotes added."""
    info_file = tmp_path / 'test.info'
    info_file.write_text('VERSION=1.0\nHOMEPAGE=http://x.com\n', encoding='utf-8')

    result = validate._fix_info_quotes(info_file)

    content = info_file.read_text(encoding='utf-8')
    assert result is True
    assert 'VERSION="1.0"' in content
    assert 'HOMEPAGE="http://x.com"' in content


def test_fix_info_quotes_no_change_when_correct(validate: ValidateHandler, tmp_path: Path) -> None:
    """Already quoted values are not changed."""
    info_file = tmp_path / 'test.info'
    info_file.write_text('VERSION="1.0"\nHOMEPAGE="http://x.com"\n', encoding='utf-8')

    result = validate._fix_info_quotes(info_file)

    assert result is False


def test_fix_info_quotes_empty_value(validate: ValidateHandler, tmp_path: Path) -> None:
    """Empty unquoted value is wrapped as empty string."""
    info_file = tmp_path / 'test.info'
    info_file.write_text('DOWNLOAD_x86_64=\nMD5SUM_x86_64=\n', encoding='utf-8')

    result = validate._fix_info_quotes(info_file)

    content = info_file.read_text(encoding='utf-8')
    assert result is True
    assert 'DOWNLOAD_x86_64=""' in content
    assert 'MD5SUM_x86_64=""' in content


def test_fix_info_quotes_multiline_missing_closing(validate: ValidateHandler,
                                                    tmp_path: Path) -> None:
    """Multi-line value missing closing quote gets it added on last line."""
    info_file = tmp_path / 'test.info'
    info_file.write_text(
        'MD5SUM="abc \\\n    def \\\n    ghi\n',
        encoding='utf-8',
    )

    result = validate._fix_info_quotes(info_file)

    content = info_file.read_text(encoding='utf-8')
    assert result is True
    assert content.endswith('ghi"\n')


def test_fix_info_quotes_multiline_correct(validate: ValidateHandler, tmp_path: Path) -> None:
    """Properly closed multi-line value is not modified."""
    info_file = tmp_path / 'test.info'
    original = 'DOWNLOAD="http://x.com/a.tar.gz \\\n    http://x.com/b.tar.gz"\n'
    info_file.write_text(original, encoding='utf-8')

    result = validate._fix_info_quotes(info_file)

    assert result is False
    assert info_file.read_text(encoding='utf-8') == original


def test_fix_info_quotes_multiline_missing_opening(validate: ValidateHandler,
                                                    tmp_path: Path) -> None:
    """Multi-line value missing opening quote gets it added on the first line."""
    info_file = tmp_path / 'test.info'
    info_file.write_text(
        'MD5SUM=abc123 \\\n    def456 \\\n    ghi789"\n',
        encoding='utf-8',
    )

    result = validate._fix_info_quotes(info_file)

    content = info_file.read_text(encoding='utf-8')
    assert result is True
    assert content.startswith('MD5SUM="abc123 \\\n')


# _fix_slackbuild_executable
# ---------------------------------------------------------------------------

def test_fix_slackbuild_executable_removes_bit(validate: ValidateHandler, tmp_path: Path) -> None:
    """Executable SlackBuild gets execute bit removed for SBo submission."""
    sb = tmp_path / 'test.SlackBuild'
    sb.write_text('#!/bin/bash\n', encoding='utf-8')
    sb.chmod(0o755)

    result = validate._fix_slackbuild_executable(sb)

    assert result is True
    assert not sb.stat().st_mode & 0o111


def test_fix_slackbuild_executable_already_clear(validate: ValidateHandler, tmp_path: Path) -> None:
    """Non-executable SlackBuild returns False (nothing to fix)."""
    sb = tmp_path / 'test.SlackBuild'
    sb.write_text('#!/bin/bash\n', encoding='utf-8')
    sb.chmod(0o644)

    result = validate._fix_slackbuild_executable(sb)

    assert result is False


# ---------------------------------------------------------------------------
# _fix_info_variables
# ---------------------------------------------------------------------------

def test_fix_info_variables_prgnam_mismatch(validate: ValidateHandler, tmp_path: Path) -> None:
    """PRGNAM wrong value is fixed to match folder name."""
    validate.ctx.current_folder = tmp_path
    info_file = tmp_path / 'myapp.info'
    info_file.write_text('PRGNAM="wrongname"\nVERSION="1.0"\n', encoding='utf-8')

    result = validate._fix_info_variables(info_file)

    assert result['PRGNAM'] is True
    assert 'PRGNAM="myapp"' in info_file.read_text(encoding='utf-8')


def test_fix_info_variables_prgnam_missing(validate: ValidateHandler, tmp_path: Path) -> None:
    """Missing PRGNAM is prepended."""
    validate.ctx.current_folder = tmp_path
    info_file = tmp_path / 'myapp.info'
    info_file.write_text('VERSION="1.0"\n', encoding='utf-8')

    result = validate._fix_info_variables(info_file)

    assert result['PRGNAM'] is True
    assert 'PRGNAM="myapp"' in info_file.read_text(encoding='utf-8')



def test_fix_info_variables_maintainer_email_not_changed(validate: ValidateHandler,
                                                          tmp_path: Path) -> None:
    """MAINTAINER and EMAIL are not changed by --fix (user must update manually)."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.maintainer.name = 'Alice'
    validate.ctx.maintainer.email = 'alice@example.com'
    info_file = tmp_path / 'myapp.info'
    info_file.write_text(
        'PRGNAM="myapp"\nVERSION="1.0"\nHOMEPAGE="https://x.com"\n'
        'DOWNLOAD="https://x.com/f.tar.gz"\nMD5SUM="abc"\n'
        'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES=""\n'
        'MAINTAINER="Old Name"\nEMAIL="old@x.com"\n',
        encoding='utf-8',
    )

    result = validate._fix_info_variables(info_file)

    content = info_file.read_text(encoding='utf-8')
    assert result['MAINTAINER'] is False
    assert result['EMAIL'] is False
    assert 'MAINTAINER="Old Name"' in content
    assert 'EMAIL="old@x.com"' in content


def test_fix_info_variables_corrupted_keys(validate: ValidateHandler, tmp_path: Path) -> None:
    """Corrupted field names (ERSION, D5SUM) are fuzzy-matched and renamed."""
    validate.ctx.current_folder = tmp_path
    info_file = tmp_path / 'myapp.info'
    info_file.write_text(
        'PRGNAM="myapp"\nERSION="1.0"\nHOMEPAGE="https://x.com"\n'
        'DOWNLOAD="https://x.com/f.tar.gz"\nD5SUM="abc123"\n'
        'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES=""\n'
        'MAINTAINER="Me"\nEMAIL="me@x.com"\n',
        encoding='utf-8',
    )

    result = validate._fix_info_variables(info_file)

    content = info_file.read_text(encoding='utf-8')
    assert result['VERSION'] is True
    assert result['MD5SUM'] is True
    assert 'VERSION="1.0"' in content
    assert 'MD5SUM="abc123"' in content
    assert not re.search(r'^ERSION=', content, re.MULTILINE)
    assert not re.search(r'^D5SUM=', content, re.MULTILINE)


def test_fix_info_variables_no_change_when_correct(validate: ValidateHandler,
                                                    tmp_path: Path) -> None:
    """No fields changed when all fields are already correct."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.maintainer.name = 'Alice'
    validate.ctx.maintainer.email = 'alice@example.com'
    info_file = tmp_path / 'myapp.info'
    info_file.write_text(
        'PRGNAM="myapp"\nVERSION="1.0"\nHOMEPAGE="https://x.com"\n'
        'DOWNLOAD="https://x.com/f.tar.gz"\nMD5SUM="abc123"\n'
        'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES=""\n'
        'MAINTAINER="Alice"\nEMAIL="alice@example.com"\n',
        encoding='utf-8',
    )

    result = validate._fix_info_variables(info_file)

    assert not any(result.values())


# ---------------------------------------------------------------------------
# _check_slack_desc (extended)
# ---------------------------------------------------------------------------

def _make_slack_desc(name: str, lines: list[str] | None = None, ruler: str | None = None) -> str:
    """Build a valid slack-desc string for testing."""
    default_ruler = ' ' * len(name) + '|-----handy-ruler' + '-' * 54 + '|'
    header = ['# comment\n'] * 7 + [(ruler if ruler is not None else default_ruler) + '\n']
    desc = lines if lines is not None else [f'{name}: {name} (short description)\n'] + [f'{name}: line\n'] * 10
    return ''.join(header + desc)


def test_check_slack_desc_wrong_prefix(validate: ValidateHandler, tmp_path: Path) -> None:
    """Line not starting with name: is reported."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    desc_lines = [f'{name}: line\n'] * 10 + ['wrongprefix: line\n']
    (tmp_path / 'slack-desc').write_text(_make_slack_desc(name, desc_lines), encoding='utf-8')

    errors = validate._check_slack_desc()

    assert any("does not start with" in e for e in errors)


def test_check_slack_desc_line_too_long(validate: ValidateHandler, tmp_path: Path) -> None:
    """Description line exceeding 70 chars is reported."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    long_line = f'{name}: ' + 'x' * 71 + '\n'
    desc_lines = [long_line] + [f'{name}: line\n'] * 10
    (tmp_path / 'slack-desc').write_text(_make_slack_desc(name, desc_lines), encoding='utf-8')

    errors = validate._check_slack_desc()

    assert any('exceeds 70 characters' in e for e in errors)


def test_check_slack_desc_misaligned_ruler(validate: ValidateHandler, tmp_path: Path) -> None:
    """Wrong handy-ruler is reported."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    bad_ruler = ' ' * (len(name) + 3) + '|-----handy-ruler' + '-' * 54 + '|'
    (tmp_path / 'slack-desc').write_text(_make_slack_desc(name, ruler=bad_ruler), encoding='utf-8')

    errors = validate._check_slack_desc()

    assert any('handy-ruler' in e for e in errors)


def test_check_slack_desc_valid(validate: ValidateHandler, tmp_path: Path) -> None:
    """Valid slack-desc produces no errors."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    (tmp_path / 'slack-desc').write_text(_make_slack_desc(name), encoding='utf-8')

    errors = validate._check_slack_desc()

    assert errors == []


# ---------------------------------------------------------------------------
# _fix_slack_desc_prefix
# ---------------------------------------------------------------------------

def test_fix_slack_desc_prefix_fixes_typo(validate: ValidateHandler, tmp_path: Path) -> None:
    """Corrupted prefix (e.g. eovim:) is replaced with the correct name."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    desc_lines = [f'{name}: line\n'] * 10 + [f'{name[1:]}: last line\n']
    sd = tmp_path / 'slack-desc'
    sd.write_text(_make_slack_desc(name, desc_lines), encoding='utf-8')

    result = validate._fix_slack_desc_prefix(sd)

    content = sd.read_text(encoding='utf-8')
    assert result is True
    assert f'{name}: last line' in content


def test_fix_slack_desc_prefix_fixes_missing_space(validate: ValidateHandler,
                                                    tmp_path: Path) -> None:
    """Missing space after name: is added."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    desc_lines = [f'{name}: line\n'] * 10 + [f'{name}:nospace\n']
    sd = tmp_path / 'slack-desc'
    sd.write_text(_make_slack_desc(name, desc_lines), encoding='utf-8')

    result = validate._fix_slack_desc_prefix(sd)

    assert result is True
    assert f'{name}: nospace' in sd.read_text(encoding='utf-8')


def test_fix_slack_desc_prefix_fixes_extra_space(validate: ValidateHandler,
                                                  tmp_path: Path) -> None:
    """Extra space after name: is removed."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    desc_lines = [f'{name}: line\n'] * 10 + [f'{name}:  doublespace\n']
    sd = tmp_path / 'slack-desc'
    sd.write_text(_make_slack_desc(name, desc_lines), encoding='utf-8')

    result = validate._fix_slack_desc_prefix(sd)

    assert result is True
    assert f'{name}: doublespace' in sd.read_text(encoding='utf-8')


def test_fix_slack_desc_prefix_no_change_when_correct(validate: ValidateHandler,
                                                       tmp_path: Path) -> None:
    """No change when all prefixes are correct."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    sd = tmp_path / 'slack-desc'
    sd.write_text(_make_slack_desc(name), encoding='utf-8')

    result = validate._fix_slack_desc_prefix(sd)

    assert result is False


# ---------------------------------------------------------------------------
# _fix_slack_desc_ruler
# ---------------------------------------------------------------------------

def test_fix_slack_desc_ruler_fixes_misaligned(validate: ValidateHandler, tmp_path: Path) -> None:
    """Misaligned handy-ruler is corrected."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    bad_ruler = ' ' * (len(name) + 2) + '|-----handy-ruler' + '-' * 54 + '|'
    sd = tmp_path / 'slack-desc'
    sd.write_text(_make_slack_desc(name, ruler=bad_ruler), encoding='utf-8')

    result = validate._fix_slack_desc_ruler(sd)

    assert result is True
    expected = ' ' * len(name) + '|-----handy-ruler' + '-' * 54 + '|'
    assert sd.read_text(encoding='utf-8').splitlines()[7] == expected


def test_fix_slack_desc_ruler_no_change_when_correct(validate: ValidateHandler,
                                                      tmp_path: Path) -> None:
    """Correct handy-ruler is not modified."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    sd = tmp_path / 'slack-desc'
    sd.write_text(_make_slack_desc(name), encoding='utf-8')

    result = validate._fix_slack_desc_ruler(sd)

    assert result is False


def test_check_slack_desc_empty_parens(validate: ValidateHandler, tmp_path: Path) -> None:
    """First description line with empty parentheses is reported."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    desc_lines = [f'{name}: {name} ()\n'] + [f'{name}: line\n'] * 10
    (tmp_path / 'slack-desc').write_text(_make_slack_desc(name, desc_lines), encoding='utf-8')

    errors = validate._check_slack_desc()

    assert any('short description' in e for e in errors)


def test_check_slack_desc_missing_parens(validate: ValidateHandler, tmp_path: Path) -> None:
    """First description line with no parentheses at all is reported."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    desc_lines = [f'{name}: {name} is a tool\n'] + [f'{name}: line\n'] * 10
    (tmp_path / 'slack-desc').write_text(_make_slack_desc(name, desc_lines), encoding='utf-8')

    errors = validate._check_slack_desc()

    assert any('short description' in e for e in errors)


def test_fix_slack_desc_prefix_missing_colon(validate: ValidateHandler, tmp_path: Path) -> None:
    """Line with no colon at all gets name: prefix added."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    desc_lines = [f'{name}: {name} (short description)\n'] + [f'{name}: line\n'] * 9 + [f'no colon here\n']
    sd = tmp_path / 'slack-desc'
    sd.write_text(_make_slack_desc(name, desc_lines), encoding='utf-8')

    result = validate._fix_slack_desc_prefix(sd)

    assert result is True
    assert f'{name}: no colon here' in sd.read_text(encoding='utf-8')


def test_fix_slack_desc_prefix_name_without_colon(validate: ValidateHandler, tmp_path: Path) -> None:
    """Line starting with correct name but missing colon gets colon inserted."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    desc_lines = [f'{name}: {name} (short description)\n'] + [f'{name}: line\n'] * 9 + [f'{name} missing colon\n']
    sd = tmp_path / 'slack-desc'
    sd.write_text(_make_slack_desc(name, desc_lines), encoding='utf-8')

    result = validate._fix_slack_desc_prefix(sd)

    assert result is True
    content = sd.read_text(encoding='utf-8')
    assert f'{name}: missing colon' in content
    assert f'{name}: {name}' not in content.splitlines()[-1]


# ---------------------------------------------------------------------------
# _check_info_consistency (MD5SUM hex + DOWNLOAD filename)
# ---------------------------------------------------------------------------

def test_check_info_consistency_invalid_md5sum(validate: ValidateHandler, tmp_path: Path) -> None:
    """Non-hex MD5SUM value is reported."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.download_x86 = 'https://example.com/pkg-1.0.tar.gz'
    validate.ctx.info.md5sum_x86 = 'INVALIDCHECKSUM1234567890abcdef'
    (tmp_path / f'{validate.ctx.prg_name}.info').write_text('', encoding='utf-8')

    errors = validate._check_info_consistency()

    assert any('invalid checksum' in e for e in errors)


def test_check_info_consistency_valid_md5sum(validate: ValidateHandler, tmp_path: Path) -> None:
    """Valid 32-char hex MD5SUM produces no checksum error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.download_x86 = 'https://example.com/pkg-1.0.tar.gz'
    validate.ctx.info.md5sum_x86 = 'a' * 32
    (tmp_path / f'{validate.ctx.prg_name}.info').write_text('', encoding='utf-8')

    errors = validate._check_info_consistency()

    assert not any('invalid checksum' in e for e in errors)


def test_check_info_consistency_download_no_filename(validate: ValidateHandler,
                                                      tmp_path: Path) -> None:
    """DOWNLOAD URL ending with / is reported."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.download_x86 = 'https://example.com/downloads/'
    validate.ctx.info.md5sum_x86 = 'a' * 32
    (tmp_path / f'{validate.ctx.prg_name}.info').write_text('', encoding='utf-8')

    errors = validate._check_info_consistency()

    assert any('no filename' in e for e in errors)


# ---------------------------------------------------------------------------
# _validate_info_variables (HOMEPAGE)
# ---------------------------------------------------------------------------

def test_validate_info_variables_homepage_invalid(validate: ValidateHandler, tmp_path: Path) -> None:
    """HOMEPAGE with no URL scheme is reported as invalid."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.homepage = 'example.com'
    info_lines = [
        'PRGNAM="myapp"\n', 'VERSION="1.0"\n', 'HOMEPAGE="example.com"\n',
        'DOWNLOAD=""\n', 'MD5SUM=""\n', 'DOWNLOAD_x86_64=""\n', 'MD5SUM_x86_64=""\n',
        'REQUIRES=""\n', 'MAINTAINER="Test"\n', 'EMAIL="test@example.com"\n',
    ]
    errors = validate._validate_info_variables(info_lines)
    assert any('not a valid URL' in e for e in errors)


def test_validate_info_variables_homepage_https_ok(validate: ValidateHandler, tmp_path: Path) -> None:
    """HOMEPAGE using https:// produces no URL error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.homepage = 'https://example.com'
    info_lines = [
        'PRGNAM="myapp"\n', 'VERSION="1.0"\n', 'HOMEPAGE="https://example.com"\n',
        'DOWNLOAD=""\n', 'MD5SUM=""\n', 'DOWNLOAD_x86_64=""\n', 'MD5SUM_x86_64=""\n',
        'REQUIRES=""\n', 'MAINTAINER="Test"\n', 'EMAIL="test@example.com"\n',
    ]
    errors = validate._validate_info_variables(info_lines)
    assert not any('HOMEPAGE' in e for e in errors)


# ---------------------------------------------------------------------------
# _check_strict (slack-desc line count)
# ---------------------------------------------------------------------------

def test_check_strict_slack_desc_wrong_line_count(validate: ValidateHandler,
                                                   tmp_path: Path) -> None:
    """slack-desc with line count != 19 is reported under --strict."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    (tmp_path / 'slack-desc').write_text(_make_slack_desc(name) + 'extra line\n', encoding='utf-8')

    errors = validate._check_slack_desc_lines()

    assert any('19 lines' in e for e in errors)


def test_check_strict_slack_desc_correct_line_count(validate: ValidateHandler,
                                                     tmp_path: Path) -> None:
    """slack-desc with exactly 19 lines produces no line count error."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    (tmp_path / 'slack-desc').write_text(_make_slack_desc(name), encoding='utf-8')

    errors = validate._check_slack_desc_lines()

    assert not any('19 lines' in e for e in errors)


# ---------------------------------------------------------------------------
# _check_trailing_whitespace
# ---------------------------------------------------------------------------

def test_check_trailing_whitespace_detected(validate: ValidateHandler, tmp_path: Path) -> None:
    """Trailing whitespace in .info is reported as an error."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    (tmp_path / f'{name}.info').write_text('VERSION="1.0"   \n', encoding='utf-8')

    errors = validate._check_trailing_whitespace()

    assert any('trailing whitespace' in e for e in errors)


def test_check_trailing_whitespace_clean(validate: ValidateHandler, tmp_path: Path) -> None:
    """No error when files have no trailing whitespace."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    (tmp_path / f'{name}.info').write_text('VERSION="1.0"\n', encoding='utf-8')

    errors = validate._check_trailing_whitespace()

    assert errors == []


# ---------------------------------------------------------------------------
# validate_info --fix
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# _check_strict
# ---------------------------------------------------------------------------

def test_check_strict_executable_slackbuild(validate: ValidateHandler, tmp_path: Path) -> None:
    """Executable .SlackBuild reports an error."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    sb = tmp_path / f'{name}.SlackBuild'
    sb.write_text('#!/bin/bash\n', encoding='utf-8')
    sb.chmod(0o755)
    errors = validate._check_permissions()
    assert any('must not be executable' in e for e in errors)


def test_check_strict_http_url(validate: ValidateHandler, tmp_path: Path) -> None:
    """HTTP (non-HTTPS) DOWNLOAD URL reports an error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.download_x86 = 'http://example.com/file.tar.gz'
    validate.ctx.info.download_x86_64 = ''
    errors = validate._check_https_urls()
    assert any('HTTP, not HTTPS' in e for e in errors)


def test_check_strict_https_url_no_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """HTTPS DOWNLOAD URL produces no URL error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.download_x86 = 'https://example.com/file.tar.gz'
    validate.ctx.info.download_x86_64 = ''
    errors = validate._check_https_urls()
    assert not any('HTTP, not HTTPS' in e for e in errors)


def test_check_strict_homepage_ok(validate: ValidateHandler, tmp_path: Path) -> None:
    """Accessible HOMEPAGE produces no error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.homepage = 'https://example.com'
    mock_resp = MagicMock()
    mock_resp.status = 200
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.return_value = mock_resp
        errors = validate._check_homepage()
    assert not any('HOMEPAGE' in e for e in errors)


def test_check_strict_homepage_failed(validate: ValidateHandler, tmp_path: Path) -> None:
    """Inaccessible HOMEPAGE reports an error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.homepage = 'https://example.com'
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.side_effect = Exception('timeout')
        errors = validate._check_homepage()
    assert any('HOMEPAGE is not accessible' in e for e in errors)


def test_check_strict_version_with_spaces(validate: ValidateHandler, tmp_path: Path) -> None:
    """VERSION containing spaces reports an error."""
    validate.ctx.info.version = '1.0 beta'
    errors = validate._check_version()
    assert any('VERSION contains spaces' in e for e in errors)


def test_check_strict_version_no_spaces(validate: ValidateHandler, tmp_path: Path) -> None:
    """VERSION without spaces produces no VERSION error."""
    validate.ctx.info.version = '1.0.0'
    errors = validate._check_version()
    assert not any('VERSION' in e for e in errors)


def test_check_strict_homepage_http_scheme(validate: ValidateHandler, tmp_path: Path) -> None:
    """HOMEPAGE using http:// reports an error in strict mode."""
    validate.ctx.info.homepage = 'http://example.com'
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.return_value.status = 200
        errors = validate._check_homepage()
    assert any('HOMEPAGE uses HTTP' in e for e in errors)


def test_check_strict_homepage_https_scheme_no_error(validate: ValidateHandler, tmp_path: Path) -> None:
    """HOMEPAGE using https:// produces no scheme error."""
    validate.ctx.info.homepage = 'https://example.com'
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.return_value.status = 200
        errors = validate._check_homepage()
    assert not any('HOMEPAGE uses HTTP' in e for e in errors)


def _write_slack_desc(folder: Path, name: str, homepage: str = '') -> None:
    lines = [f'{name}: {name} (short description)\n', f'{name}:\n']
    if homepage:
        lines.append(f'{name}: Homepage: {homepage}\n')
    slack_desc = folder / 'slack-desc'
    slack_desc.write_text(''.join(lines))


def test_check_slack_desc_homepage_ok(validate: ValidateHandler, tmp_path: Path) -> None:
    """Accessible slack-desc Homepage produces no error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.prg_name = 'myapp'
    _write_slack_desc(tmp_path, 'myapp', 'https://example.com')
    mock_resp = MagicMock()
    mock_resp.status = 200
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.return_value = mock_resp
        errors = validate._check_slack_desc_homepage()
    assert errors == []


def test_check_slack_desc_homepage_http_scheme(validate: ValidateHandler, tmp_path: Path) -> None:
    """slack-desc Homepage using http:// reports an error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.prg_name = 'myapp'
    _write_slack_desc(tmp_path, 'myapp', 'http://example.com')
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.return_value.status = 200
        errors = validate._check_slack_desc_homepage()
    assert any('HTTP' in e for e in errors)


def test_check_slack_desc_homepage_not_accessible(validate: ValidateHandler, tmp_path: Path) -> None:
    """Inaccessible slack-desc Homepage reports an error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.prg_name = 'myapp'
    _write_slack_desc(tmp_path, 'myapp', 'https://example.com')
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.side_effect = Exception('timeout')
        errors = validate._check_slack_desc_homepage()
    assert any('not accessible' in e for e in errors)


def test_check_slack_desc_homepage_404(validate: ValidateHandler, tmp_path: Path) -> None:
    """slack-desc Homepage returning 404 reports an error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.prg_name = 'myapp'
    _write_slack_desc(tmp_path, 'myapp', 'https://example.com')
    mock_resp = MagicMock()
    mock_resp.status = 404
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.return_value = mock_resp
        errors = validate._check_slack_desc_homepage()
    assert any('404' in e for e in errors)


def test_check_slack_desc_homepage_absent(validate: ValidateHandler, tmp_path: Path) -> None:
    """slack-desc without Homepage line produces no error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.prg_name = 'myapp'
    _write_slack_desc(tmp_path, 'myapp')
    errors = validate._check_slack_desc_homepage()
    assert errors == []


def test_check_slack_desc_homepage_no_file(validate: ValidateHandler, tmp_path: Path) -> None:
    """Missing slack-desc produces no error."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.prg_name = 'myapp'
    errors = validate._check_slack_desc_homepage()
    assert errors == []


def test_validate_info_fix_calls_fix_method(validate: ValidateHandler, tmp_path: Path) -> None:
    """validate_info with fix=True calls _fix_files before validation."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    info_file = tmp_path / f'{name}.info'
    info_file.write_text(
        f'PRGNAM="{name}"\nVERSION="1.0"\nHOMEPAGE="http://x.com"\n'
        'DOWNLOAD="http://x.com/f.tar.gz"\nMD5SUM="abc123"\n'
        'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES=""\n'
        'MAINTAINER="Me"\nEMAIL="me@x.com"\n',
        encoding='utf-8',
    )
    with patch.object(validate, '_fix_files') as mock_fix, \
         pytest.raises(SystemExit):
        validate.validate_info(fix=True)
    mock_fix.assert_called_once()


# ---------------------------------------------------------------------------
# search_sbo_packages
# ---------------------------------------------------------------------------

def test_search_sbo_packages_found(misc: MiscHandler) -> None:
    """Matching packages are printed and exits 0."""
    packages = {
        'vim-plugin-ale': 'development  vim-plugin-ale desc',
        'vim-plugin-nerd': 'development  vim-plugin-nerd desc',
    }
    with patch.object(misc.resolver, 'load',
                      side_effect=lambda: misc.ctx.sbo_packages.update(packages)), \
         pytest.raises(SystemExit) as exc_info:
        misc.search_sbo_packages(['vim-plugin'])
    assert exc_info.value.code == 0


def test_search_sbo_packages_not_found(misc: MiscHandler) -> None:
    """No match exits 1."""
    packages = {'otherapp': 'category  otherapp desc'}
    with patch.object(misc.resolver, 'load',
                      side_effect=lambda: misc.ctx.sbo_packages.update(packages)), \
         pytest.raises(SystemExit) as exc_info:
        misc.search_sbo_packages(['nonexistent'])
    assert exc_info.value.code == 1


def test_search_sbo_packages_multiple_names(misc: MiscHandler) -> None:
    """Multiple search terms each produce results."""
    packages = {
        'vim-plugin-ale': 'development  vim-plugin-ale desc',
        'python3-bcrypt': 'python  python3-bcrypt desc',
    }
    with patch.object(misc.resolver, 'load',
                      side_effect=lambda: misc.ctx.sbo_packages.update(packages)), \
         pytest.raises(SystemExit) as exc_info:
        misc.search_sbo_packages(['vim-plugin', 'python3'])
    assert exc_info.value.code == 0


# ---------------------------------------------------------------------------
# clean_sources
# ---------------------------------------------------------------------------

def test_clean_sources_no_tarballs(misc: MiscHandler, tmp_path: Path) -> None:
    """No tarballs found exits 0 without prompting."""
    misc.ctx.current_folder = tmp_path
    result = misc.clean_sources()
    assert result is False


def test_clean_sources_user_declines(misc: MiscHandler, tmp_path: Path) -> None:
    """User declines removal — returns False, files intact."""
    misc.ctx.current_folder = tmp_path
    tarball = tmp_path / 'myapp-1.0.tar.gz'
    tarball.write_text('data', encoding='utf-8')

    with patch('builtins.input', return_value='n'):
        result = misc.clean_sources()
    assert result is False
    assert tarball.exists()


def test_clean_sources_removes_tarballs(misc: MiscHandler, tmp_path: Path) -> None:
    """User confirms — tarballs removed, returns True."""
    misc.ctx.current_folder = tmp_path
    tarball = tmp_path / 'myapp-1.0.tar.gz'
    tarball.write_text('data', encoding='utf-8')

    with patch('builtins.input', return_value='y'):
        result = misc.clean_sources()
    assert result is True
    assert not tarball.exists()


# ---------------------------------------------------------------------------
# show_info
# ---------------------------------------------------------------------------

def test_show_info_file_not_found(misc: MiscHandler, tmp_path: Path) -> None:
    """Missing .info file exits 1."""
    misc.ctx.current_folder = tmp_path
    with pytest.raises(SystemExit) as exc_info:
        misc.show_info()
    assert exc_info.value.code == 1


def test_show_info_displays_fields(misc: MiscHandler, tmp_path: Path) -> None:
    """Parsed fields are printed and exits 0."""
    misc.ctx.current_folder = tmp_path
    name = misc.ctx.prg_name
    info_file = tmp_path / f'{name}.info'
    info_file.write_text(
        f'PRGNAM="{name}"\n'
        'VERSION="1.0"\n'
        'HOMEPAGE="https://example.com"\n'
        'DOWNLOAD="https://example.com/file.tar.gz"\n'
        'MD5SUM="abc123"\n'
        'DOWNLOAD_x86_64=""\n'
        'MD5SUM_x86_64=""\n'
        'REQUIRES=""\n'
        'MAINTAINER="John"\n'
        'EMAIL="john@example.com"\n',
        encoding='utf-8',
    )
    with pytest.raises(SystemExit) as exc_info:
        misc.show_info()
    assert exc_info.value.code == 0


# ---------------------------------------------------------------------------
# _format_info_field
# ---------------------------------------------------------------------------

def test_format_info_field_single_value(bump: BumpHandler) -> None:
    """Single value produces inline format."""
    result = bump._format_info_field('MD5SUM', ['abc123'])
    assert result == 'MD5SUM="abc123"'


def test_format_info_field_multiple_values(bump: BumpHandler) -> None:
    """Multiple values produce multi-line format with backslash continuation."""
    result = bump._format_info_field('MD5SUM', ['abc123', 'def456'])
    assert result.startswith('MD5SUM="abc123 \\\n')
    assert result.endswith('def456"')


# ---------------------------------------------------------------------------
# _check_url_accessibility
# ---------------------------------------------------------------------------

def test_check_url_accessibility_ok(download: DownloadHandler,
                                    capsys: pytest.CaptureFixture) -> None:
    """HTTP 200 response prints OK."""
    mock_resp = MagicMock()
    mock_resp.status = 200
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.return_value = mock_resp
        download._check_url_accessibility(['https://example.com/file.tar.gz'])
    assert 'OK' in capsys.readouterr().out


def test_check_url_accessibility_404(download: DownloadHandler,
                                     capsys: pytest.CaptureFixture) -> None:
    """HTTP 404 response prints FAILED."""
    mock_resp = MagicMock()
    mock_resp.status = 404
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.return_value = mock_resp
        download._check_url_accessibility(['https://example.com/file.tar.gz'])
    assert 'FAILED' in capsys.readouterr().out


def test_check_url_accessibility_405(download: DownloadHandler,
                                     capsys: pytest.CaptureFixture) -> None:
    """HTTP 405 response prints WARN."""
    mock_resp = MagicMock()
    mock_resp.status = 405
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.return_value = mock_resp
        download._check_url_accessibility(['https://example.com/file.tar.gz'])
    assert 'WARN' in capsys.readouterr().out


def test_check_url_accessibility_connection_error(download: DownloadHandler,
                                                   capsys: pytest.CaptureFixture) -> None:
    """Connection error prints FAILED."""
    with patch('urllib3.PoolManager') as mock_pm:
        mock_pm.return_value.request.side_effect = Exception('connection refused')
        download._check_url_accessibility(['https://example.com/file.tar.gz'])
    assert 'FAILED' in capsys.readouterr().out


# ---------------------------------------------------------------------------
# sources_download --check-urls
# ---------------------------------------------------------------------------

def test_sources_download_check_urls_calls_accessibility(download: DownloadHandler) -> None:
    """With check_urls=True, _check_url_accessibility is called instead of downloading."""
    download.ctx.info.download_x86 = 'https://example.com/file.tar.gz'
    download.ctx.info.md5sum_x86 = 'abc123'
    download.ctx.info.download_x86_64 = ''
    download.ctx.info.md5sum_x86_64 = ''
    download.files.read_info_file = MagicMock()

    with patch.object(download, '_check_url_accessibility') as mock_check, \
         pytest.raises(SystemExit) as exc_info:
        download.sources_download(check_urls=True)
    mock_check.assert_called_once()
    assert exc_info.value.code == 0


# ---------------------------------------------------------------------------
# create_package (strict validation prompt)
# ---------------------------------------------------------------------------

def test_create_package_skips_validation_when_declined(misc: MiscHandler,
                                                        tmp_path: Path) -> None:
    """When user declines validation prompt, packaging proceeds without validate."""
    misc.ctx.current_folder = tmp_path
    misc.files.read_info_file = MagicMock()
    name = misc.ctx.prg_name
    for f in [f'{name}.SlackBuild', f'{name}.info', 'slack-desc', 'README']:
        (tmp_path / f).write_text('x', encoding='utf-8')

    with patch('sbo_create.handlers.misc.confirm', return_value=False), \
         patch.object(misc.validate, 'collect_validation_errors',
                      return_value=([], [])) as mock_val, \
         patch('tarfile.open'), \
         pytest.raises(SystemExit) as exc_info:
        misc.create_package()

    mock_val.assert_not_called()
    assert exc_info.value.code == 0


def test_create_package_aborts_on_strict_errors(misc: MiscHandler,
                                                 tmp_path: Path) -> None:
    """When user confirms validation and errors are found, packaging is aborted."""
    misc.ctx.current_folder = tmp_path
    misc.files.read_info_file = MagicMock()

    with patch('sbo_create.handlers.misc.confirm', return_value=True), \
         patch.object(misc.validate, 'collect_validation_errors',
                      return_value=(['some error'], [])), \
         pytest.raises(SystemExit) as exc_info:
        misc.create_package()

    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# confirm() before_newline / after_newline
# ---------------------------------------------------------------------------

def test_confirm_no_before_newline(capsys: pytest.CaptureFixture) -> None:
    """before_newline=False suppresses the leading blank line."""
    with patch('builtins.input', return_value='n'):
        from sbo_create.models import confirm
        confirm('Continue?', before_newline=False, after_newline=False)
    out = capsys.readouterr().out
    assert not out.startswith('\n')


def test_confirm_with_before_newline(capsys: pytest.CaptureFixture) -> None:
    """before_newline=True (default) prints a leading blank line."""
    with patch('builtins.input', return_value='n'):
        from sbo_create.models import confirm
        confirm('Continue?', before_newline=True, after_newline=False)
    out = capsys.readouterr().out
    assert out.startswith('\n')


# ---------------------------------------------------------------------------
# HOMEPAGE http:// does not error in regular validate
# ---------------------------------------------------------------------------

def test_validate_info_variables_homepage_http_no_error(validate: ValidateHandler) -> None:
    """HOMEPAGE using http:// does not cause an error in regular validate."""
    validate.ctx.info.homepage = 'http://example.com'
    info_lines = [
        'PRGNAM="myapp"\n', 'VERSION="1.0"\n', 'HOMEPAGE="http://example.com"\n',
        'DOWNLOAD=""\n', 'MD5SUM=""\n', 'DOWNLOAD_x86_64=""\n', 'MD5SUM_x86_64=""\n',
        'REQUIRES=""\n', 'MAINTAINER="Test"\n', 'EMAIL="test@example.com"\n',
    ]
    errors = validate._validate_info_variables(info_lines)
    assert not any('HOMEPAGE' in e for e in errors)


# ---------------------------------------------------------------------------
# edit_files
# ---------------------------------------------------------------------------

def test_edit_files_opens_existing_files(misc: MiscHandler, tmp_path: Path) -> None:
    """edit_files calls the configured editor with existing package files."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'myapp'
    misc.ctx.maintainer.editor = 'vim'
    misc.ctx.maintainer.editor_options = ''
    (tmp_path / 'myapp.info').write_text('', encoding='utf-8')
    (tmp_path / 'myapp.SlackBuild').write_text('', encoding='utf-8')

    with patch('subprocess.run') as mock_run:
        with pytest.raises(SystemExit) as exc_info:
            misc.edit_files()
    assert exc_info.value.code == 0
    called_args = mock_run.call_args[0][0]
    assert called_args[0] == 'vim'
    assert any('myapp.info' in a for a in called_args)
    assert any('myapp.SlackBuild' in a for a in called_args)


def test_edit_files_no_files_exits_1(misc: MiscHandler, tmp_path: Path) -> None:
    """edit_files exits with code 1 when no package files are found."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'missing'
    with pytest.raises(SystemExit) as exc_info:
        misc.edit_files()
    assert exc_info.value.code == 1


def test_edit_files_falls_back_to_vim(misc: MiscHandler, tmp_path: Path) -> None:
    """edit_files uses 'vim' when no editor is configured."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'myapp'
    misc.ctx.maintainer.editor = ''
    misc.ctx.maintainer.editor_options = ''
    (tmp_path / 'myapp.info').write_text('', encoding='utf-8')

    with patch('subprocess.run') as mock_run:
        with pytest.raises(SystemExit):
            misc.edit_files()
    assert mock_run.call_args[0][0][0] == 'vim'


def test_edit_files_editor_not_found_exits_1(misc: MiscHandler, tmp_path: Path) -> None:
    """edit_files exits with code 1 when the configured editor is not found."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'myapp'
    misc.ctx.maintainer.editor = 'nonexistent-editor'
    misc.ctx.maintainer.editor_options = ''
    (tmp_path / 'myapp.info').write_text('', encoding='utf-8')

    with patch('subprocess.run', side_effect=FileNotFoundError):
        with pytest.raises(SystemExit) as exc_info:
            misc.edit_files()
    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# rename_package
# ---------------------------------------------------------------------------

def test_rename_package_renames_files(misc: MiscHandler, tmp_path: Path) -> None:
    """rename_package renames .SlackBuild and .info files after confirmation."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'oldapp'
    (tmp_path / 'oldapp.SlackBuild').write_text('PRGNAM=oldapp\n', encoding='utf-8')
    (tmp_path / 'oldapp.info').write_text('PRGNAM="oldapp"\n', encoding='utf-8')

    with patch('builtins.input', return_value='y'):
        with pytest.raises(SystemExit) as exc_info:
            misc.rename_package('newapp')
    assert exc_info.value.code == 0
    assert (tmp_path / 'newapp.SlackBuild').is_file()
    assert (tmp_path / 'newapp.info').is_file()
    assert not (tmp_path / 'oldapp.SlackBuild').exists()
    assert not (tmp_path / 'oldapp.info').exists()


def test_rename_package_updates_prgnam_in_files(misc: MiscHandler, tmp_path: Path) -> None:
    """rename_package updates PRGNAM references inside .SlackBuild and .info."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'oldapp'
    (tmp_path / 'oldapp.SlackBuild').write_text('PRGNAM=oldapp\n', encoding='utf-8')
    (tmp_path / 'oldapp.info').write_text('PRGNAM="oldapp"\n', encoding='utf-8')

    with patch('builtins.input', return_value='y'):
        with pytest.raises(SystemExit):
            misc.rename_package('newapp')

    assert 'PRGNAM=newapp' in (tmp_path / 'newapp.SlackBuild').read_text(encoding='utf-8')
    assert 'PRGNAM="newapp"' in (tmp_path / 'newapp.info').read_text(encoding='utf-8')


def test_rename_package_updates_slackbuild_header(misc: MiscHandler, tmp_path: Path) -> None:
    """rename_package updates the header comment in .SlackBuild (with angle brackets)."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'oldapp'
    sb_content = '#!/bin/bash\n# Slackware build script for <oldapp>\nPRGNAM=oldapp\n'
    (tmp_path / 'oldapp.SlackBuild').write_text(sb_content, encoding='utf-8')
    (tmp_path / 'oldapp.info').write_text('PRGNAM="oldapp"\n', encoding='utf-8')

    with patch('builtins.input', return_value='y'):
        with pytest.raises(SystemExit):
            misc.rename_package('newapp')

    result = (tmp_path / 'newapp.SlackBuild').read_text(encoding='utf-8')
    assert '# Slackware build script for <newapp>' in result


def test_rename_package_updates_slack_desc(misc: MiscHandler, tmp_path: Path) -> None:
    """rename_package updates prefix in slack-desc lines."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'oldapp'
    (tmp_path / 'oldapp.info').write_text('PRGNAM="oldapp"\n', encoding='utf-8')
    (tmp_path / 'slack-desc').write_text('oldapp: oldapp (a tool)\n', encoding='utf-8')

    with patch('builtins.input', return_value='y'):
        with pytest.raises(SystemExit):
            misc.rename_package('newapp')

    assert 'newapp:' in (tmp_path / 'slack-desc').read_text(encoding='utf-8')


def test_rename_package_user_declines_exits_0(misc: MiscHandler, tmp_path: Path) -> None:
    """rename_package exits 0 without renaming when user declines."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'oldapp'
    (tmp_path / 'oldapp.info').write_text('PRGNAM="oldapp"\n', encoding='utf-8')

    with patch('builtins.input', return_value='n'):
        with pytest.raises(SystemExit) as exc_info:
            misc.rename_package('newapp')
    assert exc_info.value.code == 0
    assert (tmp_path / 'oldapp.info').is_file()


def test_rename_package_same_name_exits_1(misc: MiscHandler, tmp_path: Path) -> None:
    """rename_package exits 1 when new name equals current name."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'myapp'
    with pytest.raises(SystemExit) as exc_info:
        misc.rename_package('myapp')
    assert exc_info.value.code == 1


def test_rename_package_no_files_exits_1(misc: MiscHandler, tmp_path: Path) -> None:
    """rename_package exits with code 1 when no package files are found."""
    misc.ctx.current_folder = tmp_path
    misc.ctx.prg_name = 'ghost'
    with pytest.raises(SystemExit) as exc_info:
        misc.rename_package('newname')
    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# _collect_md5_mismatches / _fix_md5sums
# ---------------------------------------------------------------------------

def _make_tarball(path: Path, content: bytes = b'data') -> str:
    """Write a fake tarball and return its md5 hexdigest."""
    path.write_bytes(content)
    return hashlib.md5(content).hexdigest()


def test_collect_md5_mismatches_no_sources(validate: ValidateHandler,
                                           tmp_path: Path) -> None:
    """No local source files → no mismatches reported."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.download_x86 = 'https://example.com/pkg-1.0.tar.gz'
    validate.ctx.info.md5sum_x86 = 'a' * 32
    validate.ctx.info.download_x86_64 = ''
    validate.ctx.info.md5sum_x86_64 = ''

    assert validate._collect_md5_mismatches() == []


def test_collect_md5_mismatches_checksums_match(validate: ValidateHandler,
                                                tmp_path: Path) -> None:
    """Local file with correct checksum → no mismatches."""
    validate.ctx.current_folder = tmp_path
    correct_md5 = _make_tarball(tmp_path / 'pkg-1.0.tar.gz')
    validate.ctx.info.download_x86 = 'https://example.com/pkg-1.0.tar.gz'
    validate.ctx.info.md5sum_x86 = correct_md5
    validate.ctx.info.download_x86_64 = ''
    validate.ctx.info.md5sum_x86_64 = ''

    assert validate._collect_md5_mismatches() == []


def test_collect_md5_mismatches_wrong_checksum(validate: ValidateHandler,
                                               tmp_path: Path) -> None:
    """Local file with wrong checksum → mismatch tuple returned."""
    validate.ctx.current_folder = tmp_path
    actual_md5 = _make_tarball(tmp_path / 'pkg-1.0.tar.gz')
    old_md5 = 'b' * 32
    validate.ctx.info.download_x86 = 'https://example.com/pkg-1.0.tar.gz'
    validate.ctx.info.md5sum_x86 = old_md5
    validate.ctx.info.download_x86_64 = ''
    validate.ctx.info.md5sum_x86_64 = ''

    result = validate._collect_md5_mismatches()

    assert len(result) == 1
    assert result[0] == ('pkg-1.0.tar.gz', old_md5, actual_md5)


def test_collect_md5_mismatches_multiple_sources(validate: ValidateHandler,
                                                 tmp_path: Path) -> None:
    """Multiple mismatched sources all appear in the result."""
    validate.ctx.current_folder = tmp_path
    md5_a = _make_tarball(tmp_path / 'a-1.0.tar.gz', b'aaa')
    md5_b = _make_tarball(tmp_path / 'b-2.0.tar.gz', b'bbb')
    old_a, old_b = 'a' * 32, 'b' * 32
    validate.ctx.info.download_x86 = (
        'https://example.com/a-1.0.tar.gz \\\n'
        'https://example.com/b-2.0.tar.gz'
    )
    validate.ctx.info.md5sum_x86 = f'{old_a} \\\n{old_b}'
    validate.ctx.info.download_x86_64 = ''
    validate.ctx.info.md5sum_x86_64 = ''

    result = validate._collect_md5_mismatches()

    assert len(result) == 2
    assert ('a-1.0.tar.gz', old_a, md5_a) in result
    assert ('b-2.0.tar.gz', old_b, md5_b) in result


def test_collect_md5_mismatches_x86_64_field(validate: ValidateHandler,
                                              tmp_path: Path) -> None:
    """Mismatch in DOWNLOAD_x86_64/MD5SUM_x86_64 is also detected."""
    validate.ctx.current_folder = tmp_path
    actual_md5 = _make_tarball(tmp_path / 'pkg-x64-1.0.tar.gz')
    old_md5 = 'c' * 32
    validate.ctx.info.download_x86 = ''
    validate.ctx.info.md5sum_x86 = ''
    validate.ctx.info.download_x86_64 = 'https://example.com/pkg-x64-1.0.tar.gz'
    validate.ctx.info.md5sum_x86_64 = old_md5

    result = validate._collect_md5_mismatches()

    assert len(result) == 1
    assert result[0] == ('pkg-x64-1.0.tar.gz', old_md5, actual_md5)


def test_fix_md5sums_no_mismatches_returns_false(validate: ValidateHandler,
                                                  tmp_path: Path) -> None:
    """No mismatches → _fix_md5sums returns False without prompting."""
    validate.ctx.current_folder = tmp_path
    validate.ctx.info.download_x86 = ''
    validate.ctx.info.md5sum_x86 = ''
    validate.ctx.info.download_x86_64 = ''
    validate.ctx.info.md5sum_x86_64 = ''

    with patch('builtins.input') as mock_input:
        result = validate._fix_md5sums()

    assert result is False
    mock_input.assert_not_called()


def test_fix_md5sums_user_declines_returns_false(validate: ValidateHandler,
                                                  tmp_path: Path) -> None:
    """User declines prompt → _fix_md5sums returns False and .info unchanged."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    old_md5 = 'd' * 32
    _make_tarball(tmp_path / 'pkg-1.0.tar.gz')
    validate.ctx.info.download_x86 = 'https://example.com/pkg-1.0.tar.gz'
    validate.ctx.info.md5sum_x86 = old_md5
    validate.ctx.info.download_x86_64 = ''
    validate.ctx.info.md5sum_x86_64 = ''
    info_path = tmp_path / f'{name}.info'
    info_path.write_text(f'MD5SUM="{old_md5}"\n', encoding='utf-8')

    with patch('builtins.input', return_value='n'):
        result = validate._fix_md5sums()

    assert result is False
    assert old_md5 in info_path.read_text(encoding='utf-8')


def test_fix_md5sums_user_confirms_updates_info(validate: ValidateHandler,
                                                 tmp_path: Path) -> None:
    """User confirms → _fix_md5sums returns True and updates .info with new checksum."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    old_md5 = 'e' * 32
    actual_md5 = _make_tarball(tmp_path / 'pkg-1.0.tar.gz', b'realdata')
    validate.ctx.info.download_x86 = 'https://example.com/pkg-1.0.tar.gz'
    validate.ctx.info.md5sum_x86 = old_md5
    validate.ctx.info.download_x86_64 = ''
    validate.ctx.info.md5sum_x86_64 = ''
    info_path = tmp_path / f'{name}.info'
    info_path.write_text(f'MD5SUM="{old_md5}"\n', encoding='utf-8')

    with patch('builtins.input', return_value='y'):
        result = validate._fix_md5sums()

    assert result is True
    content = info_path.read_text(encoding='utf-8')
    assert actual_md5 in content
    assert old_md5 not in content


def test_fix_md5sums_multiple_updates_all(validate: ValidateHandler,
                                           tmp_path: Path) -> None:
    """Multiple mismatches are all replaced in the .info file."""
    validate.ctx.current_folder = tmp_path
    name = validate.ctx.prg_name
    old_a, old_b = 'f' * 32, '0' * 32
    real_a = _make_tarball(tmp_path / 'a-1.0.tar.gz', b'aaa')
    real_b = _make_tarball(tmp_path / 'b-2.0.tar.gz', b'bbb')
    validate.ctx.info.download_x86 = (
        'https://example.com/a-1.0.tar.gz \\\nhttps://example.com/b-2.0.tar.gz'
    )
    validate.ctx.info.md5sum_x86 = f'{old_a} \\\n{old_b}'
    validate.ctx.info.download_x86_64 = ''
    validate.ctx.info.md5sum_x86_64 = ''
    info_path = tmp_path / f'{name}.info'
    info_path.write_text(
        f'MD5SUM="{old_a} \\\n{old_b}"\n', encoding='utf-8'
    )

    with patch('builtins.input', return_value='y'):
        result = validate._fix_md5sums()

    assert result is True
    content = info_path.read_text(encoding='utf-8')
    assert real_a in content
    assert real_b in content
    assert old_a not in content
    assert old_b not in content


# ---------------------------------------------------------------------------
# _require_repo_path / _resolve_repo_path
# ---------------------------------------------------------------------------

def test_require_repo_path_not_configured_exits_1(misc: MiscHandler) -> None:
    """_require_repo_path exits 1 when sbo_repo_path is not configured."""
    misc.ctx.maintainer.sbo_repo_path = ''
    with pytest.raises(SystemExit) as exc_info:
        misc._require_repo_path()
    assert exc_info.value.code == 1


def test_require_repo_path_not_found_exits_1(misc: MiscHandler, tmp_path: Path) -> None:
    """_require_repo_path exits 1 when configured path does not exist."""
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path / 'nonexistent')
    with pytest.raises(SystemExit) as exc_info:
        misc._require_repo_path()
    assert exc_info.value.code == 1


def test_require_repo_path_returns_path(misc: MiscHandler, tmp_path: Path) -> None:
    """_require_repo_path returns the resolved path when it exists."""
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    result = misc._require_repo_path()
    assert result == tmp_path.resolve()


def test_resolve_repo_path_no_config_opens_browser_exits_0(git_handler: GitHandler) -> None:
    """_resolve_repo_path opens browser and exits 0 when unconfigured."""
    git_handler.ctx.maintainer.sbo_repo_path = ''
    with patch('subprocess.run') as mock_run:
        with pytest.raises(SystemExit) as exc_info:
            git_handler._resolve_repo_path()
    assert exc_info.value.code == 0
    mock_run.assert_called_once()
    assert 'xdg-open' in mock_run.call_args[0][0]


def test_resolve_repo_path_xdg_not_found_prints_url(git_handler: GitHandler,
                                                      capsys: pytest.CaptureFixture) -> None:
    """_resolve_repo_path prints the URL when xdg-open is not available."""
    git_handler.ctx.maintainer.sbo_repo_path = ''
    with patch('subprocess.run', side_effect=FileNotFoundError):
        with pytest.raises(SystemExit) as exc_info:
            git_handler._resolve_repo_path()
    assert exc_info.value.code == 0
    assert 'slackbuilds.org/submit' in capsys.readouterr().out


# ---------------------------------------------------------------------------
# _branch_behind_count
# ---------------------------------------------------------------------------

def test_branch_behind_count_returns_int(git_handler: GitHandler, tmp_path: Path) -> None:
    """_branch_behind_count returns the integer from git output."""
    git_handler.ctx.maintainer.git_main_branch = 'master'
    mock_result = MagicMock()
    mock_result.stdout = '5\n'
    with patch('subprocess.run', return_value=mock_result):
        count = git_handler._branch_behind_count(tmp_path)
    assert count == 5


def test_branch_behind_count_returns_zero_on_invalid_output(git_handler: GitHandler,
                                                              tmp_path: Path) -> None:
    """_branch_behind_count returns 0 when git output is not an integer."""
    mock_result = MagicMock()
    mock_result.stdout = 'fatal: not a git repo\n'
    with patch('subprocess.run', return_value=mock_result):
        count = git_handler._branch_behind_count(tmp_path)
    assert count == 0


# ---------------------------------------------------------------------------
# _resolve_push_cmd
# ---------------------------------------------------------------------------

def test_resolve_push_cmd_no_upstream_uses_set_upstream(git_handler: GitHandler,
                                                          tmp_path: Path) -> None:
    """Returns --set-upstream command when no upstream is configured."""
    git_handler.ctx.maintainer.git_branch = 'user/dslackw/updates'
    git_handler.ctx.maintainer.git_push_cmd = 'git push'
    mock_result = MagicMock()
    mock_result.returncode = 1  # no upstream
    with patch('subprocess.run', return_value=mock_result):
        cmd = git_handler._resolve_push_cmd(tmp_path)
    assert '--set-upstream' in cmd
    assert 'user/dslackw/updates' in cmd


def test_resolve_push_cmd_with_upstream_uses_config(git_handler: GitHandler,
                                                      tmp_path: Path) -> None:
    """Returns configured push command when upstream exists."""
    git_handler.ctx.maintainer.git_push_cmd = 'git push'
    mock_result = MagicMock()
    mock_result.returncode = 0  # upstream exists
    with patch('subprocess.run', return_value=mock_result):
        cmd = git_handler._resolve_push_cmd(tmp_path)
    assert cmd == ['git', 'push']


# ---------------------------------------------------------------------------
# sync_repo
# ---------------------------------------------------------------------------

def test_sync_repo_no_branch_configured_exits_1(git_handler: GitHandler,
                                                  tmp_path: Path) -> None:
    """sync_repo exits 1 when git_branch is not configured."""
    git_handler.ctx.maintainer.sbo_repo_path = str(tmp_path)
    git_handler.ctx.maintainer.git_branch = ''
    with pytest.raises(SystemExit) as exc_info:
        git_handler.sync_repo()
    assert exc_info.value.code == 1


def test_sync_repo_runs_all_steps(git_handler: GitHandler, tmp_path: Path) -> None:
    """sync_repo runs checkout, pull, branch -D and checkout -B in order."""
    git_handler.ctx.maintainer.sbo_repo_path = str(tmp_path)
    git_handler.ctx.maintainer.git_branch = 'user/dslackw/updates'
    git_handler.ctx.maintainer.git_main_branch = 'master'

    results = [
        MagicMock(returncode=0),  # rev-parse --verify (branch exists)
        MagicMock(returncode=0),  # git checkout master
        MagicMock(returncode=0),  # git pull
        MagicMock(returncode=0),  # git branch -D
        MagicMock(returncode=0),  # git checkout -B
    ]
    with patch('subprocess.run', side_effect=results):
        with pytest.raises(SystemExit) as exc_info:
            git_handler.sync_repo()
    assert exc_info.value.code == 0


def test_sync_repo_skips_delete_when_branch_not_exists(git_handler: GitHandler,
                                                         tmp_path: Path) -> None:
    """sync_repo skips git branch -D when the branch does not exist yet."""
    git_handler.ctx.maintainer.sbo_repo_path = str(tmp_path)
    git_handler.ctx.maintainer.git_branch = 'user/dslackw/updates'
    git_handler.ctx.maintainer.git_main_branch = 'master'

    calls: list = []

    def fake_run(cmd, **_kwargs):
        calls.append(cmd)
        return MagicMock(returncode=1 if '--verify' in cmd else 0)

    with patch('subprocess.run', side_effect=fake_run):
        with pytest.raises(SystemExit):
            git_handler.sync_repo()

    executed = [' '.join(c) for c in calls]
    assert not any('branch' in c and '-D' in c for c in executed)


def test_sync_repo_step_failure_exits_1(git_handler: GitHandler, tmp_path: Path) -> None:
    """sync_repo exits 1 when any git step fails."""
    git_handler.ctx.maintainer.sbo_repo_path = str(tmp_path)
    git_handler.ctx.maintainer.git_branch = 'user/dslackw/updates'
    git_handler.ctx.maintainer.git_main_branch = 'master'

    results = [
        MagicMock(returncode=0),  # rev-parse --verify
        MagicMock(returncode=1),  # git checkout master fails
    ]
    with patch('subprocess.run', side_effect=results):
        with pytest.raises(SystemExit) as exc_info:
            git_handler.sync_repo()
    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# submit_package
# ---------------------------------------------------------------------------

def test_submit_package_no_repo_opens_browser(git_handler: GitHandler) -> None:
    """submit_package opens browser and exits 0 when no repo configured."""
    git_handler.ctx.maintainer.sbo_repo_path = ''
    with patch('subprocess.run') as mock_run:
        with pytest.raises(SystemExit) as exc_info:
            git_handler.submit_package()
    assert exc_info.value.code == 0
    assert 'xdg-open' in mock_run.call_args[0][0]


def test_submit_package_warns_when_branch_behind(git_handler: GitHandler,
                                                   tmp_path: Path,
                                                   capsys: pytest.CaptureFixture) -> None:
    """submit_package prints a warning when branch is behind main."""
    git_handler.ctx.maintainer.sbo_repo_path = str(tmp_path)
    git_handler.ctx.maintainer.git_branch = 'user/dslackw/updates'
    git_handler.ctx.maintainer.git_main_branch = 'master'

    with patch.object(git_handler, '_branch_behind_count', return_value=5), \
         patch('builtins.input', return_value='n'):
        with pytest.raises(SystemExit) as exc_info:
            git_handler.submit_package()

    assert exc_info.value.code == 0
    assert 'behind' in capsys.readouterr().out


def test_submit_package_commit_and_push(git_handler: GitHandler, tmp_path: Path) -> None:
    """submit_package runs git add + commit + push for a single detected package."""
    git_handler.ctx.maintainer.sbo_repo_path = str(tmp_path)
    git_handler.ctx.maintainer.git_commit_cmd = 'git commit -asm'
    git_handler.ctx.maintainer.git_branch = 'user/dslackw/updates'

    pkg_dir = tmp_path / 'python' / 'myapp'
    pkg_dir.mkdir(parents=True)
    (pkg_dir / 'myapp.info').write_text('PRGNAM="myapp"\nVERSION="1.0"\n')

    detected = [('myapp', 'python')]
    with patch.object(git_handler, '_branch_behind_count', return_value=0), \
         patch.object(git_handler, '_detect_packages_from_git', return_value=detected), \
         patch.object(git_handler, '_resolve_push_cmd', return_value=['git', 'push']), \
         patch('builtins.input', side_effect=['', 'y', 'y']), \
         patch('subprocess.run', return_value=MagicMock(returncode=0)) as mock_run:
        with pytest.raises(SystemExit) as exc_info:
            git_handler.submit_package()

    assert exc_info.value.code == 0
    cmds = [c[0][0] for c in mock_run.call_args_list]
    assert any('add' in c for c in cmds)
    assert any('commit' in c for c in cmds)
    assert any('push' in c for c in cmds)


def test_submit_package_commit_only_no_push(git_handler: GitHandler, tmp_path: Path) -> None:
    """submit_package skips push when user declines."""
    git_handler.ctx.maintainer.sbo_repo_path = str(tmp_path)
    git_handler.ctx.maintainer.git_commit_cmd = 'git commit -asm'

    pkg_dir = tmp_path / 'python' / 'myapp'
    pkg_dir.mkdir(parents=True)
    (pkg_dir / 'myapp.info').write_text('PRGNAM="myapp"\nVERSION="1.0"\n')

    detected = [('myapp', 'python')]
    with patch.object(git_handler, '_branch_behind_count', return_value=0), \
         patch.object(git_handler, '_detect_packages_from_git', return_value=detected), \
         patch('builtins.input', side_effect=['', 'n', 'y']), \
         patch('subprocess.run', return_value=MagicMock(returncode=0)) as mock_run:
        with pytest.raises(SystemExit) as exc_info:
            git_handler.submit_package()

    assert exc_info.value.code == 0
    cmds = [c[0][0] for c in mock_run.call_args_list]
    assert any('commit' in c for c in cmds)
    assert not any('push' in c for c in cmds)


# ---------------------------------------------------------------------------
# diff_package
# ---------------------------------------------------------------------------

def test_diff_package_not_in_sbo_with_name(misc: MiscHandler, tmp_path: Path,
                                            capsys: pytest.CaptureFixture) -> None:
    """diff_package prints 'not found' error when explicit NAME is not in SBo cache."""
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)

    with patch.object(misc.files, 'fetch_sbo_packages', return_value={}):
        misc.diff_package(['nonexistent'])

    out = capsys.readouterr().out
    assert 'not found' in out
    assert 'No differences found' not in out


def test_diff_package_not_in_sbo_from_cwd(misc: MiscHandler, tmp_path: Path,
                                           capsys: pytest.CaptureFixture) -> None:
    """diff_package prints helpful hint when no NAME given and cwd is not a package."""
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    misc.ctx.prg_name = 'slackbuilds'

    with patch.object(misc.files, 'fetch_sbo_packages', return_value={}):
        misc.diff_package([])

    out = capsys.readouterr().out
    assert 'No package given' in out
    assert 'No differences found' not in out


def test_diff_package_repo_dir_missing(misc: MiscHandler, tmp_path: Path,
                                       capsys: pytest.CaptureFixture) -> None:
    """diff_package prints error when repo dir for package is missing."""
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    misc.ctx.prg_name = 'myapp'

    packages = {'myapp': 'python  My app'}
    with patch.object(misc.files, 'fetch_sbo_packages', return_value=packages):
        misc.diff_package([misc.ctx.prg_name])

    out = capsys.readouterr().out
    assert 'not found' in out
    assert 'No differences found' not in out


def test_diff_package_no_differences(misc: MiscHandler, tmp_path: Path,
                                     capsys: pytest.CaptureFixture) -> None:
    """diff_package prints 'No differences found' when files are identical (from_cwd)."""
    repo_pkg = tmp_path / 'python' / 'myapp'
    repo_pkg.mkdir(parents=True)

    content = 'PRGNAM="myapp"\nVERSION="1.0"\n'
    (repo_pkg / 'myapp.info').write_text(content, encoding='utf-8')
    (misc.ctx.current_folder / 'myapp.info').write_text(content, encoding='utf-8')

    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    misc.ctx.prg_name = 'myapp'

    packages = {'myapp': 'python  My app'}
    with patch.object(misc.files, 'fetch_sbo_packages', return_value=packages):
        misc.diff_package([])  # from_cwd: local_path = current_folder

    assert 'No differences found' in capsys.readouterr().out


def test_diff_package_shows_diff(misc: MiscHandler, tmp_path: Path,
                                 capsys: pytest.CaptureFixture) -> None:
    """diff_package prints unified diff when files differ (explicit NAME)."""
    repo_pkg = tmp_path / 'python' / 'myapp'
    repo_pkg.mkdir(parents=True)

    (repo_pkg / 'myapp.info').write_text('VERSION="1.0"\n', encoding='utf-8')
    local_pkg = misc.ctx.current_folder / 'myapp'
    local_pkg.mkdir()
    (local_pkg / 'myapp.info').write_text('VERSION="2.0"\n', encoding='utf-8')

    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    misc.ctx.prg_name = 'myapp'

    packages = {'myapp': 'python  My app'}
    with patch.object(misc.files, 'fetch_sbo_packages', return_value=packages):
        misc.diff_package(['myapp'])

    out = capsys.readouterr().out
    assert '1.0' in out
    assert '2.0' in out


def test_diff_package_context_lines(misc: MiscHandler, tmp_path: Path,
                                    capsys: pytest.CaptureFixture) -> None:
    """diff_package respects the context parameter (explicit NAME)."""
    repo_pkg = tmp_path / 'python' / 'myapp'
    repo_pkg.mkdir(parents=True)

    repo_content = ''.join(f'line{i}\n' for i in range(20)) + 'old\n'
    local_content = ''.join(f'line{i}\n' for i in range(20)) + 'new\n'
    (repo_pkg / 'myapp.info').write_text(repo_content, encoding='utf-8')
    local_pkg = misc.ctx.current_folder / 'myapp'
    local_pkg.mkdir()
    (local_pkg / 'myapp.info').write_text(local_content, encoding='utf-8')

    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    misc.ctx.prg_name = 'myapp'

    packages = {'myapp': 'python  My app'}
    with patch.object(misc.files, 'fetch_sbo_packages', return_value=packages):
        misc.diff_package([misc.ctx.prg_name], context=1)

    out = capsys.readouterr().out
    assert 'old' in out
    assert 'new' in out
    assert 'line17' not in out


def test_diff_package_multiple_names(misc: MiscHandler, tmp_path: Path,
                                     capsys: pytest.CaptureFixture) -> None:
    """diff_package diffs multiple packages, each in its own subdirectory."""
    for pkg in ('pkg1', 'pkg2'):
        repo_pkg = tmp_path / 'python' / pkg
        repo_pkg.mkdir(parents=True)
        (repo_pkg / f'{pkg}.info').write_text(f'VERSION="1.0"\n', encoding='utf-8')
        local_pkg = misc.ctx.current_folder / pkg
        local_pkg.mkdir()
        (local_pkg / f'{pkg}.info').write_text(f'VERSION="2.0"\n', encoding='utf-8')

    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    packages = {'pkg1': 'python  Pkg1', 'pkg2': 'python  Pkg2'}
    with patch.object(misc.files, 'fetch_sbo_packages', return_value=packages):
        misc.diff_package(['pkg1', 'pkg2'])

    out = capsys.readouterr().out
    assert 'pkg1.info' in out
    assert 'pkg2.info' in out
    assert '1.0' in out
    assert '2.0' in out


# ---------------------------------------------------------------------------
# SboResolver — find_pkg_dir
# ---------------------------------------------------------------------------

def _make_pkg(base: Path, category: str, name: str) -> Path:
    """Create a minimal package directory inside a fake local SBo repo."""
    pkg = base / category / name
    pkg.mkdir(parents=True)
    (pkg / f'{name}.info').write_text(
        f'PRGNAM="{name}"\nVERSION="1.0"\nREQUIRES=""\n', encoding='utf-8')
    return pkg


def _make_resolver(ctx: AppContext) -> SboResolver:
    files = FileHandler(ctx)
    return SboResolver(ctx, files)


def test_find_pkg_dir_returns_path_when_found(ctx: AppContext, tmp_path: Path) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    ctx.maintainer.sbo_repo_path = str(tmp_path)
    result = _make_resolver(ctx).find_pkg_dir('myapp')
    assert result is not None
    assert result.name == 'myapp'


def test_find_pkg_dir_returns_none_when_not_found(ctx: AppContext, tmp_path: Path) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    ctx.maintainer.sbo_repo_path = str(tmp_path)
    assert _make_resolver(ctx).find_pkg_dir('other') is None


def test_find_pkg_dir_returns_none_without_repo(ctx: AppContext) -> None:
    assert _make_resolver(ctx).find_pkg_dir('myapp') is None


def test_find_pkg_dir_returns_none_for_nonexistent_repo(ctx: AppContext,
                                                         tmp_path: Path) -> None:
    ctx.maintainer.sbo_repo_path = str(tmp_path / 'no_such_dir')
    assert _make_resolver(ctx).find_pkg_dir('myapp') is None


# ---------------------------------------------------------------------------
# SboResolver — load from local repo
# ---------------------------------------------------------------------------

def test_resolver_load_from_local_repo_populates_packages(ctx: AppContext,
                                                           tmp_path: Path) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    _make_pkg(tmp_path, 'system', 'mytools')
    ctx.maintainer.sbo_repo_path = str(tmp_path)
    _make_resolver(ctx).load()
    assert 'myapp' in ctx.sbo_packages
    assert ctx.sbo_packages['myapp'].startswith('python  ')
    assert 'mytools' in ctx.sbo_packages
    assert ctx.sbo_packages['mytools'].startswith('system  ')


def test_resolver_load_from_local_repo_populates_versions(ctx: AppContext,
                                                           tmp_path: Path) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    ctx.maintainer.sbo_repo_path = str(tmp_path)
    _make_resolver(ctx).load()
    assert ctx.sbo_versions.get('myapp') == '1.0'


def test_resolver_load_case_sensitive_name(ctx: AppContext, tmp_path: Path) -> None:
    _make_pkg(tmp_path, 'python', 'MyApp')
    ctx.maintainer.sbo_repo_path = str(tmp_path)
    _make_resolver(ctx).load()
    assert 'MyApp' in ctx.sbo_packages


def test_resolver_load_nonexistent_repo_falls_back_to_json(ctx: AppContext,
                                                            tmp_path: Path) -> None:
    ctx.maintainer.sbo_repo_path = str(tmp_path / 'no_such')
    resolver = _make_resolver(ctx)
    with patch.object(resolver.files, 'fetch_sbo_packages') as mock_fetch:
        resolver.load()
    mock_fetch.assert_called_once()


# ---------------------------------------------------------------------------
# is_the_sbo_exists — local branch
# ---------------------------------------------------------------------------

def test_is_the_sbo_exists_local_found_exits_0(misc: MiscHandler,
                                                tmp_path: Path,
                                                capsys: pytest.CaptureFixture) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    with pytest.raises(SystemExit) as exc_info:
        misc.is_the_sbo_exists(['myapp'])
    assert exc_info.value.code == 0
    assert 'EXISTS' in capsys.readouterr().out


def test_is_the_sbo_exists_local_not_found_exits_1(misc: MiscHandler,
                                                    tmp_path: Path,
                                                    capsys: pytest.CaptureFixture) -> None:
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    with pytest.raises(SystemExit) as exc_info:
        misc.is_the_sbo_exists(['no_such'])
    assert exc_info.value.code == 1
    assert 'NOT FOUND' in capsys.readouterr().out


def test_is_the_sbo_exists_local_partial_match_exits_0(misc: MiscHandler,
                                                        tmp_path: Path,
                                                        capsys: pytest.CaptureFixture) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    with pytest.raises(SystemExit) as exc_info:
        misc.is_the_sbo_exists(['myapp', 'missing'])
    assert exc_info.value.code == 0
    out = capsys.readouterr().out
    assert 'EXISTS' in out
    assert 'NOT FOUND' in out


# ---------------------------------------------------------------------------
# search_sbo_packages — local branch
# ---------------------------------------------------------------------------

def test_search_sbo_packages_local_finds_match(misc: MiscHandler,
                                                tmp_path: Path,
                                                capsys: pytest.CaptureFixture) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    with pytest.raises(SystemExit) as exc_info:
        misc.search_sbo_packages(['myapp'])
    assert exc_info.value.code == 0
    assert 'myapp' in capsys.readouterr().out


def test_search_sbo_packages_local_partial_match(misc: MiscHandler,
                                                  tmp_path: Path,
                                                  capsys: pytest.CaptureFixture) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    _make_pkg(tmp_path, 'system', 'mytools')
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    with pytest.raises(SystemExit) as exc_info:
        misc.search_sbo_packages(['my'])
    assert exc_info.value.code == 0
    out = capsys.readouterr().out
    assert 'myapp' in out
    assert 'mytools' in out


def test_search_sbo_packages_local_no_match_exits_1(misc: MiscHandler,
                                                     tmp_path: Path,
                                                     capsys: pytest.CaptureFixture) -> None:
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path)
    with pytest.raises(SystemExit) as exc_info:
        misc.search_sbo_packages(['zzz'])
    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# fetch_package — local branch
# ---------------------------------------------------------------------------

def test_fetch_package_local_copies_to_current_folder(fetch: FetchHandler,
                                                       tmp_path: Path,
                                                       capsys: pytest.CaptureFixture) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    fetch.ctx.maintainer.sbo_repo_path = str(tmp_path)
    fetch.fetch_package('myapp')
    assert (fetch.ctx.current_folder / 'myapp').is_dir()


def test_fetch_package_local_list_files(fetch: FetchHandler,
                                        tmp_path: Path,
                                        capsys: pytest.CaptureFixture) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    fetch.ctx.maintainer.sbo_repo_path = str(tmp_path)
    fetch.fetch_package('myapp', list_files=True)
    assert 'myapp.info' in capsys.readouterr().out


def test_fetch_package_local_read_file(fetch: FetchHandler,
                                       tmp_path: Path,
                                       capsys: pytest.CaptureFixture) -> None:
    _make_pkg(tmp_path, 'python', 'myapp')
    fetch.ctx.maintainer.sbo_repo_path = str(tmp_path)
    fetch.fetch_package('myapp', read_files=['myapp.info'])
    assert 'VERSION' in capsys.readouterr().out


def test_fetch_package_local_not_found_falls_back(fetch: FetchHandler,
                                                   tmp_path: Path) -> None:
    fetch.ctx.maintainer.sbo_repo_path = str(tmp_path)
    with patch.object(fetch.files, 'fetch_sbo_packages', return_value={}):
        with pytest.raises(SystemExit) as exc_info:
            fetch.fetch_package('no_such')
    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# _view_list_local
# ---------------------------------------------------------------------------

def test_view_list_local_prints_files(fetch: FetchHandler,
                                      tmp_path: Path,
                                      capsys: pytest.CaptureFixture) -> None:
    pkg = _make_pkg(tmp_path, 'python', 'myapp')
    fetch._view_list_local(pkg, 'myapp')
    assert 'myapp.info' in capsys.readouterr().out


# ---------------------------------------------------------------------------
# _view_file_local
# ---------------------------------------------------------------------------

def test_view_file_local_prints_content(fetch: FetchHandler,
                                        tmp_path: Path,
                                        capsys: pytest.CaptureFixture) -> None:
    pkg = _make_pkg(tmp_path, 'python', 'myapp')
    fetch._view_file_local(pkg, 'myapp', 'myapp.info')
    assert 'VERSION' in capsys.readouterr().out


def test_view_file_local_missing_file_exits_1(fetch: FetchHandler,
                                               tmp_path: Path) -> None:
    pkg = _make_pkg(tmp_path, 'python', 'myapp')
    with pytest.raises(SystemExit) as exc_info:
        fetch._view_file_local(pkg, 'myapp', 'README')
    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# _fetch_copy_local
# ---------------------------------------------------------------------------

def test_fetch_copy_local_copies_directory(fetch: FetchHandler,
                                           tmp_path: Path) -> None:
    pkg = _make_pkg(tmp_path, 'python', 'myapp')
    fetch._fetch_copy_local(pkg, 'myapp')
    assert (fetch.ctx.current_folder / 'myapp').is_dir()


def test_fetch_copy_local_overwrites_on_confirm(fetch: FetchHandler,
                                                tmp_path: Path) -> None:
    pkg = _make_pkg(tmp_path, 'python', 'myapp')
    dest = fetch.ctx.current_folder / 'myapp'
    dest.mkdir()
    with patch('builtins.input', return_value='y'):
        fetch._fetch_copy_local(pkg, 'myapp')
    assert (dest / 'myapp.info').is_file()


def test_fetch_copy_local_declines_overwrite_exits_0(fetch: FetchHandler,
                                                      tmp_path: Path) -> None:
    pkg = _make_pkg(tmp_path, 'python', 'myapp')
    dest = fetch.ctx.current_folder / 'myapp'
    dest.mkdir()
    with patch('builtins.input', return_value='n'):
        fetch._fetch_copy_local(pkg, 'myapp')


# ---------------------------------------------------------------------------
# copy_package
# ---------------------------------------------------------------------------

def _make_local_pkg(base: Path, name: str) -> tuple[Path, Path]:
    """Create a fake local package dir and repo dir, return (local, repo_pkg)."""
    local = base / 'local' / name
    local.mkdir(parents=True)
    (local / f'{name}.info').write_text(f'PRGNAM="{name}"\n')
    (local / f'{name}.SlackBuild').write_text('#!/bin/bash\n')
    (local / 'slack-desc').write_text('desc\n')
    (local / f'{name}-1.0.tar.gz').write_text('tarball')  # should be excluded

    repo_pkg = base / 'repo' / 'network' / name
    repo_pkg.mkdir(parents=True)
    return local, repo_pkg


def test_copy_package_copies_files(misc: MiscHandler, tmp_path: Path,
                                   capsys: pytest.CaptureFixture) -> None:
    local, repo_pkg = _make_local_pkg(tmp_path, 'myapp')
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path / 'repo')
    misc.ctx.current_folder = local
    misc.ctx.prg_name = 'myapp'
    misc.ctx.sbo_packages = {'myapp': 'network myapp'}

    with patch('builtins.input', return_value='y'):
        with pytest.raises(SystemExit) as exc_info:
            misc.copy_package()
    assert exc_info.value.code == 0

    assert (repo_pkg / 'myapp.info').is_file()
    assert (repo_pkg / 'myapp.SlackBuild').is_file()
    assert (repo_pkg / 'slack-desc').is_file()
    assert not (repo_pkg / 'myapp-1.0.tar.gz').exists()

    out = capsys.readouterr().out
    assert 'COPIED' in out
    assert 'Skipped' in out
    assert 'myapp-1.0.tar.gz' in out


def test_copy_package_aborts_on_decline(misc: MiscHandler, tmp_path: Path) -> None:
    local, _ = _make_local_pkg(tmp_path, 'myapp')
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path / 'repo')
    misc.ctx.current_folder = local
    misc.ctx.prg_name = 'myapp'
    misc.ctx.sbo_packages = {'myapp': 'network myapp'}

    with patch('builtins.input', return_value='n'):
        with pytest.raises(SystemExit) as exc_info:
            misc.copy_package()
    assert exc_info.value.code == 0


def test_copy_package_not_in_sbo_exits(misc: MiscHandler, tmp_path: Path,
                                        capsys: pytest.CaptureFixture) -> None:
    local, _ = _make_local_pkg(tmp_path, 'myapp')
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path / 'repo')
    misc.ctx.current_folder = local
    misc.ctx.prg_name = 'myapp'
    misc.ctx.sbo_packages = {}

    with patch.object(misc.files, 'fetch_sbo_packages', return_value={}):
        with pytest.raises(SystemExit) as exc_info:
            misc.copy_package()
    assert exc_info.value.code == 1
    assert 'not found' in capsys.readouterr().out


def test_copy_package_no_repo_path_exits(misc: MiscHandler, tmp_path: Path,
                                          capsys: pytest.CaptureFixture) -> None:
    misc.ctx.maintainer.sbo_repo_path = ''
    with pytest.raises(SystemExit) as exc_info:
        misc.copy_package()
    assert exc_info.value.code == 1
    assert 'not configured' in capsys.readouterr().out


def test_copy_package_oserror_exits(misc: MiscHandler, tmp_path: Path,
                                    capsys: pytest.CaptureFixture) -> None:
    local, _ = _make_local_pkg(tmp_path, 'myapp')
    misc.ctx.maintainer.sbo_repo_path = str(tmp_path / 'repo')
    misc.ctx.current_folder = local
    misc.ctx.prg_name = 'myapp'
    misc.ctx.sbo_packages = {'myapp': 'network myapp'}

    with patch('shutil.copy2', side_effect=OSError('permission denied')):
        with patch('builtins.input', return_value='y'):
            with pytest.raises(SystemExit) as exc_info:
                misc.copy_package()
    assert exc_info.value.code == 1
    assert 'failed to copy' in capsys.readouterr().out


# ---------------------------------------------------------------------------
# FixHandler._fix_slack_desc_homepage_http
# ---------------------------------------------------------------------------

from sbo_create.handlers.fix import FixHandler  # noqa: E402
from sbo_create.handlers.files import FileHandler as _FileHandler  # noqa: E402


@pytest.fixture
def fix(ctx: AppContext, tmp_path: Path) -> FixHandler:
    """FixHandler wired with a temporary current_folder."""
    ctx.current_folder = tmp_path
    files = _FileHandler(ctx)
    return FixHandler(ctx, files)


def test_fix_slack_desc_homepage_http_replaces(fix: FixHandler, tmp_path: Path) -> None:
    """http:// in Homepage: line is replaced with https://."""
    fix.ctx.prg_name = 'myapp'
    _write_slack_desc(tmp_path, 'myapp', 'http://example.com')
    result = fix._fix_slack_desc_homepage_http()
    assert result is True
    sd = tmp_path / 'slack-desc'
    assert 'https://example.com' in sd.read_text(encoding='utf-8')
    assert 'http://' not in sd.read_text(encoding='utf-8')


def test_fix_slack_desc_homepage_http_already_https(fix: FixHandler, tmp_path: Path) -> None:
    """https:// in Homepage: line is left unchanged; returns False."""
    fix.ctx.prg_name = 'myapp'
    _write_slack_desc(tmp_path, 'myapp', 'https://example.com')
    result = fix._fix_slack_desc_homepage_http()
    assert result is False


def test_fix_slack_desc_homepage_http_no_file(fix: FixHandler) -> None:
    """Missing slack-desc returns False without error."""
    fix.ctx.prg_name = 'myapp'
    result = fix._fix_slack_desc_homepage_http()
    assert result is False


def test_fix_slack_desc_homepage_http_no_homepage_line(fix: FixHandler, tmp_path: Path) -> None:
    """slack-desc without a Homepage: line returns False."""
    fix.ctx.prg_name = 'myapp'
    (tmp_path / 'slack-desc').write_text('myapp: myapp (some app)\nmyapp:\n', encoding='utf-8')
    result = fix._fix_slack_desc_homepage_http()
    assert result is False


# ---------------------------------------------------------------------------
# ValidateHandler.validate_all
# ---------------------------------------------------------------------------

def _make_valid_pkg(repo: Path, category: str, name: str, maintainer: str) -> Path:
    """Create a minimal but valid package dir inside a fake SBo repo."""
    pkg = repo / category / name
    pkg.mkdir(parents=True)
    (pkg / f'{name}.info').write_text(
        f'PRGNAM="{name}"\n'
        f'VERSION="1.0"\n'
        f'HOMEPAGE="https://example.com"\n'
        f'DOWNLOAD="UNSUPPORTED"\n'
        f'MD5SUM=""\n'
        f'DOWNLOAD_x86_64="UNSUPPORTED"\n'
        f'MD5SUM_x86_64=""\n'
        f'REQUIRES=""\n'
        f'MAINTAINER="{maintainer}"\n'
        f'EMAIL="user@example.com"\n',
        encoding='utf-8',
    )
    (pkg / f'{name}.SlackBuild').write_text(
        f'#!/bin/bash\n'
        f'BUILD=${{BUILD:-1}}\n'
        f'TAG=${{TAG:-_SBo}}\n'
        f'PKGTYPE=${{PKGTYPE:-tgz}}\n',
        encoding='utf-8',
    )
    ruler = ' ' * len(name) + '|-----handy-ruler' + '-' * 54 + '|'
    (pkg / 'slack-desc').write_text(
        '# HOW TO EDIT THIS FILE:\n' * 7
        + ruler + '\n'
        + f'{name}: {name} (short description)\n'
        + f'{name}:\n' * 10,
        encoding='utf-8',
    )
    (pkg / 'README').write_text('README\n', encoding='utf-8')
    return pkg


def test_validate_all_no_repo_path_exits(validate: ValidateHandler,
                                         capsys: pytest.CaptureFixture) -> None:
    """validate_all exits 1 when sbo_repo_path is not configured."""
    validate.ctx.maintainer.sbo_repo_path = ''
    with pytest.raises(SystemExit) as exc_info:
        validate.validate_all()
    assert exc_info.value.code == 1
    assert 'not configured' in capsys.readouterr().out


def test_validate_all_repo_not_found_exits(validate: ValidateHandler, tmp_path: Path,
                                           capsys: pytest.CaptureFixture) -> None:
    """validate_all exits 1 when sbo_repo_path directory does not exist."""
    validate.ctx.maintainer.sbo_repo_path = str(tmp_path / 'nonexistent')
    with pytest.raises(SystemExit) as exc_info:
        validate.validate_all()
    assert exc_info.value.code == 1
    assert 'not found' in capsys.readouterr().out


def test_validate_all_no_packages_exits(validate: ValidateHandler, tmp_path: Path,
                                        capsys: pytest.CaptureFixture) -> None:
    """validate_all exits 1 when no packages match the maintainer name."""
    validate.ctx.maintainer.sbo_repo_path = str(tmp_path)
    validate.ctx.maintainer.name = 'John Doe'
    _make_valid_pkg(tmp_path, 'network', 'myapp', 'Someone Else')
    with pytest.raises(SystemExit) as exc_info:
        validate.validate_all()
    assert exc_info.value.code == 1
    assert 'No packages found' in capsys.readouterr().out


def test_validate_all_all_pass_exits_0(validate: ValidateHandler, tmp_path: Path,
                                       capsys: pytest.CaptureFixture) -> None:
    """validate_all exits 0 when all packages pass."""
    validate.ctx.maintainer.sbo_repo_path = str(tmp_path)
    validate.ctx.maintainer.name = 'John Doe'
    _make_valid_pkg(tmp_path, 'network', 'myapp', 'John Doe')
    _make_valid_pkg(tmp_path, 'python', 'mylib', 'John Doe')
    with pytest.raises(SystemExit) as exc_info:
        validate.validate_all()
    assert exc_info.value.code == 0
    out = capsys.readouterr().out
    assert 'network/myapp' in out
    assert 'python/mylib' in out
    assert 'OK' in out


def test_validate_all_one_fails_exits_1(validate: ValidateHandler, tmp_path: Path,
                                        capsys: pytest.CaptureFixture) -> None:
    """validate_all exits 1 when at least one package fails."""
    validate.ctx.maintainer.sbo_repo_path = str(tmp_path)
    validate.ctx.maintainer.name = 'John Doe'
    _make_valid_pkg(tmp_path, 'network', 'myapp', 'John Doe')
    # broken package: .info with wrong MAINTAINER field format to trigger error
    broken = tmp_path / 'python' / 'broken'
    broken.mkdir(parents=True)
    (broken / 'broken.info').write_text(
        'PRGNAM="broken"\nVERSION=""\nHOMEPAGE=""\n'
        'DOWNLOAD=""\nMD5SUM=""\nDOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n'
        'REQUIRES=""\nMAINTAINER="John Doe"\nEMAIL="user@example.com"\n',
        encoding='utf-8',
    )
    with pytest.raises(SystemExit) as exc_info:
        validate.validate_all()
    assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# _print_tree_branch / deps_tree / reverse_deps
# ---------------------------------------------------------------------------

# Small package graph used by tree tests:
#   pkgA → pkgB → pkgD
#        → pkgC
_GRAPH: dict[str, list[str]] = {
    'pkgA': ['pkgB', 'pkgC'],
    'pkgB': ['pkgD'],
    'pkgC': [],
    'pkgD': [],
}
_GRAPH_PKGS: dict[str, str] = {k: f'cat {k}' for k in _GRAPH}


def _setup_graph(misc: MiscHandler) -> None:
    """Wire the test graph into misc.ctx and mock fetch_sbo_requires."""
    misc.ctx.sbo_packages = dict(_GRAPH_PKGS)
    misc.ctx.sbo_requires = dict(_GRAPH)
    misc.resolver.load = MagicMock()
    misc.files.fetch_sbo_requires = MagicMock(
        side_effect=lambda name: _GRAPH.get(name, [])
    )


# -- _print_tree_branch ------------------------------------------------------

def test_print_tree_branch_last_connector(capsys: pytest.CaptureFixture) -> None:
    """is_last=True prints └─ connector."""
    from sbo_create.handlers.misc import _print_tree_branch
    seen: set[str] = set()
    collected: set[str] = set()
    _print_tree_branch('pkgX', '  ', True, seen, collected, lambda _: [])
    assert '└─' in capsys.readouterr().out


def test_print_tree_branch_not_last_connector(capsys: pytest.CaptureFixture) -> None:
    """is_last=False prints ├─ connector."""
    from sbo_create.handlers.misc import _print_tree_branch
    seen: set[str] = set()
    collected: set[str] = set()
    _print_tree_branch('pkgX', '  ', False, seen, collected, lambda _: [])
    assert '├─' in capsys.readouterr().out


def test_print_tree_branch_adds_to_collected(capsys: pytest.CaptureFixture) -> None:
    """Visited package is added to collected."""
    from sbo_create.handlers.misc import _print_tree_branch
    seen: set[str] = set()
    collected: set[str] = set()
    _print_tree_branch('pkgX', '', True, seen, collected, lambda _: [])
    assert 'pkgX' in collected


def test_print_tree_branch_cycle_stops_recursion(capsys: pytest.CaptureFixture) -> None:
    """Package already in seen is printed but not expanded again."""
    from sbo_create.handlers.misc import _print_tree_branch
    seen: set[str] = {'pkgX'}
    collected: set[str] = set()
    get_children = MagicMock(return_value=['pkgY'])
    _print_tree_branch('pkgX', '', True, seen, collected, get_children)
    get_children.assert_not_called()
    assert 'pkgX' in collected


def test_print_tree_branch_recurses_into_children(capsys: pytest.CaptureFixture) -> None:
    """Children are printed recursively."""
    from sbo_create.handlers.misc import _print_tree_branch
    seen: set[str] = set()
    collected: set[str] = set()
    children: dict[str, list[str]] = {'pkgA': ['pkgB'], 'pkgB': []}
    _print_tree_branch('pkgA', '', True, seen, collected,
                       lambda p: children.get(p, []))
    out = capsys.readouterr().out
    assert 'pkgA' in out
    assert 'pkgB' in out
    assert collected == {'pkgA', 'pkgB'}


# -- deps_tree ---------------------------------------------------------------

def test_deps_tree_unknown_package_exits_1(misc: MiscHandler,
                                           capsys: pytest.CaptureFixture) -> None:
    """Unknown package name exits with code 1."""
    misc.resolver.load = MagicMock()
    misc.ctx.sbo_packages = {}
    with pytest.raises(SystemExit) as exc_info:
        misc.deps_tree('missing')
    assert exc_info.value.code == 1


def test_deps_tree_no_dependencies(misc: MiscHandler,
                                   capsys: pytest.CaptureFixture) -> None:
    """Package with no REQUIRES prints '(no dependencies)'."""
    misc.resolver.load = MagicMock()
    misc.ctx.sbo_packages = {'pkgA': 'cat pkgA'}
    misc.files.fetch_sbo_requires = MagicMock(return_value=[])
    misc.deps_tree('pkgA')
    assert '(no dependencies)' in capsys.readouterr().out


def test_deps_tree_flat_lists_all_deps(misc: MiscHandler,
                                       capsys: pytest.CaptureFixture) -> None:
    """Flat mode lists direct and transitive packages with counts."""
    _setup_graph(misc)
    misc.deps_tree('pkgA', tree=False)
    out = capsys.readouterr().out
    assert 'pkgB' in out
    assert 'pkgC' in out
    assert 'pkgD' in out
    assert 'direct' in out
    assert 'transitive' in out


def test_deps_tree_flat_connectors(misc: MiscHandler,
                                   capsys: pytest.CaptureFixture) -> None:
    """Flat mode prints branch connectors for direct deps."""
    _setup_graph(misc)
    misc.deps_tree('pkgA', tree=False)
    out = capsys.readouterr().out
    assert '├─' in out or '└─' in out


def test_deps_tree_tree_mode(misc: MiscHandler,
                             capsys: pytest.CaptureFixture) -> None:
    """Tree mode prints all packages with branch connectors and summary line."""
    _setup_graph(misc)
    misc.deps_tree('pkgA', tree=True)
    out = capsys.readouterr().out
    assert 'pkgB' in out
    assert 'pkgC' in out
    assert 'pkgD' in out
    assert ('├─' in out or '└─' in out)
    assert 'direct' in out
    assert 'transitive' in out


def test_deps_tree_tree_cycle_no_infinite_loop(misc: MiscHandler,
                                               capsys: pytest.CaptureFixture) -> None:
    """Circular dependency does not cause infinite recursion."""
    misc.resolver.load = MagicMock()
    misc.ctx.sbo_packages = {'pkgA': 'cat pkgA', 'pkgB': 'cat pkgB'}
    cycle: dict[str, list[str]] = {'pkgA': ['pkgB'], 'pkgB': ['pkgA']}
    misc.files.fetch_sbo_requires = MagicMock(side_effect=lambda n: cycle.get(n, []))
    misc.deps_tree('pkgA', tree=True)  # must not hang
    out = capsys.readouterr().out
    assert 'pkgB' in out


# -- reverse_deps ------------------------------------------------------------

def test_reverse_deps_unknown_package_exits_1(misc: MiscHandler,
                                              capsys: pytest.CaptureFixture) -> None:
    """Unknown package name exits with code 1."""
    misc.resolver.load = MagicMock()
    misc.ctx.sbo_packages = {}
    with pytest.raises(SystemExit) as exc_info:
        misc.reverse_deps('missing')
    assert exc_info.value.code == 1


def test_reverse_deps_no_dependees(misc: MiscHandler,
                                   capsys: pytest.CaptureFixture) -> None:
    """Package with no reverse deps prints '(no reverse dependencies)'."""
    misc.resolver.load = MagicMock()
    misc.ctx.sbo_packages = {'pkgA': 'cat pkgA'}
    misc.ctx.sbo_requires = {}
    misc.reverse_deps('pkgA')
    assert '(no reverse dependencies)' in capsys.readouterr().out


def test_reverse_deps_flat(misc: MiscHandler,
                           capsys: pytest.CaptureFixture) -> None:
    """Flat mode lists direct and transitive reverse dependees with counts."""
    _setup_graph(misc)
    misc.reverse_deps('pkgD', tree=False)
    out = capsys.readouterr().out
    assert 'pkgB' in out   # pkgB requires pkgD
    assert 'direct' in out
    assert 'transitive' in out


def test_reverse_deps_tree_mode(misc: MiscHandler,
                                capsys: pytest.CaptureFixture) -> None:
    """Tree mode prints reverse dependees with branch connectors and summary."""
    _setup_graph(misc)
    misc.reverse_deps('pkgD', tree=True)
    out = capsys.readouterr().out
    assert 'pkgB' in out
    assert ('├─' in out or '└─' in out)
    assert 'direct' in out
    assert 'transitive' in out


def test_reverse_deps_tree_cycle_no_infinite_loop(misc: MiscHandler,
                                                  capsys: pytest.CaptureFixture) -> None:
    """Circular reverse dependency does not cause infinite recursion."""
    misc.resolver.load = MagicMock()
    misc.ctx.sbo_packages = {'pkgA': 'cat pkgA', 'pkgB': 'cat pkgB'}
    misc.ctx.sbo_requires = {'pkgA': ['pkgB'], 'pkgB': ['pkgA']}
    misc.reverse_deps('pkgA', tree=True)  # must not hang
    out = capsys.readouterr().out
    assert 'pkgB' in out
