from datetime import datetime
from typing import Optional

import github
import requests
from pydantic.v1 import BaseModel

from prowler.lib.logger import logger
from prowler.providers.github.lib.service.service import GithubService
from prowler.providers.github.models import GithubAppIdentityInfo


class Repository(GithubService):
    def __init__(self, provider):
        super().__init__(__class__.__name__, provider)
        self.repositories = self._list_repositories()

    def _file_exists(self, repo, filename):
        """Check if a file exists in the repository. Returns True if exists, False if not, None if error."""
        try:
            return repo.get_contents(filename) is not None
        except Exception as error:
            if "404" in str(error):
                return False
            else:
                logger.error(
                    f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
                )
                return None

    def _validate_repository_format(self, repo_name: str) -> bool:
        """
        Validate repository name format.

        Args:
            repo_name: Repository name to validate

        Returns:
            bool: True if format is valid, False otherwise
        """
        if not repo_name or "/" not in repo_name:
            return False

        parts = repo_name.split("/")
        if len(parts) != 2:
            return False

        owner, repo = parts
        # Ensure both owner and repo names are non-empty
        if not owner.strip() or not repo.strip():
            return False

        return True

    def _get_accessible_repos_graphql(self) -> list[str]:
        """
        Use the GitHub GraphQL API to list all repositories that the authentication token has access to.
        This works with high-granularity (fine-grained) PATs.
        """
        graphql_url = "https://api.github.com/graphql"
        token = self.provider.session.token
        headers = {
            "Authorization": f"bearer {token}",
            "Content-Type": "application/json",
        }
        query = """
        {
          viewer {
            repositories(first: 100, affiliations: [OWNER, ORGANIZATION_MEMBER]) {
              nodes {
                nameWithOwner
              }
            }
          }
        }
        """

        try:
            response = requests.post(
                graphql_url, json={"query": query}, headers=headers
            )
            response.raise_for_status()
            data = response.json()

            if "errors" in data:
                logger.error(f"Error in GraphQL query: {data['errors']}")
                return []

            repo_nodes = (
                data.get("data", {})
                .get("viewer", {})
                .get("repositories", {})
                .get("nodes", [])
            )
            return [repo["nameWithOwner"] for repo in repo_nodes]

        except requests.exceptions.RequestException as error:
            logger.error(
                f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
            )
            return []

    def _list_repositories(self):
        """
        List repositories based on provider scoping configuration.
        If the provider is a GitHub App, it will list repositories in the organizations that the GitHub App is installed in.
        If the provider is a user, it will list repositories where the user is a member or owner.
        If input repositories are provided, it will list repositories that match the input repositories.
        If input organizations are provided, it will list repositories in the organizations that match the input organizations.
        """
        logger.info("Repository - Listing Repositories...")
        repos = {}
        try:
            for client in self.clients:
                if (
                    self.provider.repositories
                    or self.provider.organizations
                    or (
                        isinstance(self.provider.identity, GithubAppIdentityInfo)
                        and self.provider.identity.installations
                    )
                ):
                    if self.provider.repositories:
                        logger.info(
                            f"Filtering for specific repositories: {self.provider.repositories}"
                        )
                        for repo_name in self.provider.repositories:
                            if not self._validate_repository_format(repo_name):
                                logger.warning(
                                    f"Repository name '{repo_name}' should be in 'owner/repo-name' format. Skipping."
                                )
                                continue
                            try:
                                repo = client.get_repo(repo_name)
                                self._process_repository(repo, repos)
                            except Exception as error:
                                self._handle_github_api_error(
                                    error, "accessing repository", repo_name
                                )

                    if self.provider.organizations:
                        logger.info(
                            f"Filtering for repositories in organizations: {self.provider.organizations}"
                        )
                        for org_name in self.provider.organizations:
                            try:
                                repos_list, _ = self._get_repositories_from_owner(
                                    client, org_name
                                )
                                for repo in repos_list:
                                    self._process_repository(repo, repos)
                            except Exception as error:
                                self._handle_github_api_error(
                                    error, "processing organization", org_name
                                )
                    elif (
                        isinstance(self.provider.identity, GithubAppIdentityInfo)
                        and self.provider.identity.installations
                        and not self.provider.repositories
                    ):
                        logger.info(
                            f"Filtering for repositories in the organizations or accounts that the GitHub App is installed in: {', '.join(self.provider.identity.installations)}"
                        )
                        for org_name in self.provider.identity.installations:
                            try:
                                repos_list, _ = self._get_repositories_from_owner(
                                    client, org_name
                                )
                                for repo in repos_list:
                                    self._process_repository(repo, repos)
                            except Exception as error:
                                self._handle_github_api_error(
                                    error, "processing organization", org_name
                                )
                else:
                    logger.info(
                        "No repository or organization specified, discovering accessible repositories via GraphQL API..."
                    )
                    accessible_repo_names = self._get_accessible_repos_graphql()

                    if not accessible_repo_names:
                        logger.warning(
                            "Could not find any accessible repositories with the provided token."
                        )

                    for repo_name in accessible_repo_names:
                        try:
                            repo = client.get_repo(repo_name)
                            logger.info(
                                f"Processing repository found via GraphQL: {repo.full_name}"
                            )
                            self._process_repository(repo, repos)
                        except Exception as error:
                            if hasattr(self, "_handle_github_api_error"):
                                self._handle_github_api_error(
                                    error,
                                    "accessing repository discovered via GraphQL",
                                    repo_name,
                                )
                            else:
                                logger.error(
                                    f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
                                )

        except github.RateLimitExceededException as error:
            logger.error(f"GitHub API rate limit exceeded: {error}")
            raise
        except github.GithubException as error:
            logger.error(f"GitHub API error while listing repositories: {error}")
        except Exception as error:
            logger.error(
                f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
            )
        return repos

    def _process_repository(self, repo, repos):
        """Process a single repository and extract all its information."""
        try:
            default_branch = repo.default_branch
            securitymd_exists = self._file_exists(repo, "SECURITY.md")
            # CODEOWNERS file can be in .github/, root, or docs/
            # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location
            codeowners_paths = [
                ".github/CODEOWNERS",
                "CODEOWNERS",
                "docs/CODEOWNERS",
            ]
            codeowners_files = [
                self._file_exists(repo, path) for path in codeowners_paths
            ]
            if True in codeowners_files:
                codeowners_exists = True
            elif all(file is None for file in codeowners_files):
                codeowners_exists = None
            else:
                codeowners_exists = False
            delete_branch_on_merge = (
                repo.delete_branch_on_merge
                if repo.delete_branch_on_merge is not None
                else False
            )

            require_pr = False
            approval_cnt = 0
            branch_protection = False
            required_linear_history = False
            allow_force_pushes = True
            branch_deletion = True
            require_code_owner_reviews = False
            require_signed_commits = False
            status_checks = False
            enforce_admins = False
            conversation_resolution = False
            try:
                branch = repo.get_branch(default_branch)
                if branch.protected:
                    protection = branch.get_protection()
                    if protection:
                        require_pr = (
                            protection.required_pull_request_reviews is not None
                        )
                        approval_cnt = (
                            protection.required_pull_request_reviews.required_approving_review_count
                            if require_pr
                            else 0
                        )
                        required_linear_history = protection.required_linear_history
                        allow_force_pushes = protection.allow_force_pushes
                        branch_deletion = protection.allow_deletions
                        status_checks = protection.required_status_checks is not None
                        enforce_admins = protection.enforce_admins
                        conversation_resolution = (
                            protection.required_conversation_resolution
                        )
                        branch_protection = True
                        require_code_owner_reviews = (
                            protection.required_pull_request_reviews.require_code_owner_reviews
                            if require_pr
                            else False
                        )
                        require_signed_commits = branch.get_required_signatures()
            except Exception as error:
                # If the branch is not found, it is not protected
                if "404" in str(error):
                    logger.warning(
                        f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
                    )
                # Any other error, we cannot know if the branch is protected or not
                else:
                    require_pr = None
                    approval_cnt = None
                    branch_protection = None
                    required_linear_history = None
                    allow_force_pushes = None
                    branch_deletion = None
                    require_code_owner_reviews = None
                    require_signed_commits = None
                    status_checks = None
                    enforce_admins = None
                    conversation_resolution = None
                    logger.error(
                        f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
                    )

            secret_scanning_enabled = False
            dependabot_alerts_enabled = False
            try:
                if (
                    repo.security_and_analysis
                    and repo.security_and_analysis.secret_scanning
                ):
                    secret_scanning_enabled = (
                        repo.security_and_analysis.secret_scanning.status == "enabled"
                    )
                try:
                    # Use get_dependabot_alerts to check if Dependabot alerts are enabled
                    repo.get_dependabot_alerts().totalCount
                    # If the call succeeds, Dependabot is enabled (even if no alerts)
                    dependabot_alerts_enabled = True
                except Exception as error:
                    error_str = str(error)
                    if (
                        "403" in error_str
                        and "Dependabot alerts are disabled for this repository."
                        in error_str
                    ):
                        dependabot_alerts_enabled = False
                    else:
                        logger.error(
                            f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
                        )
                        dependabot_alerts_enabled = None
            except Exception as error:
                logger.error(
                    f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
                )
                secret_scanning_enabled = None
                dependabot_alerts_enabled = None
            repos[repo.id] = Repo(
                id=repo.id,
                name=repo.name,
                owner=repo.owner.login,
                full_name=repo.full_name,
                immutable_releases_enabled=self._get_repository_immutable_releases_status(
                    repo
                ),
                default_branch=Branch(
                    name=default_branch,
                    protected=branch_protection,
                    default_branch=True,
                    require_pull_request=require_pr,
                    approval_count=approval_cnt,
                    required_linear_history=required_linear_history,
                    allow_force_pushes=allow_force_pushes,
                    branch_deletion=branch_deletion,
                    status_checks=status_checks,
                    enforce_admins=enforce_admins,
                    conversation_resolution=conversation_resolution,
                    require_code_owner_reviews=require_code_owner_reviews,
                    require_signed_commits=require_signed_commits,
                ),
                private=repo.private,
                archived=repo.archived,
                pushed_at=repo.pushed_at,
                securitymd=securitymd_exists,
                codeowners_exists=codeowners_exists,
                secret_scanning_enabled=secret_scanning_enabled,
                dependabot_alerts_enabled=dependabot_alerts_enabled,
                delete_branch_on_merge=delete_branch_on_merge,
            )
        except Exception as error:
            logger.error(
                f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
            )

    def _get_repository_immutable_releases_status(self, repo) -> Optional[bool]:
        """Retrieve the immutable releases status for the provided repository.

        The API returns a response in the format:
        {
            "enabled": true,
            "enforced_by_owner": false
        }

        Args:
            repo: The PyGithub repository instance to query.

        Returns:
            Optional[bool]: True when immutable releases are enabled, False when they are disabled, and None when the status cannot be determined.
        """
        try:
            _, response = repo._requester.requestJsonAndCheck(  # type: ignore[attr-defined]
                "GET",
                f"/repos/{repo.full_name}/immutable-releases",
                headers={
                    "Accept": "application/vnd.github+json",
                    "X-GitHub-Api-Version": "2022-11-28",
                },
            )
            if isinstance(response, dict) and "enabled" in response:
                return response.get("enabled")
            return None
        except github.GithubException as error:
            status_code = getattr(error, "status", None)
            if status_code == 404:
                logger.info(
                    f"{repo.full_name}: immutable releases endpoint not available for this repository."
                )
                return None
            if status_code == 403:
                logger.warning(
                    f"{repo.full_name}: insufficient permissions to query immutable releases endpoint."
                )
                return None
            self._handle_github_api_error(
                error, "fetching immutable releases status", repo.full_name
            )
        except Exception as error:
            logger.error(
                f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
            )
        return None


class Branch(BaseModel):
    """Model for Github Branch"""

    name: str
    protected: Optional[bool]
    default_branch: bool
    require_pull_request: Optional[bool]
    approval_count: Optional[int]
    required_linear_history: Optional[bool]
    allow_force_pushes: Optional[bool]
    branch_deletion: Optional[bool]
    status_checks: Optional[bool]
    enforce_admins: Optional[bool]
    require_code_owner_reviews: Optional[bool]
    require_signed_commits: Optional[bool]
    conversation_resolution: Optional[bool]


class Repo(BaseModel):
    """Model for Github Repository"""

    id: int
    name: str
    owner: str
    full_name: str
    immutable_releases_enabled: Optional[bool] = None
    default_branch: Branch
    private: bool
    archived: bool
    pushed_at: datetime
    securitymd: Optional[bool]
    codeowners_exists: Optional[bool]
    secret_scanning_enabled: Optional[bool]
    dependabot_alerts_enabled: Optional[bool]
    delete_branch_on_merge: Optional[bool]
