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

from __future__ import annotations

import hashlib
import json
import sys
from collections.abc import Callable
from pathlib import Path

from sbo_create.models import (PROG, AppContext, colorize, confirm,
                               select_option, set_arm_labels, set_x86_labels)
from sbo_create.template_loader import TemplateLoader


class FileHandler:
    """Handle all file read/write and disk operations for sbo-create.

    Provides methods for reading and writing .info, .SlackBuild, slack-desc
    and configuration files, as well as interactive package creation.
    """

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

    def ensure_config_dir(self) -> None:
        """Create the config directory if it does not already exist."""
        if not self.ctx.config_path.is_dir():
            self.ctx.config_path.mkdir(parents=True, exist_ok=True)

    def init_info_text(self) -> None:
        """Initialize info labels for the default x86/x86_64 architecture."""
        set_x86_labels(self.ctx.info)

    def init_info_text_arm(self) -> None:
        """Initialize info labels for ARM64 architecture."""
        set_arm_labels(self.ctx.info)

    def create_interactive(self, warn_callback: Callable[[], None] | None = None) -> None:
        """Prompt the user for package details interactively, then create files.

        Asks for package name, version, template type, ARM64 support, comment
        stripping preference and output directory. Calls warn_callback (if provided)
        before writing files to allow pre-creation warnings (e.g. package exists in SBo).

        Args:
            warn_callback: Optional callable invoked after collecting user input
                but before creating files (e.g. to warn about existing packages).
        """
        templates: list[str] = ['autotools', 'cmake', 'perl', 'python', 'rubygem', 'haskell', 'meson']

        yn: str = colorize('y/N', 'yellow')
        try:
            default_name: str = colorize(self.ctx.prg_name, 'yellow')
            name = input(f"Package name [{default_name}]: ").strip() or self.ctx.prg_name
            default_ver: str = colorize(self.ctx.info.version or '1.0.0', 'yellow')
            version = input(f"Version [{default_ver}]: ").strip() or self.ctx.info.version or '1.0.0'
            template = select_option('Template', templates, default='autotools')
            if template not in templates:
                print(colorize(f"{PROG}: Unknown template '{template}'.", 'red'))
                raise SystemExit(1)
            arm64 = input(f"ARM64 support? [{yn}]: ").strip().lower() in ('y', 'yes')
            strip = input(f"Strip comments? [{yn}]: ").strip().lower() in ('y', 'yes')
            default_out: str = colorize(str(self.ctx.current_folder), 'yellow')
            output = input(f"Output directory [{default_out}]: ").strip()
        except (KeyboardInterrupt, EOFError) as exc:
            print()
            raise SystemExit(0) from exc

        self.ctx.prg_name = name
        self.ctx.info.version = version
        self.ctx.template = template
        if arm64:
            set_arm_labels(self.ctx.info)
        if strip:
            self.ctx.strip_comments = True
        if output:
            output_dir = Path(output).expanduser().resolve()
            output_dir.mkdir(parents=True, exist_ok=True)
            self.ctx.current_folder = output_dir

        if warn_callback:
            warn_callback()

        print()
        self.create_files()

    def create_files(self, dry_run: bool = False) -> None:
        """Create .SlackBuild, .info, README and slack-desc files from templates.

        Creates a subdirectory named after the package inside current_folder if
        the folder name does not already match the package name. Warns and prompts
        before overwriting any existing files.

        Args:
            dry_run: If True, print the files that would be created without
                writing anything to disk.
        """
        loader = TemplateLoader(self.ctx)

        try:
            slackbuild: str = loader.slackbuild(self.ctx.template) if self.ctx.template else ''
            info: str = loader.info()
            slack_desc: str = loader.slack_desc()
        except FileNotFoundError as err:
            print(colorize(f"{PROG}: Error: {err}", 'red'))
            raise SystemExit(1) from err
        files: dict[str, str] = {
            f'{self.ctx.prg_name}.SlackBuild': slackbuild,
            f'{self.ctx.prg_name}.info': info,
            'slack-desc': slack_desc,
            'README': str()
        }

        if self.ctx.current_folder.name != self.ctx.prg_name:
            pkg_dir: Path = self.ctx.current_folder / self.ctx.prg_name
            pkg_dir.mkdir(parents=True, exist_ok=True)
            self.ctx.current_folder = pkg_dir

        if dry_run:
            print(f"Files that would be created in '{self.ctx.current_folder}':\n")
            for file in files:
                print(f"  > {file}")
            print()
            raise SystemExit(0)

        existing: list[str] = [f for f in files if (self.ctx.current_folder / f).is_file()]
        if existing:
            print(colorize("The following files already exist and will be overwritten:", 'yellow'))
            for f in existing:
                print(f"  {colorize('>', 'yellow')} {f}")
            if not confirm("Overwrite?"):
                raise SystemExit(0)

        for file, content in files.items():
            (self.ctx.current_folder / file).write_text(content, encoding='utf-8')

        print(colorize("Files created:", 'green') + "\n")
        for file in files:
            print(f"  {colorize('>', 'green')} {file}")

        self._print_next_steps()
        raise SystemExit(0)

    def _print_next_steps(self) -> None:
        """Print the recommended post-create workflow steps after file creation."""
        name: str = self.ctx.prg_name
        steps: list[tuple[str, str]] = [
            (f'Edit {name}.info', 'fill in VERSION, HOMEPAGE, DOWNLOAD, MD5SUM'),
            (f'Edit {name}.SlackBuild', 'adjust build options if needed'),
            ('Edit slack-desc', 'write the package description lines'),
            ('Edit README', 'describe the package and usage'),
            ('sbo-create download', 'fetch source files'),
            ('Test the build locally', f'./{self.ctx.prg_name}.SlackBuild'),
            ('sbo-create validate', 'check submission format'),
            ('sbo-create package', 'create submission tarball'),
        ]
        col: int = max(len(s) for s, _ in steps) + 2
        print(f"\n{colorize('Next steps:', 'cyan')}\n")
        for idx, (step, hint) in enumerate(steps, 1):
            padding: str = ' ' * (col - len(step))
            hint_str: str = f'  {colorize(hint, "yellow")}' if hint else ''
            print(f"  {idx}. {colorize(step, 'cyan')}{padding}{hint_str}")
        print()

    def read_info_file(self) -> None:
        """Read and parse the package .info file into AppContext.

        Detects ARM64 or x86_64 architecture from field names and populates
        ctx.info (version, homepage, downloads, md5sums, requires) and
        ctx.maintainer (name, email). Does nothing if the file does not exist.
        """
        info_file: Path = Path(self.ctx.current_folder, f'{self.ctx.prg_name}.info')
        if not info_file.is_file():
            return

        data: list[str] = info_file.read_text(encoding='utf-8').splitlines()

        for item in data:
            if 'ARM64' in item:
                set_arm_labels(self.ctx.info)
            if 'x86_64' in item:
                set_x86_labels(self.ctx.info)

        labels = self.ctx.info.labels
        self.ctx.info.version = self.info_find_element(labels[1], data)
        self.ctx.info.homepage = self.info_find_element(labels[2], data)

        self.ctx.info.download_x86 = self.find_elements(labels[3], labels[4], data)
        self.ctx.info.md5sum_x86 = self.find_elements(labels[4], labels[5], data)

        self.ctx.info.download_x86_64 = self.find_elements(labels[5], labels[6], data)
        self.ctx.info.md5sum_x86_64 = self.find_elements(labels[6], labels[7], data)

        self.ctx.info.requires = self.info_find_element(labels[7], data)
        self.ctx.info.maintainer = self.info_find_element(labels[8], data)
        self.ctx.info.email = self.info_find_element(labels[9], data)

    def read_maintainer_file(self) -> None:
        """Load maintainer configuration from config.conf into AppContext.

        Parses key=value pairs from the config file. Required keys (NAME, EMAIL,
        LOCATION, EDITOR, EDITOR_OPTIONS, SBO_VERSION) emit a warning to stderr
        if missing. Optional keys (SBO_REPO_PATH, GIT_*) are silently skipped
        when absent. Does nothing if the config file does not exist.
        """
        maintainer_config: Path = Path(self.ctx.config_path, self.ctx.config_file)
        if not maintainer_config.is_file():
            return

        required_keys: dict[str, str] = {
            'NAME': 'name',
            'EMAIL': 'email',
            'LOCATION': 'where_you_live',
            'EDITOR': 'editor',
            'EDITOR_OPTIONS': 'editor_options',
            'SBO_VERSION': 'sbo_repo_version',
        }
        optional_keys: dict[str, str] = {
            'SBO_REPO_PATH': 'sbo_repo_path',
            'GIT_BRANCH': 'git_branch',
            'GIT_MAIN_BRANCH': 'git_main_branch',
            'GIT_COMMIT_CMD': 'git_commit_cmd',
            'GIT_PUSH_CMD': 'git_push_cmd',
        }

        lines: list[str] = maintainer_config.read_text(encoding='utf-8').splitlines()

        found: dict[str, str] = {}
        for line in lines:
            if '=' in line:
                key, _, value = line.partition('=')
                val: str = value.strip()
                if val.startswith('"') and val.endswith('"'):
                    val = val[1:-1]
                found[key.strip()] = val

        for key, attr in required_keys.items():
            if key in found:
                setattr(self.ctx.maintainer, attr, found[key])
            else:
                print(f"{PROG}: {colorize('Warning:', 'red')} '{key}' not found in {self.ctx.config_file}.",
                      file=sys.stderr)

        for key, attr in optional_keys.items():
            if key in found:
                setattr(self.ctx.maintainer, attr, found[key])

    def fetch_sbo_packages(self) -> dict[str, str]:
        """Load and cache SBo packages from the local slackbuilds.json.

        Returns a cached result on subsequent calls. Prints a warning to
        stderr if the file is missing or corrupt.

        Returns:
            Mapping of package name to 'category  short description'.
        """
        if self.ctx.sbo_packages:
            return self.ctx.sbo_packages

        json_cache: Path = self.ctx.repo_path / 'slackbuilds.json'
        if json_cache.is_file():
            try:
                self._load_json_cache(json_cache)
                if self.ctx.sbo_packages:
                    return self.ctx.sbo_packages
            except (OSError, json.JSONDecodeError):
                pass

        print(f"{PROG}: {colorize('Warning:', 'yellow')}"
              f" SBo package list not found. Run '{PROG} update' to populate it.",
              file=sys.stderr)
        return self.ctx.sbo_packages

    def _load_json_cache(self, json_cache: Path) -> None:
        """Load the JSON package cache into the AppContext caches.

        Args:
            json_cache: Path to the packages.json cache file.
        """
        data: dict[str, dict[str, str | list[str]]] = json.loads(
            json_cache.read_text(encoding='utf-8'))
        for name, entry in data.items():
            self.ctx.sbo_packages[name] = f'{entry["category"]}  {entry["desc"]}'
            self.ctx.sbo_versions[name] = str(entry.get('version', ''))
            requires = entry.get('requires')
            if requires:
                self.ctx.sbo_requires[name] = list(requires)

    def _write_json_cache(self, json_cache: Path) -> None:
        """Write the current AppContext caches to a JSON cache file.

        Args:
            json_cache: Destination path for the slackbuilds.json file.
        """
        data: dict[str, dict[str, object]] = {}
        for name, cat_desc in self.ctx.sbo_packages.items():
            parts = cat_desc.split('  ', 1)
            data[name] = {
                'category': parts[0],
                'desc': parts[1] if len(parts) > 1 else '',
                'version': self.ctx.sbo_versions.get(name, ''),
                'requires': self.ctx.sbo_requires.get(name, []),
            }
        json_cache.write_text(json.dumps(data), encoding='utf-8')

    def build_json_cache(self, content: str) -> None:
        """Parse SLACKBUILDS.TXT content and write slackbuilds.json.

        Called once after each 'update' with the downloaded content.
        The raw SLACKBUILDS.TXT is never written to disk.

        Args:
            content: Full text content of SLACKBUILDS.TXT.
        """
        json_cache: Path = self.ctx.repo_path / 'slackbuilds.json'
        self._parse_sbo_txt(content.splitlines())
        self._write_json_cache(json_cache)

    def _parse_sbo_txt(self, lines: list[str]) -> None:
        """Parse SLACKBUILDS.TXT content lines into the AppContext caches.

        Extracts NAME, LOCATION (for category), VERSION, REQUIRES and SHORT
        DESCRIPTION from each package block and stores the result in
        ctx.sbo_packages, ctx.sbo_requires and ctx.sbo_versions.

        Args:
            lines: Lines of the SLACKBUILDS.TXT file.
        """
        name = category = version = desc = requires = ''
        for line in lines:
            if line.startswith('SLACKBUILD NAME:'):
                name = line.split(':', 1)[1].strip()
            elif line.startswith('SLACKBUILD LOCATION:'):
                location = line.split(':', 1)[1].strip()
                category = location.split('/')[-2] if '/' in location else ''
            elif line.startswith('SLACKBUILD VERSION:'):
                version = line.split(':', 1)[1].strip()
            elif line.startswith('SLACKBUILD REQUIRES:'):
                requires = line.split(':', 1)[1].strip()
            elif line.startswith('SLACKBUILD SHORT DESCRIPTION:'):
                desc = line.split(':', 1)[1].strip()
            elif not line.strip() and name:
                self._store_package(name, category, version, desc, requires)
                name = category = version = desc = requires = ''
        if name:
            self._store_package(name, category, version, desc, requires)

    def _store_package(self, name: str, category: str, version: str,  # pylint: disable=too-many-arguments,too-many-positional-arguments
                       desc: str, requires: str) -> None:
        """Store a single parsed package entry into the AppContext caches.

        Args:
            name: Package name.
            category: Package category derived from LOCATION.
            version: Package version string.
            desc: Short description.
            requires: Space-separated dependency string.
        """
        self.ctx.sbo_packages[name] = f'{category}  {desc}'
        self.ctx.sbo_versions[name] = version
        if requires:
            self.ctx.sbo_requires[name] = [
                r for r in requires.split() if r and r != '%README%'
            ]

    def fetch_sbo_requires(self, name: str) -> list[str]:
        """Return the direct dependency list for a package from SLACKBUILDS.TXT.

        Args:
            name: Package name.

        Returns:
            List of dependency names, or [] if the package has no dependencies
            or is not found.
        """
        self.fetch_sbo_packages()  # ensure sbo_requires cache is warm
        return self.ctx.sbo_requires.get(name, [])

    def source_check_sum(self, source: str) -> str:
        """Compute the MD5 checksum of a source file in the current folder.

        Args:
            source: Filename of the source tarball (without path).

        Returns:
            Lowercase hex MD5 digest string, or '' if the file does not exist.
        """
        file: Path = Path(self.ctx.current_folder, source)
        if file.is_file():
            md5_hash = hashlib.md5()
            with open(file, 'rb') as f:
                for chunk in iter(lambda: f.read(65536), b''):
                    md5_hash.update(chunk)
            return md5_hash.hexdigest()
        return ''

    @staticmethod
    def find_elements(start: str, stop: str, info_file: list[str]) -> str:
        """Extract a possibly multi-line value between two field labels in .info lines.

        Reads from the line starting with 'start' up to (but not including) the
        line starting with 'stop', stripping quotes, backslash continuations and
        extra whitespace from each segment.

        Args:
            start: The field label prefix that begins the value (e.g. 'DOWNLOAD=').
            stop: The field label prefix that terminates the value (e.g. 'MD5SUM=').
            info_file: Lines of the .info file.

        Returns:
            Space-joined string of all value segments found, or '' if not found.
        """
        elements: list[str] = []
        in_element = False

        for line in info_file:
            if not in_element:
                if line.startswith(start):
                    in_element = True
                    txt = line.replace(start, '', 1).strip()
                    txt = txt.replace('"', '').rstrip('\\').strip()
                    if txt:
                        elements.append(txt)
                    if line.rstrip().endswith('"'):
                        break
            else:
                if stop and line.startswith(stop):
                    break
                txt = line.replace('"', '').rstrip('\\').strip()
                if txt:
                    elements.append(txt)
                if line.rstrip().endswith('"'):
                    break

        return ' '.join(elements).strip()

    @staticmethod
    def info_find_element(tag: str, info_file: list[str]) -> str:
        """Find a single-line value by field label in .info file lines.

        Args:
            tag: The field label prefix (e.g. 'VERSION=').
            info_file: Lines of the .info file.

        Returns:
            The unquoted field value, or '' if the tag is not found.
        """
        for info in info_file:
            if info.startswith(tag):
                return info.split('=', 1)[1].replace('"', '').strip()
        return ''
