#!/usr/bin/python3

import logging
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path

from slpkg.config import config_load
from slpkg.repositories import Repositories
from slpkg.utilities import Utilities

logger = logging.getLogger(__name__)


class Changelog:  # pylint: disable=[R0902]
    """Manages and prints changelog information for specified repositories.

    This class handles the retrieval and display of changelog entries.
    It allows filtering changelog content based on a user-provided query
    and includes terminal-based pagination for large outputs.
    """

    def __init__(self, options: dict[str, bool], repository: str, query: str) -> None:
        logger.debug(
            "Initializing Changelog module with options: %s, repository: %s, query: '%s'", options, repository, query
        )
        self.options = options
        self.repository = repository
        self.query = query
        self._query_re: re.Pattern[str] | None = None

        self.editor = config_load.editor
        self.pager = config_load.pager
        logger.debug("Pager string: %s, and editor: %s from config.", self.pager, self.editor)

        self.red = config_load.red
        self.endc = config_load.endc

        self.repos = Repositories()
        self.utils = Utilities()
        self.columns, self.rows = shutil.get_terminal_size()
        logger.debug("Terminal size: %sx%s", self.columns, self.rows)

        self.repo_path: Path = self.repos.repositories[self.repository]["path"]
        self.changelog_txt: str = self.repos.repositories[self.repository]["changelog_txt"]
        self.changelog_file: Path = Path(self.repo_path, self.changelog_txt)
        logger.debug("Changelog file path: %s", self.changelog_file)

        self.option_for_pager: bool = options.get("option_pager", False)
        self.option_for_edit: bool = options.get("option_edit", False)
        self.option_lines: int = options.get("option_lines", 0)
        self.option_short: bool = options.get("option_short", False)
        self.option_date: str = str(options.get("option_date", ""))
        logger.debug(
            "Option for edit enabled: %s, Option for pager enabled: %s", self.option_for_edit, self.option_for_pager
        )

    def _validate_date(self) -> None:
        """Validate self.option_date format. Exits with an error if invalid."""
        if not self.option_date:
            return
        try:
            datetime.strptime(self.option_date, "%Y-%m-%d")
        except ValueError:
            print(
                f"{self.red}Error{self.endc}: invalid date '{self.option_date}'. "
                "Expected format: YYYY-MM-DD (e.g. 2026-03-07)."
            )
            logger.error("Invalid --date value: '%s'", self.option_date)
            sys.exit(1)

    def _compile_query(self) -> None:
        """Compile self.query as a case-insensitive regex pattern.

        Sets self._query_re to the compiled pattern, or None when the query
        is empty.  Exits with an error message if the query is not valid
        Python regex syntax.

        Plain text queries (e.g. ``ffmpeg``) continue to work exactly as
        before. Regex meta-characters unlock advanced filtering, for example:

        * ``kernel.*generic`` — packages whose line contains both words
        * ``^[al]/``          — lines starting with category a/ or l/
        * ``security fix``    — case-insensitive literal phrase (default)
        * ``(upgrade|remove)`` — lines mentioning either word
        """
        if not self.query:
            self._query_re = None
            return
        try:
            self._query_re = re.compile(self.query, re.IGNORECASE)
            logger.debug("Compiled query regex: '%s'", self.query)
        except re.error as e:
            print(
                f"{self.red}Error{self.endc}: invalid regex query '{self.query}': {e}.\n"
                r"Tip: escape special characters with \, e.g. \( instead of (."
            )
            logger.error("Invalid --query regex: '%s': %s", self.query, e)
            sys.exit(1)

    def run(self) -> None:
        """Run changelog methods"""
        logger.info("Changelog run method called. Option for edit: %s", self.option_for_edit)
        self._validate_date()
        self._compile_query()
        if self.option_for_edit:
            self.edit_changelog()
        elif self.option_short:
            self.display_short()
        else:
            self.display_changelog()

    def _format_line(self, line: str, day_pattern: re.Pattern[str], green: str, endc: str) -> str:
        """Apply color formatting to a changelog line.

        Args:
            line (str): The raw line from the changelog file.
            day_pattern (re.Pattern): Compiled regex to detect date lines.
            green (str): ANSI green color code.
            endc (str): ANSI reset code.

        Returns:
            str: The formatted line.
        """
        if day_pattern.search(line):
            logger.debug("Applied green color to date line: '%s'", line.strip())
            return f"{green}{line}{endc}"
        return line

    # Compiled once at class level — shared by _build_short_entries instances.
    _DATE_PATTERN: re.Pattern[str] = re.compile(
        r"^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(\w+\s+\d+\s+[\d:]+\s+\w+\s+\d{4})", re.IGNORECASE
    )
    # Handles both formats:
    #   Slackware: "a/ffmpeg-6.1-x86_64-1.txz:  Upgraded."
    #   SBo/Ponce: "system/slpkg: Updated for version 6.0.0."
    # Requires at least one "category/" prefix so plain text lines are not matched.
    _PKG_PATTERN: re.Pattern[str] = re.compile(
        r"^(?:[^:\s]+/)+([^/:]+(?:\.t[gblx]z)?):\s+(.+)$", re.IGNORECASE
    )

    def _select_date_block(self, lines: list[str]) -> list[str]:
        """Return only lines belonging to the date block matching self.option_date.

        Keeps the date header line and all package/description lines until the
        next date header. Separator lines (+-…-+) between blocks are excluded.

        Args:
            lines: Raw lines from the changelog file.

        Returns:
            Filtered list of lines, or all lines if option_date is not set.
        """
        if not self.option_date:
            return lines

        result: list[str] = []
        in_block: bool = False

        for line in lines:
            date_match = self._DATE_PATTERN.match(line.rstrip())
            if date_match:
                iso = self._parse_date(date_match.group(1).strip())
                in_block = iso == self.option_date
                if in_block:
                    result.append(line)
                continue

            if line.startswith("+"):
                in_block = False
                continue

            if in_block:
                result.append(line)

        return result

    @staticmethod
    def _parse_date(raw: str) -> str:
        """Convert a raw changelog date string to ISO-8601 (YYYY-MM-DD).

        Args:
            raw: Date portion extracted from a changelog header line,
                 e.g. 'Mar  7 23:16:55 UTC 2026'.

        Returns:
            ISO-8601 date string, or the original raw string if parsing fails.
        """
        try:
            raw_clean = " ".join(raw.split())
            return datetime.strptime(raw_clean, "%b %d %H:%M:%S %Z %Y").strftime("%Y-%m-%d")
        except ValueError:
            return raw

    def _build_short_entries(self, lines: list[str]) -> list[str]:
        """Parse raw changelog lines and return formatted short-summary entries.

        Args:
            lines: Raw lines from the changelog file.

        Returns:
            List of formatted strings ready for output, one per package entry.
        """
        green: str = config_load.green
        endc: str = config_load.endc
        current_date: str = ""
        last_added_date: str = ""
        entries: list[str] = []

        for line in lines:
            line = line.rstrip()

            date_match = self._DATE_PATTERN.match(line)
            if date_match:
                current_date = self._parse_date(date_match.group(1).strip())
                continue

            pkg_match = self._PKG_PATTERN.match(line)
            if pkg_match and current_date:
                if self._query_re and not self._query_re.search(line):
                    continue
                filename = pkg_match.group(1)
                action = pkg_match.group(2).strip()
                if current_date != last_added_date:
                    if entries:
                        entries.append("\n")
                    entries.append(f"[{green}{current_date}{endc}]\n")
                    last_added_date = current_date
                entries.append(f"  {filename}: {action}\n")

        return entries

    @staticmethod
    def _limit_pkg_entries(entries: list[str], n: int) -> list[str]:
        """Return the first n package lines from entries, keeping their date headers and separators.

        Args:
            entries: Output of _build_short_entries (mix of headers, packages, blank lines).
            n: Maximum number of package lines to keep.

        Returns:
            Trimmed list that contains at most n package lines.
        """
        result: list[str] = []
        pkg_count = 0
        for entry in entries:
            result.append(entry)
            if entry.startswith("  "):
                pkg_count += 1
                if pkg_count >= n:
                    break
        return result

    def display_short(self) -> None:
        """Print a compact one-line-per-package summary of the changelog.

        Format: [YYYY-MM-DD] package: Action.
        Skips separator lines, indented descriptions, and non-package lines.
        Respects -n/--lines and -g/--pager options.
        """
        logger.info("Displaying short changelog for repository '%s'.", self.repository)

        if not self.changelog_file.is_file():
            logger.warning("Changelog file not found for repository: %s", self.repository)
            print(f"The repository '{self.repository}' does not provide a {self.changelog_txt} file.")
            return

        try:
            lines = self.utils.read_text_file(self.changelog_file)
            lines = self._select_date_block(lines)
            formatted = self._build_short_entries(lines)

            if self.option_lines:
                formatted = self._limit_pkg_entries(formatted, self.option_lines)
                logger.debug("Limiting short changelog output to first %d package entries.", self.option_lines)

            if not formatted:
                print(
                    f"No structured entries found. The changelog for '{self.repository}' "
                    "may use an unsupported format.\n"
                    "Try without --short to view the raw changelog."
                )
                logger.warning("display_short: no entries matched for repository '%s'.", self.repository)
                return

            if self.option_for_pager:
                pager_command = self.pager.split()
                logger.debug("Using pager command for short output: %s", pager_command)
                with subprocess.Popen(pager_command, stdin=subprocess.PIPE, text=True, encoding="utf-8") as process:
                    if process.stdin is None:
                        raise OSError("Failed to open stdin pipe for pager process.")
                    for entry in formatted:
                        process.stdin.write(entry)
                    process.stdin.close()
            else:
                for entry in formatted:
                    sys.stdout.write(entry)

        except Exception as e:  # pylint: disable=[W0718]
            logger.critical("An unexpected error occurred while displaying short changelog: %s", e, exc_info=True)
            print(f"Error: An unexpected error occurred: {e}")
            sys.exit(1)

        logger.info("Short changelog displayed %d entries.", len(formatted))

    def display_changelog(self) -> None:
        """Prints repository changelog."""
        logger.info(
            "Displaying changelog for repository '%s' with query: '%s'. Pager enabled: %s",
            self.repository,
            self.query,
            self.option_for_pager,
        )

        if not self.changelog_file.is_file():
            logger.warning("Changelog file not found for repository: %s", self.repository)
            print(f"The repository '{self.repository}' does not provide a {self.changelog_txt} file.")
            return

        day_pattern = re.compile(r"\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\b", re.IGNORECASE)
        # Console colors.
        green: str = config_load.green
        endc: str = config_load.endc

        try:
            # Read changelog file line by line.
            lines = self.utils.read_text_file(self.changelog_file)
            logger.debug("Read %d lines from changelog file: %s", len(lines), self.changelog_file)

            lines = self._select_date_block(lines)

            # Apply query filter first, then limit — so -n counts matching lines, not raw lines.
            filtered = [
                self._format_line(ln, day_pattern, green, endc)
                for ln in lines
                if self._query_re is None or self._query_re.search(ln)
            ]
            if self.option_lines:
                filtered = filtered[: self.option_lines]
                logger.debug("Limiting changelog output to first %d matching lines.", self.option_lines)

            if self.option_for_pager:
                # If the pager option is enabled, pipe output to the configured pager.
                pager_command = self.pager.split()
                logger.debug("Using pager command: %s", pager_command)

                # Start a subprocess to the pager using a 'with' statement for proper resource management.
                # stdin=subprocess.PIPE allows us to write to the pager's input.
                # text=True ensures proper handling of text (str) and encoding.
                # encoding='utf-8' specifies the character encoding for the stream.
                with subprocess.Popen(pager_command, stdin=subprocess.PIPE, text=True, encoding="utf-8") as process:
                    if process.stdin is None:
                        logger.error("Pager process stdin is unexpectedly None. Cannot write to pager.")
                        raise OSError("Failed to open stdin pipe for pager process.")

                    for line_from_file in filtered:
                        process.stdin.write(line_from_file)
                        logger.debug("Wrote line to pager's stdin: '%s'", line_from_file.strip())

                    # Close the stdin of the pager process. This signals EOF (End Of File) to the pager,
                    # so it knows there's no more input coming and can display the content.
                    process.stdin.close()
                    logger.debug("Closed pager's stdin. Waiting for pager process to exit.")
                logger.info("Pager process exited. Finished displaying changelog.")
            else:
                logger.debug("Pager not enabled. Printing directly to stdout.")
                for line_from_file in filtered:
                    sys.stdout.write(line_from_file)
                logger.info("Finished displaying changelog directly to stdout.")

        except Exception as e:  # pylint: disable=[W0718]
            logger.critical("An unexpected error occurred while displaying changelog: %s", e, exc_info=True)
            print(f"Error: An unexpected error occurred: {e}")
            sys.exit(1)

    def edit_changelog(self) -> None:
        """Edit changelog file with system editor."""
        editor = self.editor
        if not editor:
            editor = os.environ.get("EDITOR", "")
        if not editor:
            logger.error("No editor configured or found in environment. Cannot open external editor for changelog.")
            print("Error: No editor configured or found. Cannot open editor.")
            sys.exit(1)

        command = [editor, str(self.changelog_file)]
        logger.info("Opening changelog file '%s' with external editor: '%s'", self.changelog_file, editor)
        try:
            subprocess.run(command, check=True)  # check=True will raise CalledProcessError on non-zero exit codes.
            logger.info("External editor exited successfully after editing changelog.")
        except subprocess.CalledProcessError as e:
            logger.error("External editor command failed with exit code %s: %s", e.returncode, e, exc_info=True)
            print(f"Error: External editor failed with exit code {e.returncode}")
            sys.exit(e.returncode)
        except FileNotFoundError:
            logger.error(
                "External editor '%s' not found. Please ensure it is installed and in your PATH.", editor, exc_info=True
            )
            print(f"Error: External editor '{editor}' not found. Please ensure it is installed and in your PATH.")
            sys.exit(1)
        except Exception as e:  # pylint: disable=[W0718]
            logger.critical(
                "An unexpected error occurred while opening external editor for changelog: %s", e, exc_info=True
            )
            print(f"Error: An unexpected error occurred with editor: {e}")
            sys.exit(1)
