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

from __future__ import annotations

import shutil
import tarfile
from io import BytesIO
from pathlib import Path

import urllib3  # pylint: disable=import-error
from urllib3.exceptions import HTTPError  # pylint: disable=import-error

from sbo_create.handlers.fallback import SboResolver
from sbo_create.handlers.files import FileHandler
from sbo_create.models import PROG, AppContext, colorize, confirm


class FetchHandler:
    """Handle fetching, browsing and listing SlackBuild packages from SBo."""

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

    def _require_repo_path(self) -> Path:
        """Return the resolved SBo repo path, or exit with a hard error if unconfigured.

        Returns:
            Absolute resolved Path to the local SBo repository.
        """
        repo_path_str: str = self.ctx.maintainer.sbo_repo_path
        if not repo_path_str:
            print(colorize(
                f"{PROG}: SBo repo path not configured. Run '{PROG} maintainer'.", 'red'))
            raise SystemExit(1)
        repo_path: Path = Path(repo_path_str).expanduser().resolve()
        if not repo_path.is_dir():
            print(colorize(f"{PROG}: SBo repo path not found: {repo_path}", 'red'))
            raise SystemExit(1)
        return repo_path

    def list_maintainer_packages(self, name: str, wide: bool = False) -> None:  # pylint: disable=too-many-locals
        """List all SBo packages maintained by a given name, grouped by category.

        Scans every .info file in the local SBo repository for a MAINTAINER field
        that contains name as a case-insensitive substring, so partial names (e.g.
        a first name) are sufficient. Results are grouped by category and sorted
        alphabetically. If exactly one unique maintainer name matches, it is shown
        in the header instead of the search term.

        Args:
            name: Partial or full maintainer name to search for (case-insensitive).
            wide: If True, print packages in multi-column layout fitted to the
                terminal width instead of one per line.
        """
        repo_path: Path = self._require_repo_path()
        name_lower: str = name.lower()

        grouped: dict[str, list[str]] = {}
        matched_names: set[str] = set()
        for info_file in repo_path.rglob('*.info'):
            try:
                content: str = info_file.read_text(encoding='utf-8')
            except OSError:
                continue
            for line in content.splitlines():
                if not line.startswith('MAINTAINER='):
                    continue
                value: str = line.split('=', 1)[1].strip('"')
                if name_lower in value.lower():
                    parts = info_file.relative_to(repo_path).parts
                    if len(parts) >= 2:
                        category, pkg = parts[0], parts[1]
                        grouped.setdefault(category, []).append(pkg)
                        matched_names.add(value)
                break

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

        display_name: str = next(iter(matched_names)) if len(matched_names) == 1 else name
        total: int = sum(len(v) for v in grouped.values())
        print(f"Packages maintained by {colorize(display_name, 'cyan')} ({total}):\n")
        for category in sorted(grouped):
            pkgs: list[str] = sorted(grouped[category])
            print(f"{colorize(category, 'yellow')} ({len(pkgs)}):")
            if wide:
                term_width: int = shutil.get_terminal_size().columns
                col_width: int = max(len(p) for p in pkgs) + 2
                cols: int = max(1, (term_width - 2) // col_width)
                for i in range(0, len(pkgs), cols):
                    row: list[str] = pkgs[i:i + cols]
                    print("  " + "".join(
                        colorize(p, 'cyan') + " " * (col_width - len(p)) for p in row
                    ))
            else:
                for pkg in pkgs:
                    print(f"  {colorize('>', 'cyan')} {pkg}")
        raise SystemExit(0)

    def fetch_package(self, name: str, slack_ver: str | None = None,  # pylint: disable=too-many-locals,too-many-statements,too-many-branches
                      list_files: bool = False, read_files: list[str] | None = None) -> None:
        """Download and extract a SlackBuild package from the SBo repository.

        If a local SBo repo path is configured (SBO_REPO_PATH), searches it
        first by scanning category directories for the package name. If found,
        copies or reads it directly (faster, no network required). Otherwise
        falls back to SLACKBUILDS.TXT for the category lookup and downloads the
        .tar.gz from slackbuilds.org. Supports listing or reading individual
        files from the package without extracting the full archive.

        Args:
            name: The exact SBo package name to fetch.
            slack_ver: Slackware version string (e.g. '15.0') used to build the
                download URL. Auto-detected from /etc/slackware-version if None.
            list_files: If True, list the files inside the package and exit.
            read_files: If provided, print the content of these specific files
                from the package and exit.
        """
        local_pkg: Path | None = self.resolver.find_pkg_dir(name)
        if local_pkg is not None:
            if list_files:
                self._view_list_local(local_pkg, name)
                return
            if read_files:
                for f in read_files:
                    self._view_file_local(local_pkg, name, f)
                return
            self._fetch_copy_local(local_pkg, name)
            return

        self.resolver.load()
        packages: dict[str, str] = self.ctx.sbo_packages
        if not packages:
            raise SystemExit(1)

        if name not in packages:
            print(colorize(f"{PROG}: '{name}' not found in SBo repository.", 'red'))
            raise SystemExit(1)

        category: str = packages[name].split()[0]

        slack_ver = slack_ver or self._slackware_version()
        base_url: str = f'https://slackbuilds.org/slackbuilds/{slack_ver}/{category}/{name}'
        http = urllib3.PoolManager()

        if list_files:
            self._view_list_web(http, base_url, name)
            return

        if read_files:
            for f in read_files:
                self._view_file_web(http, base_url, f)
            return

        url: str = f'{base_url}.tar.gz'
        dest: Path = self.ctx.current_folder / f'{name}.tar.gz'

        pkg_dir: Path = self.ctx.current_folder / name
        if pkg_dir.is_dir():
            pkg_label: str = colorize(f"'{name}'", 'yellow')
            if not confirm(f"Package {pkg_label} already exists. Overwrite?",
                           after_newline=False, before_newline=False):
                return

        print(f"Fetching {colorize(name, 'cyan')} from SBo ({category})...",
              end=' ', flush=True)
        try:
            resp = http.request('GET', url, preload_content=False, redirect=True)
            if resp.status != 200:
                print(colorize('FAIL', 'red'))
                print(colorize(f"{PROG}: Download failed (HTTP {resp.status}).", 'red'))
                raise SystemExit(1)
            with dest.open('wb') as fh:
                for chunk in resp.stream(8192):
                    fh.write(chunk)
        except HTTPError as err:
            print(colorize('FAIL', 'red'))
            print(colorize(f"{PROG}: Download failed: {err}", 'red'))
            raise SystemExit(1) from err

        print(colorize('OK', 'green'))
        with tarfile.open(dest, 'r:gz') as tar:
            tar.extractall(path=self.ctx.current_folder)
        dest.unlink()

    # -- local repo helpers --------------------------------------------------

    def _view_list_local(self, local_pkg: Path, name: str) -> None:
        """List the files in a local SBo package directory.

        Args:
            local_pkg: Path to the package directory in the local SBo repository.
            name: The package name, used in the header line.
        """
        print(f"Listing {colorize(name, 'cyan')} (local):")
        print()
        for path in sorted(local_pkg.iterdir()):
            if path.is_file():
                print(f"  {colorize('>', 'cyan')} {path.name}")

    def _view_file_local(self, local_pkg: Path, name: str, read_file: str) -> None:
        """Read and print a single file from a local SBo package directory.

        Args:
            local_pkg: Path to the local package directory.
            name: The package name, used in the error message if not found.
            read_file: The filename to read (e.g. 'README', 'package.info').
        """
        file_path: Path = local_pkg / read_file
        if not file_path.is_file():
            print(colorize(f"{PROG}: '{read_file}' not found in '{name}'.", 'red'))
            raise SystemExit(1)
        print(file_path.read_text(encoding='utf-8', errors='replace').rstrip())

    def _fetch_copy_local(self, local_pkg: Path, name: str) -> None:
        """Copy a local SBo package directory into the current working folder.

        If the destination already exists, prompts the user before removing and
        replacing it. Uses shutil.copytree for a full recursive copy.

        Args:
            local_pkg: Source path of the package in the local SBo repository.
            name: The package name, used for the destination directory and prompts.
        """
        dest: Path = self.ctx.current_folder / name
        if dest.is_dir():
            pkg_label: str = colorize(f"'{name}'", 'yellow')
            if not confirm(f"Package {pkg_label} already exists. Overwrite?",
                           after_newline=True, before_newline=False):
                return
            shutil.rmtree(dest)
        print(f"Fetching {colorize(name, 'cyan')} (local)...", end=' ', flush=True)
        shutil.copytree(local_pkg, dest)
        print(colorize('OK', 'green'))

    # -- web helpers ---------------------------------------------------------

    def _view_list_web(self, http: urllib3.PoolManager, base_url: str, name: str) -> None:
        """Download the package tar.gz from the web and list its file members.

        Fetches the full archive into memory and iterates over its members,
        printing each file name. Exits with an error on HTTP failure.

        Args:
            http: An active urllib3 PoolManager instance.
            base_url: Base URL of the package on slackbuilds.org (without .tar.gz).
            name: The package name, used in the header and error messages.
        """
        url: str = f'{base_url}.tar.gz'
        print(f"Listing {colorize(name, 'cyan')}...", end=' ', flush=True)
        try:
            resp = http.request('GET', url, redirect=True)
            if resp.status != 200:
                print(colorize('FAIL', 'red'))
                print(colorize(f"{PROG}: HTTP {resp.status}.", 'red'))
                raise SystemExit(1)
        except HTTPError as err:
            print(colorize('FAIL', 'red'))
            print(colorize(f"{PROG}: {err}", 'red'))
            raise SystemExit(1) from err

        print(colorize('OK', 'green'))
        buf = BytesIO(resp.data)
        print()
        with tarfile.open(fileobj=buf, mode='r:gz') as tar:
            for member in tar.getmembers():
                if member.isfile():
                    filename: str = (member.name.split('/', 1)[-1]
                                     if '/' in member.name else member.name)
                    print(f"  {colorize('>', 'cyan')} {filename}")

    def _view_file_web(self, http: urllib3.PoolManager, base_url: str,
                       read_file: str) -> None:
        """Fetch and print a single file directly from slackbuilds.org.

        Args:
            http: An active urllib3 PoolManager instance.
            base_url: Base URL of the package on slackbuilds.org.
            read_file: The filename to retrieve (e.g. 'README', 'package.info').
        """
        url: str = f'{base_url}/{read_file}'
        try:
            resp = http.request('GET', url, redirect=True)
            if resp.status != 200:
                print(colorize(
                    f"{PROG}: '{read_file}' not found (HTTP {resp.status}).", 'red'))
                raise SystemExit(1)
        except HTTPError as err:
            print(colorize(f"{PROG}: {err}", 'red'))
            raise SystemExit(1) from err
        print(resp.data.decode('utf-8', errors='replace').rstrip())

    @staticmethod
    def _tar_read_file(tar: tarfile.TarFile, name: str, read_file: str) -> None:
        """Extract and print a specific file from an already-open TarFile.

        Args:
            tar: An open tarfile.TarFile object to read from.
            name: The top-level directory name within the archive (i.e. the package name).
            read_file: The filename to extract (e.g. 'README').
        """
        member_name: str = f'{name}/{read_file}'
        try:
            member = tar.getmember(member_name)
            fobj = tar.extractfile(member)
            if fobj:
                print(fobj.read().decode('utf-8', errors='replace').rstrip())
        except KeyError:
            print(colorize(f"'{read_file}' not found in package.", 'red'))

    @staticmethod
    def _slackware_version() -> str:
        """Return the Slackware version string from /etc/slackware-version.

        Reads the file and extracts the version number (the second whitespace-separated
        token, stripping any '+' suffix for -current builds). Falls back to '15.0'
        if the file is missing or cannot be parsed.

        Returns:
            Version string such as '15.0', or '15.0' as the default fallback.
        """
        path = Path('/etc/slackware-version')
        if path.is_file():
            text = path.read_text(encoding='utf-8').strip()
            parts = text.split()
            if len(parts) >= 2:
                return parts[1].split('+')[0]
        return '15.0'
