# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Renovate-related utilities for GitHub Actions workflows.

This module provides utilities for managing Renovate prerequisites,
migrating from Dependabot to Renovate, and updating the ``exclude-newer``
date in ``pyproject.toml`` files.
"""

from __future__ import annotations

import json
import logging
import re
import sys
from dataclasses import asdict, dataclass
from datetime import date, timedelta
from pathlib import Path

from click_extra import TableFormat, render_table

from .github import AnnotationLevel, emit_annotation, run_gh_command

if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib  # type: ignore[import-not-found]

TYPE_CHECKING = False
if TYPE_CHECKING:
    from datetime import date as DateType


@dataclass
class RenovateCheckResult:
    """Result of all Renovate prerequisite checks.

    This dataclass holds the results of each check, allowing workflows to
    consume the data and build dynamic PR bodies or conditional logic.
    """

    renovate_config_exists: bool
    """Whether renovate.json5 exists in the repository."""

    dependabot_config_path: str
    """Path to Dependabot config file, or empty string if not found."""

    dependabot_security_disabled: bool
    """Whether Dependabot security updates are disabled."""

    commit_statuses_permission: bool
    """Whether the token has commit statuses permission."""

    repo: str = ""
    """Repository in 'owner/repo' format, used for generating settings links."""

    def to_github_output(self) -> str:
        """Format results for GitHub Actions output.

        :return: Multi-line string in key=value format for $GITHUB_OUTPUT.
        """
        lines = [
            f"renovate_config_exists={str(self.renovate_config_exists).lower()}",
            f"dependabot_config_path={self.dependabot_config_path}",
            "dependabot_security_disabled="
            f"{str(self.dependabot_security_disabled).lower()}",
            "commit_statuses_permission="
            f"{str(self.commit_statuses_permission).lower()}",
            f"pr_body<<EOF\n{self.to_pr_body()}\nEOF",
        ]
        return "\n".join(lines)

    def to_json(self) -> str:
        """Format results as JSON.

        :return: JSON string representation of the check results.
        """
        return json.dumps(asdict(self), indent=2)

    def to_pr_body(self) -> str:
        """Generate PR body for the migration PR.

        :return: Markdown-formatted PR body with changes and prerequisites table.
        """
        lines = ["Migrate from Dependabot to Renovate.", ""]

        # Changes section.
        lines.append("## Changes")
        if not self.renovate_config_exists:
            lines.append("- Export `renovate.json5` configuration file")
        if self.dependabot_config_path:
            lines.append(f"- Remove `{self.dependabot_config_path}`")
        if self.renovate_config_exists and not self.dependabot_config_path:
            lines.append("- No changes needed")
        lines.append("")

        # Prerequisites status table.
        lines.append("## Prerequisites Status")
        lines.append("")

        # Build table rows.
        settings_url = f"https://github.com/{self.repo}/settings/security_analysis"
        docs_url = "https://github.com/kdeldycke/workflows#permissions-and-token"

        table_data = [
            [
                "`renovate.json5` exists",
                "✅ Already exists"
                if self.renovate_config_exists
                else "🔧 Created by this PR",
                "—",
            ],
            [
                "Dependabot config removed",
                "✅ Not present"
                if not self.dependabot_config_path
                else "🔧 Removed by this PR",
                "—",
            ],
            [
                "Dependabot security updates",
                "✅ Disabled" if self.dependabot_security_disabled else "⚠️ Enabled",
                "—"
                if self.dependabot_security_disabled
                else f"[Disable in Settings]({settings_url})",
            ],
            [
                "Commit statuses permission",
                "✅ Token has access"
                if self.commit_statuses_permission
                else "⚠️ Cannot verify",
                "—"
                if self.commit_statuses_permission
                else f"[Check PAT permissions]({docs_url})",
            ],
        ]

        lines.append(
            render_table(
                table_data,
                headers=["Check", "Status", "Action"],
                table_format=TableFormat.GITHUB,
            )
        )
        lines.append("")
        lines.append("---")
        lines.append("")
        lines.append(
            "🤖 Generated with [gha-utils](https://github.com/kdeldycke/workflows)"
        )

        return "\n".join(lines)


def parse_exclude_newer_date(pyproject_path: Path) -> DateType | None:
    """Parse the exclude-newer date from pyproject.toml.

    :param pyproject_path: Path to the pyproject.toml file.
    :return: The date if found, None otherwise. Returns ``date.min`` for relative
        date strings (e.g., "1 week") to signal they should be replaced with
        fixed dates.
    """
    if not pyproject_path.exists():
        logging.debug(f"{pyproject_path} does not exist.")
        return None

    content = pyproject_path.read_text(encoding="utf-8")
    try:
        data = tomllib.loads(content)
    except tomllib.TOMLDecodeError as e:
        logging.warning(f"Failed to parse TOML: {e}")
        return None

    # Navigate to tool.uv.pip.exclude-newer or tool.uv.exclude-newer.
    tool_uv = data.get("tool", {}).get("uv", {})
    exclude_newer = tool_uv.get("pip", {}).get("exclude-newer") or tool_uv.get(
        "exclude-newer"
    )

    if not exclude_newer:
        logging.debug("No exclude-newer field found.")
        return None

    # Extract just the date part (YYYY-MM-DD) from the ISO timestamp.
    match = re.match(r"(\d{4}-\d{2}-\d{2})", exclude_newer)
    if not match:
        # Check if this is a relative date string (e.g., "1 week", "2 weeks").
        # uv supports these, but we want to replace them with fixed dates to
        # prevent uv.lock timestamp churn on every sync.
        if re.match(r"^\d+\s+(day|week|month|year)s?$", exclude_newer):
            logging.info(
                f"Found relative date {exclude_newer!r}, will convert to fixed date."
            )
            # Return date.min to signal this needs updating.
            return date.min
        logging.warning(f"Could not parse date from {exclude_newer!r}")
        return None

    return date.fromisoformat(match.group(1))


def calculate_target_date(days_ago: int = 7) -> DateType:
    """Calculate the target date for exclude-newer.

    :param days_ago: Number of days in the past. Defaults to 7.
    :return: The target date.
    """
    return date.today() - timedelta(days=days_ago)


def has_tool_uv_section(pyproject_path: Path) -> bool:
    """Check if pyproject.toml has a [tool.uv] section.

    :param pyproject_path: Path to the pyproject.toml file.
    :return: True if [tool.uv] section exists, False otherwise.
    """
    if not pyproject_path.exists():
        return False

    content = pyproject_path.read_text(encoding="utf-8")
    try:
        data = tomllib.loads(content)
    except tomllib.TOMLDecodeError:
        return False

    return "uv" in data.get("tool", {})


def add_exclude_newer_to_file(pyproject_path: Path, target_date: DateType) -> bool:
    """Add exclude-newer to [tool.uv] section if missing.

    Inserts the exclude-newer setting right after the [tool.uv] header line.
    Only works if [tool.uv] section exists but exclude-newer is not set.

    :param pyproject_path: Path to the pyproject.toml file.
    :param target_date: The date to set.
    :return: True if the file was modified, False otherwise.
    """
    if not pyproject_path.exists():
        logging.info(f"{pyproject_path} does not exist.")
        return False

    content = pyproject_path.read_text(encoding="utf-8")

    # Check if [tool.uv] exists.
    if not re.search(r"^\[tool\.uv\]", content, re.MULTILINE):
        logging.info("No [tool.uv] section found.")
        return False

    # Check if exclude-newer already exists.
    if re.search(r"^exclude-newer\s*=", content, re.MULTILINE):
        logging.debug("exclude-newer already exists.")
        return False

    new_timestamp = f"{target_date.isoformat()}T00:00:00Z"

    # Insert exclude-newer after [tool.uv] line.
    # The setting is added with a comment explaining its purpose.
    exclude_newer_block = (
        "# Cooldown period: 7-day buffer before using newly released packages.\n"
        '# Use a fixed date (not relative like "1 week") to prevent uv.lock '
        "timestamp churn.\n"
        "# This date is auto-updated by the autofix workflow when it becomes stale.\n"
        f'exclude-newer = "{new_timestamp}"\n'
    )

    new_content = re.sub(
        r"^(\[tool\.uv\]\n)",
        rf"\g<1>{exclude_newer_block}",
        content,
        count=1,
        flags=re.MULTILINE,
    )

    if new_content == content:
        logging.warning("Failed to insert exclude-newer.")
        return False

    pyproject_path.write_text(new_content, encoding="utf-8")
    logging.info(f"Added exclude-newer = {new_timestamp}")
    return True


def update_exclude_newer_in_file(pyproject_path: Path, target_date: DateType) -> bool:
    """Update the exclude-newer date in pyproject.toml.

    Uses regex replacement to preserve formatting and comments.

    :param pyproject_path: Path to the pyproject.toml file.
    :param target_date: The new date to set.
    :return: True if the file was modified, False otherwise.
    """
    if not pyproject_path.exists():
        logging.info(f"{pyproject_path} does not exist.")
        return False

    content = pyproject_path.read_text(encoding="utf-8")
    new_timestamp = f"{target_date.isoformat()}T00:00:00Z"

    # Replace the exclude-newer value.
    new_content, count = re.subn(
        r'(exclude-newer\s*=\s*")[^"]*(")',
        rf"\g<1>{new_timestamp}\g<2>",
        content,
    )

    if count == 0:
        logging.info("No exclude-newer field found to update.")
        return False

    if new_content == content:
        logging.info("exclude-newer date is already up to date.")
        return False

    pyproject_path.write_text(new_content, encoding="utf-8")
    logging.info(f"Updated exclude-newer to {new_timestamp}")
    return True


def check_dependabot_config_absent() -> tuple[bool, str]:
    """Check that no Dependabot version updates config file exists.

    Renovate handles dependency updates, so Dependabot should be disabled.

    :return: Tuple of (passed, message).
    """
    for filename in (".github/dependabot.yaml", ".github/dependabot.yml"):
        if Path(filename).exists():
            msg = (
                f"Dependabot config found at {filename}. "
                "Remove it and migrate to Renovate: "
                "run `gha-utils init renovate` to get a starter config, "
                "then use the reusable renovate.yaml workflow."
            )
            return False, msg

    return True, "Dependabot version updates: disabled (no config file)"


def check_dependabot_security_disabled(repo: str) -> tuple[bool, str]:
    """Check that Dependabot security updates are disabled.

    Renovate creates security PRs instead.

    :param repo: Repository in 'owner/repo' format.
    :return: Tuple of (passed, message).
    """
    try:
        output = run_gh_command([
            "api",
            f"repos/{repo}",
            "--jq",
            '.security_and_analysis.dependabot_security_updates.status // "disabled"',
        ])
        status = output.strip()
    except RuntimeError as e:
        logging.warning(f"Failed to check Dependabot security status: {e}")
        return True, "Could not verify Dependabot security updates status."

    if status == "enabled":
        msg = (
            "Dependabot security updates are enabled. Disable them in "
            "Settings > Advanced Security > Dependabot > Dependabot security updates."
        )
        return False, msg

    return True, "Dependabot security updates: disabled (Renovate handles security PRs)"


def check_commit_statuses_permission(repo: str, sha: str) -> tuple[bool, str]:
    """Check that the token has commit statuses permission.

    Required for Renovate to set stability-days status checks.

    :param repo: Repository in 'owner/repo' format.
    :param sha: Commit SHA to check.
    :return: Tuple of (passed, message). This check never fails fatally.
    """
    try:
        run_gh_command([
            "api",
            f"repos/{repo}/commits/{sha}/statuses",
            "--silent",
        ])
        return True, "Commit statuses: token has access"
    except RuntimeError:
        msg = (
            "Cannot verify commit statuses permission. "
            "Ensure the token has 'Commit statuses: Read and Write' permission."
        )
        return True, msg  # Non-fatal.


def run_renovate_prereq_checks(repo: str, sha: str) -> int:
    """Run all Renovate prerequisite checks.

    Emits GitHub Actions annotations for each check result.

    :param repo: Repository in 'owner/repo' format.
    :param sha: Commit SHA for permission checks.
    :return: Exit code (0 for success, 1 for fatal errors).
    """
    fatal_error = False

    # Check 1: Dependabot config file.
    passed, msg = check_dependabot_config_absent()
    if passed:
        print(f"✓ {msg}")
    else:
        emit_annotation(AnnotationLevel.ERROR, msg)
        fatal_error = True

    # Check 2: Dependabot security updates.
    passed, msg = check_dependabot_security_disabled(repo)
    if passed:
        print(f"✓ {msg}")
    else:
        emit_annotation(AnnotationLevel.ERROR, msg)
        fatal_error = True

    # Check 3: Commit statuses permission (non-fatal).
    passed, msg = check_commit_statuses_permission(repo, sha)
    if passed:
        print(f"✓ {msg}")
    else:
        emit_annotation(AnnotationLevel.WARNING, msg)

    return 1 if fatal_error else 0


def check_renovate_config_exists() -> tuple[bool, str]:
    """Check if renovate.json5 configuration file exists.

    :return: Tuple of (exists, message).
    """
    if Path("renovate.json5").exists():
        return True, "Renovate config: renovate.json5 exists"

    msg = "renovate.json5 not found. Run `gha-utils init renovate` to create it."
    return False, msg


def get_dependabot_config_path() -> Path | None:
    """Get the path to the Dependabot configuration file if it exists.

    :return: Path to the Dependabot config file, or None if not found.
    """
    for filename in (".github/dependabot.yaml", ".github/dependabot.yml"):
        path = Path(filename)
        if path.exists():
            return path
    return None


def collect_check_results(repo: str, sha: str) -> RenovateCheckResult:
    """Collect all Renovate prerequisite check results.

    Runs all checks and returns structured results that can be formatted
    as JSON or GitHub Actions output.

    :param repo: Repository in 'owner/repo' format.
    :param sha: Commit SHA for permission checks.
    :return: RenovateCheckResult with all check outcomes.
    """
    # Check 1: Renovate config exists.
    renovate_exists, _ = check_renovate_config_exists()

    # Check 2: Dependabot config path.
    dependabot_path = get_dependabot_config_path()

    # Check 3: Dependabot security updates disabled.
    security_disabled, _ = check_dependabot_security_disabled(repo)

    # Check 4: Commit statuses permission.
    statuses_permission, _ = check_commit_statuses_permission(repo, sha)

    return RenovateCheckResult(
        renovate_config_exists=renovate_exists,
        dependabot_config_path=dependabot_path.as_posix() if dependabot_path else "",
        dependabot_security_disabled=security_disabled,
        commit_statuses_permission=statuses_permission,
        repo=repo,
    )


def run_migration_checks(repo: str, sha: str) -> int:
    """Run Renovate migration prerequisite checks with console output.

    Checks for:
    - Missing renovate.json5 configuration
    - Existing Dependabot configuration
    - Dependabot security updates enabled
    - Token commit statuses permission

    :param repo: Repository in 'owner/repo' format.
    :param sha: Commit SHA for permission checks.
    :return: Exit code (0 for success, 1 for fatal errors).
    """
    fatal_error = False

    # Check 1: Renovate config exists.
    renovate_exists, renovate_msg = check_renovate_config_exists()
    if renovate_exists:
        print(f"✓ {renovate_msg}")
    else:
        emit_annotation(AnnotationLevel.ERROR, renovate_msg)
        fatal_error = True

    # Check 2: Dependabot config absent.
    passed, msg = check_dependabot_config_absent()
    if passed:
        print(f"✓ {msg}")
    else:
        emit_annotation(AnnotationLevel.ERROR, msg)
        fatal_error = True

    # Check 3: Dependabot security updates disabled.
    passed, msg = check_dependabot_security_disabled(repo)
    if passed:
        print(f"✓ {msg}")
    else:
        emit_annotation(AnnotationLevel.ERROR, msg)
        fatal_error = True

    # Check 4: Commit statuses permission (non-fatal).
    passed, msg = check_commit_statuses_permission(repo, sha)
    if passed:
        print(f"✓ {msg}")
    else:
        emit_annotation(AnnotationLevel.WARNING, msg)

    return 1 if fatal_error else 0
