import logging
from dataclasses import dataclass
from typing import Optional, Union

import requests
from jira import JIRA, JIRAError
from jira.resources import GreenHopperResource
from requests.auth import AuthBase

from jf_ingest import logging_helper
from jf_ingest.config import JiraAuthConfig, JiraAuthMethod, JiraDownloadConfig
from jf_ingest.constants import Constants
from jf_ingest.jf_jira.exceptions import AtlassianConnectException
from jf_ingest.utils import retry_for_status

logger = logging.getLogger(__name__)


class JiraAuthenticationException(Exception):
    def __init__(self, *args, original_exception: Optional[Exception] = None):
        self.original_exception = original_exception
        super().__init__(*args)


# HACK(asm,2024-05-10): We are seeing questionable HTTP 401 responses from Jira that seem to be
# related to cookie-based authentication validation. Specifically, we believe that requests made
# using a bearer token are creating an expiring session on the server side, and it is causing Jira
# to (incorrectly) challenge us to re-authenticate based on this expired session. To avoid this, we
# are customizing the authentication method so that we never send cookies when we are using bearer
# token authentication. If this works, this should be upstreamed.
class TokenAuth(AuthBase):
    def __init__(self, token: str):
        self._token = token

    def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
        # never send cookies for token auth
        r.headers["Cookie"] = ''
        r.headers["authorization"] = f"Bearer {self._token}"
        return r


def get_jira_connection(
    config: Union[JiraAuthConfig, JiraDownloadConfig],
    auth_method: JiraAuthMethod = JiraAuthMethod.BasicAuth,
    max_retries=3,
    use_jql_enhanced_search: bool = False,
) -> JIRA:
    """Get a JIRA connection object

    Args:
        config (JiraAuthConfig | JiraDownloadConfig): A JIRA Configuration Object that contains authentication information
        auth_method (JiraAuthMethod, optional): The Authentication method used. BasicAuth and AtlassianConnect are currently supported. Defaults to JiraAuthMethod.BasicAuth.
        max_retries (int, optional): The retry limit used by the JiraConnection.ResilientSession object. Defaults to 3.
        use_jql_enhanced_search (bool, optional): Whether to configure connection for JQL Enhanced Search API. When True, uses REST API v3 for /search/jql endpoints. When False, uses REST API v2 for backward compatibility. Defaults to False.

    Raises:
        Several Errors can be raised by this class. Please ensure your config is properly set up

    Returns:
        JIRA: A JIRA connection object
    """
    should_ssl_verify = not config.bypass_ssl_verification

    # This is the base Jira Connection KWARGs. For different
    # authentication methods, we will need to add specific fields
    jira_conn_kwargs = {
        "timeout": 600,
        "server": config.url,
        "max_retries": max_retries,
        "options": {
            "agile_rest_path": GreenHopperResource.AGILE_BASE_REST_PATH,
            "verify": should_ssl_verify,
            "headers": {
                "Cache-Control": "no-cache",
                "Content-Type": "application/json",
                "Accept": "application/json,*/*;q=0.9",
                "X-Atlassian-Token": "no-check",
            },
        },
    }

    # Determine REST API version based on intended usage
    if use_jql_enhanced_search:
        jira_conn_kwargs['options']['rest_api_version'] = '3'

    using_token_auth = False

    if auth_method.value == JiraAuthMethod.AtlassianConnect.value:
        if not config.connect_app_active:
            raise RuntimeError(
                f'Atlassian connect integration for {config.company_slug} is disabled. Check "connect_app_active" flag state.'
            )

        # The customer has installed our Atlassian Connect app; we authenticate to the Jira
        # API using the JWT attributes
        shared_secret = config.jwt_attributes.get("sharedSecret")
        client_key = config.jwt_attributes.get("clientKey")
        key = config.jwt_attributes.get("key")

        if not (shared_secret and client_key and key):
            raise RuntimeError(
                f"Atlassian connect integration for {config.company_slug} is misconfigured"
            )

        logger.debug(f"Authenticating to Jira API at {config.url} using JWT attributes")
        jira_conn_kwargs['jwt'] = {
            "secret": shared_secret,
            "payload": {"iss": key, "aud": client_key},
        }
        jira_conn: JIRA = retry_for_status(
            JIRA, max_retries_for_retry_for_status=3, **jira_conn_kwargs
        )

    elif auth_method.value == JiraAuthMethod.BasicAuth.value:
        # Attempt to log a helpful warning if a customer has a Jira User provided but NOT a jira password provided
        if config.user and not config.password:
            optional_bearer_token_message = (
                f'A Jira Bearer token was being detected as being provided, so we will attempt to authenticate with that instead. '
                'If you would like to authenticate with only the Bear token please remove the Jira User field provided, as it is not necessary.'
            )
            logger.warning(
                f'Jira Authentication has detected that a Jira User value has been provided, '
                'but that Jira Password value was left blank. '
                'If you would like to authenticate using a User/Password scheme, please provide the username and password. '
                f'{optional_bearer_token_message if config.personal_access_token else ""}'
            )

        # Attempt user/password auth only if we have both a user and a password
        if config.user and config.password:
            logger.debug(
                f"Authenticating to Jira API at {config.url} "
                f"using the username and password secrets for {config.user} of company {config.company_slug}"
            )
            # Add in basic auth info
            jira_conn_kwargs['basic_auth'] = (config.user, config.password)
        elif config.personal_access_token:
            logger.debug(
                f"Authenticating to Jira API at {config.url} "
                f"using the personal_access_token secret for {config.company_slug}"
            )
            # Add in personal access token
            jira_conn_kwargs['token_auth'] = config.personal_access_token

            using_token_auth = True
        else:
            raise RuntimeError(
                f"No valid basic authentication mechanism for {config.url} - need a username/password combo or a personal access token"
            )

        while True:
            try:
                jira_conn = retry_for_status(
                    JIRA, max_retries_for_retry_for_status=3, **jira_conn_kwargs
                )
                break
            except JIRAError as e:  # catch generic error raised from JIRA
                if hasattr(e, "status_code") and e.status_code in (401, 403):
                    raise JiraAuthenticationException(
                        f"Jira authentication (HTTP ERROR CODE {e.status_code}) failed for {config.url} using {config.user} and the jira_password secret for {config.company_slug}",
                        original_exception=e,
                    )
                else:
                    raise
    else:
        raise RuntimeError(
            f"No valid authentication mechanism for {config.url} (Auth Method: {auth_method})."
        )

    # set user-agent
    jira_conn._session.headers["User-Agent"] = (
        f'{config.user_agent} ({jira_conn._session.headers["User-Agent"]})'
    )

    # HACK(asm,2024-05-10): See comment above `TokenAuth` class definition - overriding
    # authentication here to force no cookies.
    if using_token_auth and config.personal_access_token:
        jira_conn._session.auth = TokenAuth(config.personal_access_token)

    return jira_conn


def _test_enhanced_search_endpoint(jira_config: JiraDownloadConfig) -> bool:
    """Test if the enhanced /search/jql (v3) endpoint is reachable and working.

    Args:
        jira_config: The Jira configuration to use for creating a test connection

    Returns:
        bool: True if the endpoint returns a successful response, False otherwise
    """
    try:
        test_connection = get_jira_connection(config=jira_config, use_jql_enhanced_search=True)
    except Exception as e:
        logger.warning(f"Failed to create v3 test connection: {e}")
        return False

    try:
        payload = {
            'jql': 'updatedDate >= -1d order by id',
            'fields': ['id'],
            'maxResults': 1,
        }
        url = test_connection._get_url('search/jql')
        response = test_connection._session.post(url=url, json=payload)

        if 200 <= response.status_code < 300:
            return True

        logger.debug(f"Enhanced search endpoint /search/jql returned status {response.status_code}")
    except Exception as e:
        logger.debug(f"Failed to test /search/jql endpoint: {e}")

    return False


def _test_legacy_search_endpoint(jira_config: JiraDownloadConfig) -> bool:
    """Test if the legacy /search (v2) endpoint is reachable and working.

    Args:
        jira_config: The Jira configuration to use for creating a test connection

    Returns:
        bool: True if the endpoint returns a successful response, False otherwise
    """
    try:
        test_connection = get_jira_connection(config=jira_config, use_jql_enhanced_search=False)
    except Exception as e:
        logger.warning(f"Failed to create v2 test connection: {e}")
        return False

    try:
        payload = {
            'jql': 'updatedDate >= -1d order by id',
            'fields': ['id'],
            'maxResults': 1,
        }
        url = test_connection._get_url('search')
        response = test_connection._session.post(url=url, json=payload)

        if 200 <= response.status_code < 300:
            return True

        logger.debug(f"Legacy search endpoint /search returned status {response.status_code}")
    except Exception as e:
        logger.debug(f"Failed to test /search endpoint: {e}")

    return False


def determine_api_search_version(
    jira_config: JiraDownloadConfig,
    jql_enhanced_search_enabled: bool = False,
    force_legacy_api: bool = False,
) -> bool:
    """Dynamically determine which Jira search API to use by testing endpoint availability.

    Always tests the preferred endpoint with a live API call. If the preferred
    endpoint is unavailable, automatically falls back to the alternative.

    The enhanced /search/jql endpoint (v3 REST API):
    - Uses nextPageToken instead of startAt for pagination
    - Has a 5,000 issue limit (vs 10,000 for legacy /search)
    - May not be available on older Jira instances

    The legacy /search endpoint (v2 REST API):
    - Uses startAt for pagination
    - Is being deprecated on newer Jira Cloud instances

    Behavior by feature flags:
    - force_legacy_api=True: Always returns False (legacy), no endpoint testing. Use as an
      emergency escape hatch.
    - jql_enhanced_search_enabled=True: Tests enhanced endpoint first; if it fails, falls
      back to legacy.
    - jql_enhanced_search_enabled=False (default): Tests legacy endpoint first; if it fails,
      tests enhanced endpoint and uses it if available.

    Args:
        jira_config: The Jira configuration to use for creating the connection
        jql_enhanced_search_enabled: Whether the enhanced API is preferred via feature flag
        force_legacy_api: Whether to force use of legacy API via feature flag (escape hatch)

    Returns:
        bool: True to use enhanced /search/jql (v3), False to use legacy /search (v2)
    """
    # Escape hatch: force legacy with no testing
    if force_legacy_api:
        logger.info("Using Legacy API for Jira operations (forced via feature flag)")
        return False

    if jql_enhanced_search_enabled:
        # Prefer enhanced: test enhanced first, fall back to legacy
        if _test_enhanced_search_endpoint(jira_config):
            logger.info("Using JQL Enhanced Search API (/search/jql) for Jira operations")
            return True
        logger.info(
            "JQL Enhanced Search API preferred but unavailable, falling back to legacy /search API"
        )
        return False
    else:
        # Prefer legacy: test legacy first, fall back to enhanced if legacy is broken
        if _test_legacy_search_endpoint(jira_config):
            logger.info("Using Legacy API (/search) for Jira operations")
            return False
        logger.warning(
            "Legacy /search API unavailable, testing enhanced /search/jql API as fallback"
        )
        if _test_enhanced_search_endpoint(jira_config):
            logger.info(
                "Automatically switching to JQL Enhanced Search API (/search/jql) "
                "because legacy /search API is unavailable"
            )
            return True
        # Both endpoints failed -- default to legacy and let the caller deal with errors
        logger.warning(
            "Both search API endpoints had issues during testing, defaulting to legacy /search API"
        )
        return False


# Keep the old name as an alias for backward compatibility
is_jql_enhanced_search_available = determine_api_search_version


@dataclass
class JiraConnectionBundle:
    """
    Holds all Jira connection objects and related configuration produced
    by the standardized connection factory.
    """

    # The primary connection to use for most API calls (BasicAuth or AtlassianConnect based on feature flags)
    primary_connection: JIRA

    # The BasicAuth connection. Always available regardless of which auth is primary
    basic_connection: JIRA

    # An AtlassianConnect connection, if available. May be None
    atlas_connect_connection: Optional[JIRA]

    # Whether the primary_connection is using AtlassianConnect auth
    using_connect_as_primary_auth: bool

    # Whether the JQL Enhanced Search API (v3 /search/jql) is available and should be used
    use_jql_enhanced_search: bool


def create_jira_connection(
    jira_config: JiraDownloadConfig,
    auth_method: JiraAuthMethod = JiraAuthMethod.BasicAuth,
) -> tuple[JIRA, bool]:
    """Create a single Jira connection with automatic enhanced JQL search detection.

    This is the standard way to create a Jira connection when only a single
    connection is needed. It automatically checks whether the JQL Enhanced
    Search API (v3) is available based on feature flags and a live endpoint
    test.

    Args:
        jira_config: Jira download configuration containing auth info and feature flags
        auth_method: The authentication method to use. Defaults to BasicAuth.

    Returns:
        tuple of (JIRA connection, use_jql_enhanced_search bool)
    """
    use_enhanced = determine_api_search_version(
        jira_config=jira_config,
        jql_enhanced_search_enabled=jira_config.feature_flags.get(
            Constants.JQL_ENHANCED_SEARCH_ENABLED, False
        ),
        force_legacy_api=jira_config.feature_flags.get(Constants.FORCE_LEGACY_API, False),
    )
    conn = get_jira_connection(
        config=jira_config,
        auth_method=auth_method,
        use_jql_enhanced_search=use_enhanced,
    )
    return conn, use_enhanced


def create_jira_connections(jira_config: JiraDownloadConfig) -> JiraConnectionBundle:
    """Create all Jira connections with automatic enhanced JQL search detection
    and AtlassianConnect fallback logic.

    This is the standard way to create Jira connections when both BasicAuth and
    AtlassianConnect connections are needed (e.g., for the main download flow).
    It encapsulates:
    1. Dynamic detection of JQL Enhanced Search (v3) availability
    2. BasicAuth connection creation
    3. Optional AtlassianConnect connection creation
    4. Connect-or-fallback logic based on the lusca feature flag

    Args:
        jira_config: Jira download configuration containing auth info and feature flags

    Returns:
        JiraConnectionBundle with all connections and configuration
    """
    # 1. Determine search API version (extracted from feature flags)
    use_jql_enhanced_search = determine_api_search_version(
        jira_config=jira_config,
        jql_enhanced_search_enabled=jira_config.feature_flags.get(
            Constants.JQL_ENHANCED_SEARCH_ENABLED, False
        ),
        force_legacy_api=jira_config.feature_flags.get(Constants.FORCE_LEGACY_API, False),
    )

    # 2. Create BasicAuth connection
    jira_basic_connection = get_jira_connection(
        config=jira_config,
        auth_method=JiraAuthMethod.BasicAuth,
        use_jql_enhanced_search=use_jql_enhanced_search,
    )

    # 3. Create optional AtlassianConnect connection
    jira_atlas_connect_connection = (
        get_jira_connection(
            config=jira_config,
            auth_method=JiraAuthMethod.AtlassianConnect,
            use_jql_enhanced_search=use_jql_enhanced_search,
        )
        if JiraAuthMethod.AtlassianConnect in jira_config.available_auth_methods
        else None
    )

    # 4. Connect-or-fallback logic
    # There is an ongoing effort to cut all things over to Atlassian Connect only,
    # but it is a piecewise migration for now.
    # OJ-29745
    jira_connect_or_fallback_connection = jira_basic_connection
    using_connect_as_primary_auth = False

    if jira_config.feature_flags.get("lusca-auth-always-use-connect-for-atlassian-apis-Q423"):
        logging_helper.send_to_agent_log_file("Will use connect for most API calls")

        if not isinstance(jira_atlas_connect_connection, JIRA):
            raise AtlassianConnectException(
                'Atlassian Connect Connection is not a JIRA object, unable to proceed'
            )

        jira_connect_or_fallback_connection = jira_atlas_connect_connection
        using_connect_as_primary_auth = True
    else:
        logging_helper.send_to_agent_log_file("Will use basic auth for most API calls")

    return JiraConnectionBundle(
        primary_connection=jira_connect_or_fallback_connection,
        basic_connection=jira_basic_connection,
        atlas_connect_connection=jira_atlas_connect_connection,
        using_connect_as_primary_auth=using_connect_as_primary_auth,
        use_jql_enhanced_search=use_jql_enhanced_search,
    )
