"""
Standalone GAM (GeneXus Access Manager) OAuth 2.0 client.

This module provides a lightweight client for authenticating with GAM
without dependencies on the full PyGEAI ecosystem.
"""

import logging
import webbrowser
from typing import Dict, Optional, Any, Tuple, Callable, Union
from urllib.parse import urlencode

import requests

from pygeai_gam.endpoints import (
    GAM_SIGNIN_ENDPOINT,
    GAM_OAUTH_PASSWORD_GRANT_ENDPOINT,
    GAM_PASSWORD_GRANT_USERINFO_ENDPOINT,
    GAM_OAUTH_AUTHCODE_GRANT_ENDPOINT,
    GAM_USERINFO_AUTHCODE_GRANT_ENDPOINT,
    GAM_AUTHENTICATION_TYPES_ENDPOINT,
)
from pygeai_gam.exceptions import (
    GAMAuthenticationError,
    GAMAPIError,
    GAMInvalidResponseError,
    GAMMissingParameterError,
)
from pygeai_gam.pkce import generate_code_verifier, generate_code_challenge
from pygeai_gam.oauth_callback import OAuthCallbackServer
from pygeai_gam.types import GAMTokenResponse
from pygeai_gam.utils import resolve_redirect_uri, extract_port_from_uri, DYNAMIC_PORT_PLACEHOLDER

logger = logging.getLogger("pygeai_gam")


class GAMClient:
    """
    Lightweight OAuth 2.0 client for GAM (GeneXus Access Manager).

    This client provides methods for OAuth 2.0 authentication flows with GAM,
    including generating signin URLs, obtaining access tokens, retrieving user
    information, and refreshing tokens.

    :param base_url: Base URL of the GAM instance (e.g., "https://gam.example.com")
    :param timeout: Request timeout in seconds. Default: 30
    :param verify_ssl: Whether to verify SSL certificates. Default: True

    Example:
        >>> client = GAMClient(base_url="https://gam.example.com")
        >>> response = client.get_access_token(username="user", password="pass")
        >>> access_token = response["access_token"]
    """

    def __init__(self, base_url: str, timeout: int = 30, verify_ssl: bool = True):
        """
        Initialize the GAM client.

        :param base_url: Base URL of the GAM instance (e.g., "https://gam.example.com")
        :param timeout: Request timeout in seconds
        :param verify_ssl: Whether to verify SSL certificates
        """
        if not base_url:
            raise GAMMissingParameterError("base_url is required", parameter_name="base_url")

        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.verify_ssl = verify_ssl
        logger.debug(f"Initialized GAMClient with base_url={self.base_url}")

    def _make_request(
        self,
        method: str,
        endpoint: str,
        params: Optional[Dict[str, Any]] = None,
        data: Optional[Dict[str, Any]] = None,
        headers: Optional[Dict[str, str]] = None,
        form: bool = False,
    ) -> requests.Response:
        """
        Internal helper method to make HTTP requests.

        :param method: HTTP method (GET, POST, etc.)
        :param endpoint: API endpoint path
        :param params: URL query parameters
        :param data: Request body data
        :param headers: HTTP headers
        :param form: Whether to send data as form-encoded
        :return: Response object
        :raises GAMAuthenticationError: For 401/403 errors
        :raises GAMAPIError: For other HTTP errors
        """
        url = f"{self.base_url}/{endpoint.lstrip('/')}"
        headers = headers or {}

        logger.debug(f"Making {method} request to {url}")

        try:
            if form and data:
                response = requests.request(
                    method=method,
                    url=url,
                    data=data,
                    headers=headers,
                    params=params,
                    timeout=self.timeout,
                    verify=self.verify_ssl,
                )
            else:
                response = requests.request(
                    method=method,
                    url=url,
                    json=data if data and not form else None,
                    headers=headers,
                    params=params,
                    timeout=self.timeout,
                    verify=self.verify_ssl,
                )

            if response.status_code in [401, 403]:
                error_msg = f"Authentication failed: {response.status_code}"
                try:
                    error_data = response.json()
                    if "error" in error_data:
                        error_msg = f"Authentication failed: {error_data.get('error_description', error_data['error'])}"
                except Exception:
                    error_msg = f"Authentication failed: {response.text or response.reason}"

                logger.error(error_msg)
                raise GAMAuthenticationError(error_msg, status_code=response.status_code, response=response)

            if not response.ok:
                error_msg = f"API request failed with status {response.status_code}"
                try:
                    error_data = response.json()
                    if "error" in error_data:
                        error_msg = f"API error: {error_data.get('error_description', error_data['error'])}"
                except Exception:
                    error_msg = f"API error: {response.text or response.reason}"

                logger.error(error_msg)
                raise GAMAPIError(error_msg, status_code=response.status_code, response=response)

            return response

        except requests.exceptions.Timeout as e:
            error_msg = f"Request timeout after {self.timeout} seconds"
            logger.error(error_msg)
            raise GAMAPIError(error_msg) from e
        except requests.exceptions.ConnectionError as e:
            error_msg = f"Connection error: {str(e)}"
            logger.error(error_msg)
            raise GAMAPIError(error_msg) from e
        except requests.exceptions.RequestException as e:
            error_msg = f"Request failed: {str(e)}"
            logger.error(error_msg)
            raise GAMAPIError(error_msg) from e

    def generate_signing_url(
        self,
        client_id: str,
        redirect_uri: str,
        state: str,
        scope: str = "gam_user_data",
        response_type: str = "code",
        code_challenge: Optional[str] = None,
        code_challenge_method: Optional[str] = None,
        signin_endpoint: str = GAM_SIGNIN_ENDPOINT,
    ) -> str:
        """
        Generate an OAuth 2.0 signin URL for browser-based authentication.

        This method constructs the URL that users should visit to authenticate
        with GAM in a browser-based OAuth flow. Supports PKCE (Proof Key for
        Code Exchange) by accepting optional code_challenge parameters.

        :param client_id: Application client ID (required)
        :param redirect_uri: Callback URL configured in the application (required)
        :param state: Random string to prevent CSRF attacks (required)
        :param scope: OAuth scope (default: "gam_user_data")
        :param response_type: OAuth response type (default: "code")
        :param code_challenge: PKCE code challenge (optional, for PKCE flow)
        :param code_challenge_method: PKCE method, must be "S256" (optional, for PKCE flow)
        :param signin_endpoint: OAuth signin endpoint path (default: GAM_SIGNIN_ENDPOINT)
        :return: Authentication URL for browser redirection
        :raises GAMMissingParameterError: If required parameters are missing
        :raises ValueError: If code_challenge_method is provided but not "S256"

        Example:
            >>> client = GAMClient(base_url="https://gam.example.com")
            >>> url = client.generate_signing_url(
            ...     client_id="my_client",
            ...     redirect_uri="https://myapp.com/callback",
            ...     state="random_state"
            ... )

        Example (with PKCE):
            >>> from pygeai_gam import generate_code_verifier, generate_code_challenge
            >>> verifier = generate_code_verifier()
            >>> challenge = generate_code_challenge(verifier)
            >>> url = client.generate_signing_url(
            ...     client_id="my_client",
            ...     redirect_uri="https://myapp.com/callback",
            ...     state="random_state",
            ...     code_challenge=challenge,
            ...     code_challenge_method="S256"
            ... )
        """
        if not all([client_id, redirect_uri, state]):
            raise GAMMissingParameterError("client_id, redirect_uri, and state are required")

        if code_challenge_method is not None and code_challenge_method != "S256":
            raise ValueError(f"code_challenge_method must be 'S256', got '{code_challenge_method}'")

        params = {
            "response_type": response_type,
            "client_id": client_id,
            "redirect_uri": redirect_uri,
            "state": state,
        }
        if scope:
            params["scope"] = scope
        if code_challenge:
            params["code_challenge"] = code_challenge
        if code_challenge_method:
            params["code_challenge_method"] = code_challenge_method

        url = f"{self.base_url}/{signin_endpoint}?{urlencode(params)}"
        return url

    def get_access_token(
        self,
        client_id: Optional[str] = None,
        client_secret: Optional[str] = None,
        grant_type: str = "password",
        authentication_type_name: str = "local",
        scope: str = "gam_user_data",
        username: Optional[str] = None,
        password: Optional[str] = None,
        initial_properties: Optional[Dict[str, Any]] = None,
        repository: Optional[str] = None,
        request_token_type: str = "OAuth",
    ) -> Dict[str, Any]:
        """
        Obtain an access token using various authentication methods.

        Supports multiple grant types including password (username/password)
        and client_credentials flows.

        :param client_id: Application client ID
        :param client_secret: Application client secret
        :param grant_type: Grant type (default: "password")
        :param authentication_type_name: Authentication type (default: "local")
        :param scope: OAuth scope (default: "gam_user_data")
        :param username: Username for password grant
        :param password: Password for password grant
        :param initial_properties: Custom user properties
        :param repository: Repository identifier (for multitenant)
        :param request_token_type: Token type (default: "OAuth")
        :return: Dictionary with access_token, refresh_token, expires_in, etc.
        :raises GAMAuthenticationError: If authentication fails
        :raises GAMInvalidResponseError: If response cannot be parsed

        Example:
            >>> client = GAMClient(base_url="https://gam.example.com")
            >>> response = client.get_access_token(
            ...     username="user@example.com",
            ...     password="secret",
            ...     scope="gam_user_data+gam_user_roles"
            ... )
            >>> access_token = response["access_token"]
        """
        data = {
            "grant_type": grant_type,
            "authentication_type_name": authentication_type_name,
            "scope": scope,
            "request_token_type": request_token_type,
        }

        if client_id is not None:
            data["client_id"] = client_id
        if client_secret is not None:
            data["client_secret"] = client_secret
        if username is not None:
            data["username"] = username
        if password is not None:
            data["password"] = password
        if initial_properties is not None:
            data["initial_properties"] = initial_properties
        if repository is not None:
            data["repository"] = repository

        headers = {"Content-Type": "application/x-www-form-urlencoded"}

        response = self._make_request(
            method="POST",
            endpoint=GAM_OAUTH_PASSWORD_GRANT_ENDPOINT,
            data=data,
            headers=headers,
            form=True,
        )

        try:
            result = response.json()
            logger.debug("Successfully obtained access token")
            return result
        except Exception as e:
            error_msg = f"Unable to parse access token response: {response.text}"
            logger.error(f"{error_msg}: {e}")
            raise GAMInvalidResponseError(error_msg, response=response) from e

    def get_user_info(
        self,
        access_token: str,
        userinfo_endpoint: str = GAM_USERINFO_AUTHCODE_GRANT_ENDPOINT,
    ) -> Dict[str, Any]:
        """
        Retrieve user information using an access token.

        :param access_token: Valid access token (required)
        :param userinfo_endpoint: Userinfo endpoint path. Defaults to authorization code grant endpoint.
                                 Use GAM_PASSWORD_GRANT_USERINFO_ENDPOINT for password grant flow.
        :return: Dictionary with user information (guid, username, email, etc.)
        :raises GAMMissingParameterError: If access_token is missing
        :raises GAMAuthenticationError: If token is invalid or expired
        :raises GAMInvalidResponseError: If response cannot be parsed

        Example (authorization code grant):
            >>> client = GAMClient(base_url="https://gam.example.com")
            >>> user_info = client.get_user_info(access_token="your_token")
            >>> print(user_info["username"])

        Example (password grant):
            >>> from pygeai_gam.endpoints import GAM_PASSWORD_GRANT_USERINFO_ENDPOINT
            >>> user_info = client.get_user_info(
            ...     access_token="your_token",
            ...     userinfo_endpoint=GAM_PASSWORD_GRANT_USERINFO_ENDPOINT
            ... )
        """
        if not access_token:
            raise GAMMissingParameterError("access_token is required", parameter_name="access_token")

        endpoint = userinfo_endpoint

        headers = {
            "Authorization": access_token,
            "Content-Type": "application/x-www-form-urlencoded",
        }

        response = self._make_request(
            method="GET",
            endpoint=endpoint,
            headers=headers,
        )

        try:
            result = response.json()
            logger.debug(f"Successfully retrieved user info for user: {result.get('username', 'unknown')}")
            return result
        except Exception as e:
            error_msg = f"Unable to parse user info response: {response.text}"
            logger.error(f"{error_msg}: {e}")
            raise GAMInvalidResponseError(error_msg, response=response) from e

    def refresh_access_token(
        self,
        client_id: Optional[str] = None,
        client_secret: Optional[str] = None,
        grant_type: str = "refresh_token",
        refresh_token: Optional[str] = None,
        return_type: str = "dict",
    ) -> Union[GAMTokenResponse, Dict[str, Any]]:
        """
        Refresh an expired access token.

        :param client_id: Application client ID
        :param client_secret: Application client secret
        :param grant_type: Grant type (default: "refresh_token")
        :param refresh_token: Refresh token from previous authentication
        :param return_type: Response format: "dict" (backward compatible), "typed" (GAMTokenResponse)
        :return: GAMTokenResponse or Dict with new access_token and refresh_token based on return_type
        :rtype: Union[GAMTokenResponse, Dict[str, Any]]
        :raises GAMAuthenticationError: If refresh fails
        :raises GAMInvalidResponseError: If response cannot be parsed

        Example (dict return):
            >>> client = GAMClient(base_url="https://gam.example.com")
            >>> new_response = client.refresh_access_token(
            ...     client_id="my_client",
            ...     client_secret="my_secret",
            ...     refresh_token="old_refresh_token"
            ... )
            >>> new_access_token = new_response["access_token"]
        
        Example (typed return):
            >>> new_response = client.refresh_access_token(
            ...     client_id="my_client",
            ...     client_secret="my_secret",
            ...     refresh_token="old_refresh_token",
            ...     return_type="typed"
            ... )
            >>> new_access_token = new_response.access_token
        """
        data = {
            "grant_type": grant_type,
        }

        if client_id is not None:
            data["client_id"] = client_id
        if client_secret is not None:
            data["client_secret"] = client_secret
        if refresh_token is not None:
            data["refresh_token"] = refresh_token

        headers = {"Content-Type": "application/x-www-form-urlencoded"}

        response = self._make_request(
            method="POST",
            endpoint=GAM_OAUTH_PASSWORD_GRANT_ENDPOINT,
            data=data,
            headers=headers,
            form=True,
        )

        try:
            result = response.json()
            logger.debug("Successfully refreshed access token")
            
            # Return based on return_type
            if return_type == "dict":
                return result
            else:
                return GAMTokenResponse.from_dict(result)
        except Exception as e:
            error_msg = f"Unable to parse refresh token response: {response.text}"
            logger.error(f"{error_msg}: {e}")
            raise GAMInvalidResponseError(error_msg, response=response) from e

    def get_authentication_types(self) -> Dict[str, Any]:
        """
        Get available authentication types from GAM.

        :return: Dictionary with authentication types information
        :raises GAMAPIError: If the request fails
        :raises GAMInvalidResponseError: If response cannot be parsed

        Example:
            >>> client = GAMClient(base_url="https://gam.example.com")
            >>> auth_types = client.get_authentication_types()
        """
        response = self._make_request(
            method="GET",
            endpoint=GAM_AUTHENTICATION_TYPES_ENDPOINT,
        )

        try:
            result = response.json()
            logger.debug("Successfully retrieved authentication types")
            return result
        except Exception as e:
            error_msg = f"Unable to parse authentication types response: {response.text}"
            logger.error(f"{error_msg}: {e}")
            raise GAMInvalidResponseError(error_msg, response=response) from e

    def initiate_pkce_flow(
        self,
        client_id: str,
        redirect_uri: str,
        state: str,
        scope: str = "gam_user_data",
    ) -> Tuple[str, str]:
        """
        Initiate a PKCE (Proof Key for Code Exchange) authentication flow.

        This method generates a code_verifier and code_challenge, then constructs
        the authorization URL for the PKCE flow. The code_verifier must be stored
        securely by the caller and used later in exchange_code_for_token().

        PKCE is recommended for all OAuth 2.0 clients, especially public clients
        like mobile apps, SPAs, and CLI tools, as it prevents authorization code
        interception attacks.

        :param client_id: Application client ID (required)
        :param redirect_uri: Callback URL configured in the application (required)
        :param state: Random string to prevent CSRF attacks (required)
        :param scope: OAuth scope (default: "gam_user_data")
        :return: Tuple of (authorization_url, code_verifier)
        :rtype: Tuple[str, str]
        :raises GAMMissingParameterError: If required parameters are missing

        Example:
            >>> client = GAMClient(base_url="https://gam.example.com")
            >>> auth_url, code_verifier = client.initiate_pkce_flow(
            ...     client_id="my_app",
            ...     redirect_uri="https://myapp.com/callback",
            ...     state="random_csrf_token"
            ... )
            >>> session['code_verifier'] = code_verifier
            >>> redirect(auth_url)

        Security Notes:
            - Store code_verifier securely (session, keychain, etc.)
            - NEVER send code_verifier in the authorization request
            - Use HTTPS for redirect_uri in production
            - Generate a cryptographically random state parameter

        See Also:
            - exchange_code_for_token(): Exchange authorization code for tokens
            - RFC 7636: https://datatracker.ietf.org/doc/html/rfc7636
        """
        code_verifier = generate_code_verifier()
        code_challenge = generate_code_challenge(code_verifier)

        logger.debug(f"Generated PKCE code_verifier: {code_verifier}")
        logger.debug(f"Generated PKCE code_challenge: {code_challenge}")

        authorization_url = self.generate_signing_url(
            client_id=client_id,
            redirect_uri=redirect_uri,
            state=state,
            scope=scope,
            code_challenge=code_challenge,
            code_challenge_method="S256",
        )

        logger.debug("Initiated PKCE flow")
        return authorization_url, code_verifier

    def exchange_code_for_token(
        self,
        code: str,
        code_verifier: str,
        client_id: str,
        redirect_uri: str,
        client_secret: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Exchange an authorization code and code_verifier for access tokens.

        This method completes the PKCE flow by exchanging the authorization code
        (received after user consent) and the code_verifier (from initiate_pkce_flow)
        for access and refresh tokens.

        The authorization server will verify that SHA256(code_verifier) matches
        the code_challenge from the authorization request before issuing tokens.

        :param code: Authorization code from the redirect callback (required)
        :param code_verifier: Code verifier from initiate_pkce_flow() (required)
        :param client_id: Application client ID (required)
        :param redirect_uri: Must exactly match the redirect_uri from authorization request (required)
        :param client_secret: Application client secret (optional, for confidential clients)
        :return: Dictionary with access_token, refresh_token, expires_in, token_type, etc.
        :rtype: Dict[str, Any]
        :raises GAMMissingParameterError: If required parameters are missing
        :raises GAMAuthenticationError: If code or code_verifier is invalid
        :raises GAMInvalidResponseError: If response cannot be parsed

        Example:
            >>> client = GAMClient(base_url="https://gam.example.com")
            >>> code = request.args.get('code')
            >>> code_verifier = session['code_verifier']
            >>> tokens = client.exchange_code_for_token(
            ...     code=code,
            ...     code_verifier=code_verifier,
            ...     client_id="my_app",
            ...     redirect_uri="https://myapp.com/callback"
            ... )
            >>> access_token = tokens['access_token']
            >>> refresh_token = tokens.get('refresh_token')

        Common Errors:
            - invalid_grant: Code expired, already used, or code_verifier mismatch
            - invalid_client: Client ID is incorrect
            - invalid_request: redirect_uri mismatch or missing parameters

        Security Notes:
            - Authorization codes are short-lived (typically 10 minutes)
            - Codes can only be used once
            - redirect_uri must exactly match the authorization request
            - Store tokens securely after receiving them

        See Also:
            - initiate_pkce_flow(): Start the PKCE flow
            - RFC 7636: https://datatracker.ietf.org/doc/html/rfc7636
        """
        if not all([code, code_verifier, client_id, redirect_uri]):
            raise GAMMissingParameterError("code, code_verifier, client_id, and redirect_uri are required")

        data = {
            "grant_type": "authorization_code",
            "code": code,
            "code_verifier": code_verifier,
            "client_id": client_id,
            "redirect_uri": redirect_uri,
        }

        if client_secret is not None:
            data["client_secret"] = client_secret

        headers = {"Content-Type": "application/x-www-form-urlencoded"}

        response = self._make_request(
            method="POST",
            endpoint=GAM_OAUTH_AUTHCODE_GRANT_ENDPOINT,
            data=data,
            headers=headers,
            form=True,
        )

        try:
            result = response.json()
            logger.debug("Successfully exchanged authorization code for tokens")
            return result
        except Exception as e:
            error_msg = f"Unable to parse token exchange response: {response.text}"
            logger.error(f"{error_msg}: {e}")
            raise GAMInvalidResponseError(error_msg, response=response) from e
    
    def get_authorization_code_via_browser(
        self,
        client_id: str,
        redirect_uri: str,
        state: str,
        scope: str = "gam_user_data",
        callback_port: int = 8080,
        callback_timeout: int = 300,
        callback_success_html: Optional[str] = None,
        callback_error_html: Optional[str] = None,
        open_browser: bool = True,
        browser_opener: Optional[Callable[[str], None]] = None,
    ) -> str:
        """
        Get authorization code via browser-based OAuth flow.
        
        This high-level method orchestrates the browser-based authorization code flow:
        1. Initiates PKCE flow (generates auth URL and code verifier)
        2. Starts local HTTP callback server
        3. Opens browser to authorization URL (optional)
        4. Waits for OAuth callback with authorization code
        5. Validates state and returns authorization code
        
        The code_verifier is NOT returned by this method - it's managed internally
        and should be used with complete_pkce_flow_via_browser() instead, or use
        initiate_pkce_flow() + manual callback handling for more control.
        
        :param client_id: Application client ID (required)
        :param redirect_uri: Callback URL configured in the application (required)
        :param state: Random string to prevent CSRF attacks (required)
        :param scope: OAuth scope (default: "gam_user_data")
        :param callback_port: Port for local callback server (default: 8080)
        :param callback_timeout: Timeout for callback in seconds (default: 300)
        :param callback_success_html: Custom HTML for success page
        :param callback_error_html: Custom HTML for error page
        :param open_browser: Whether to automatically open browser (default: True)
        :param browser_opener: Custom function to open browser, takes URL as parameter
        :return: Tuple of (authorization_code, code_verifier)
        :rtype: Tuple[str, str]
        :raises GAMMissingParameterError: If required parameters are missing
        :raises GAMAuthenticationError: If state mismatch or OAuth error occurs
        :raises GAMAPIError: If callback timeout or server error occurs
        
        Example:
            >>> client = GAMClient(base_url="https://gam.example.com")
            >>> auth_code, verifier = client.get_authorization_code_via_browser(
            ...     client_id="my_app",
            ...     redirect_uri="http://localhost:8080/callback",
            ...     state="random_state",
            ...     callback_port=8080
            ... )
            >>> # Use auth_code and verifier for token exchange
        
        Example (custom browser opener):
            >>> def custom_opener(url):
            ...     print(f"Please visit: {url}")
            >>> 
            >>> code, verifier = client.get_authorization_code_via_browser(
            ...     client_id="my_app",
            ...     redirect_uri="http://localhost:8080/callback",
            ...     state="random_state",
            ...     open_browser=False,
            ...     browser_opener=custom_opener
            ... )
        """
        auth_url, code_verifier = self.initiate_pkce_flow(
            client_id=client_id,
            redirect_uri=redirect_uri,
            state=state,
            scope=scope
        )
        
        callback_server = OAuthCallbackServer(
            port=callback_port,
            timeout=callback_timeout,
            success_html=callback_success_html,
            error_html=callback_error_html,
            host='localhost'
        )
        
        try:
            callback_server.start()
            logger.debug(f"Callback server started on port {callback_port}")
            
            if browser_opener:
                browser_opener(auth_url)
            elif open_browser:
                logger.debug("Opening browser for authentication")
                webbrowser.open(auth_url)
            
            result = callback_server.wait_for_callback(expected_state=state)
            logger.debug("Authorization code received successfully")
            
            return result['code'], code_verifier
            
        finally:
            callback_server.stop()
    
    def complete_pkce_flow_via_browser(
        self,
        client_id: str,
        redirect_uri: str,
        state: str,
        scope: str = "gam_user_data",
        callback_port: Optional[int] = None,
        callback_timeout: int = 300,
        callback_success_html: Optional[str] = None,
        callback_error_html: Optional[str] = None,
        open_browser: bool = True,
        browser_opener: Optional[Callable[[str], None]] = None,
        client_secret: Optional[str] = None,
        fetch_user_info: bool = True,
        return_type: str = "dict",
    ) -> Union[GAMTokenResponse, Dict[str, Any]]:
        """
        Complete end-to-end PKCE flow via browser and return tokens.
        
        This is the highest-level convenience method that combines:
        1. Getting authorization code via browser (see get_authorization_code_via_browser)
        2. Exchanging code for tokens
        3. Optionally fetching user info
        
        This method handles all steps of the browser-based PKCE flow automatically.
        Enhanced to automatically resolve XXXX placeholder in redirect_uri.
        
        :param client_id: Application client ID (required)
        :param redirect_uri: Callback URL. May contain 'XXXX' for dynamic port (required)
        :param state: Random string to prevent CSRF attacks (required)
        :param scope: OAuth scope (default: "gam_user_data")
        :param callback_port: Port for callback server. Auto-resolved if redirect_uri
                             contains 'XXXX' and this is None (optional)
        :param callback_timeout: Timeout for callback in seconds (default: 300)
        :param callback_success_html: Custom HTML for success page
        :param callback_error_html: Custom HTML for error page
        :param open_browser: Whether to automatically open browser (default: True)
        :param browser_opener: Custom function to open browser, takes URL as parameter
        :param client_secret: Application client secret (optional)
        :param fetch_user_info: Whether to fetch user info and include in response (default: True)
        :param return_type: Response format: "dict" (backward compatible), "typed" (GAMTokenResponse)
        :return: GAMTokenResponse or Dict based on return_type
        :rtype: Union[GAMTokenResponse, Dict[str, Any]]
        :raises GAMMissingParameterError: If required parameters are missing
        :raises GAMAuthenticationError: If authentication fails
        :raises GAMAPIError: If API request fails
        
        Example (with XXXX auto-resolve):
            >>> import secrets
            >>> client = GAMClient(base_url="https://gam.example.com")
            >>> state = secrets.token_urlsafe(32)
            >>> tokens = client.complete_pkce_flow_via_browser(
            ...     client_id="my_app",
            ...     redirect_uri="http://localhost:XXXX/callback",  # Auto-resolved!
            ...     state=state,
            ...     return_type="typed"
            ... )
            >>> print(f"Access token: {tokens.access_token}")
            >>> print(f"User: {tokens.user_info.username}")
        
        Example (fixed port):
            >>> tokens = client.complete_pkce_flow_via_browser(
            ...     client_id="my_app",
            ...     redirect_uri="http://localhost:8080/callback",
            ...     state=state,
            ...     callback_port=8080
            ... )
            >>> print(f"Access token: {tokens['access_token']}")
        
        See Also:
            - get_authorization_code_via_browser(): For manual token exchange
            - initiate_pkce_flow(): For completely manual flow
        """
        # Auto-resolve XXXX if present and callback_port not specified
        if DYNAMIC_PORT_PLACEHOLDER in redirect_uri:
            resolved_uri, resolved_port = resolve_redirect_uri(redirect_uri)
            if callback_port is None:
                callback_port = resolved_port
            redirect_uri = resolved_uri
            logger.debug(f"Auto-resolved redirect_uri to port {resolved_port}")
        elif callback_port is None:
            # Extract port from fixed redirect_uri
            callback_port = extract_port_from_uri(redirect_uri)
            logger.debug(f"Using fixed port {callback_port} from redirect_uri")
        
        auth_code, code_verifier = self.get_authorization_code_via_browser(
            client_id=client_id,
            redirect_uri=redirect_uri,
            state=state,
            scope=scope,
            callback_port=callback_port,
            callback_timeout=callback_timeout,
            callback_success_html=callback_success_html,
            callback_error_html=callback_error_html,
            open_browser=open_browser,
            browser_opener=browser_opener
        )
        
        token_response = self.exchange_code_for_token(
            code=auth_code,
            code_verifier=code_verifier,
            client_id=client_id,
            redirect_uri=redirect_uri,
            client_secret=client_secret
        )
        
        user_info_data = None
        if fetch_user_info and 'access_token' in token_response:
            user_info_data = self.get_user_info(
                access_token=token_response['access_token']
            )
            token_response['user_info'] = user_info_data
        
        # Return based on return_type
        if return_type == "dict":
            return token_response
        else:
            return GAMTokenResponse.from_dict(token_response, user_info=user_info_data)
