"""
Function registry and dynamic dispatch for akshare functions.

Provides discovery, introspection, and invocation of all akshare public functions.
"""

import importlib
import inspect
import re
import sys
import time
from datetime import date as _date
from functools import lru_cache
from typing import Any, Callable, Dict, List, Optional, Tuple

from akshare_cli.core.cache import _result_cache, get_ttl
from akshare_cli.core.fallback import (
    FALLBACK_MAP,
    PARAM_ADAPTERS,
    warn_if_slow,
)

# Matches YYYYMMDD date strings
_DATE_DEFAULT_RE = re.compile(r"^\d{8}$")

# Retry configuration (can be overridden via CLI --retry)
_retry_config = {"max_retries": 0, "base_delay": 1.0}

# Exceptions that are worth retrying (network / transient errors)
_RETRYABLE_ERRORS = (
    ConnectionError,
    TimeoutError,
    OSError,
)


def set_retry(max_retries: int, base_delay: float = 1.0) -> None:
    """Set the global retry policy for API calls."""
    _retry_config["max_retries"] = max(0, max_retries)
    _retry_config["base_delay"] = base_delay

# Functions permanently disabled (truly removed from akshare, not just temporarily broken)
DISABLED_FUNCTIONS: set = {
    "index_news_sentiment_scope",
}


@lru_cache(maxsize=1)
def _get_akshare_module():
    """Lazily import the real akshare library (not the local namespace package).

    Uses importlib to ensure we get the pip-installed akshare,
    even when running inside the akshare_cli namespace.
    """
    ak = importlib.import_module("akshare")
    # Verify we got the real library, not the local namespace sub-package
    if not hasattr(ak, "__version__"):
        raise ImportError(
            "Imported 'akshare' does not appear to be the real akshare library. "
            "Ensure akshare is installed: pip install akshare"
        )
    return ak


@lru_cache(maxsize=1)
def get_all_functions() -> Dict[str, Callable]:
    """Return a dict of all public akshare functions keyed by name."""
    ak = _get_akshare_module()
    funcs = {}
    for name in dir(ak):
        if name.startswith("_"):
            continue
        if name in DISABLED_FUNCTIONS:
            continue
        obj = getattr(ak, name)
        if callable(obj) and not isinstance(obj, type):
            funcs[name] = obj
    return funcs


def get_function(name: str) -> Optional[Callable]:
    """Get a single akshare function by exact name."""
    return get_all_functions().get(name)


def search_functions(keyword: str) -> List[str]:
    """Search function names containing the keyword (case-insensitive)."""
    keyword_lower = keyword.lower()
    return sorted(
        name for name in get_all_functions()
        if keyword_lower in name.lower()
    )


def list_functions_by_domain(domain: str) -> List[str]:
    """List functions whose name starts with a given domain prefix."""
    prefix = domain.lower() + "_"
    return sorted(
        name for name in get_all_functions()
        if name.lower().startswith(prefix)
    )


def get_function_info(name: str) -> Optional[Dict[str, Any]]:
    """Get detailed info about a function: signature, parameters, docstring."""
    func = get_function(name)
    if func is None:
        return None

    sig = inspect.signature(func)
    params = []
    for pname, param in sig.parameters.items():
        p_info = {
            "name": pname,
            "kind": str(param.kind),
            "has_default": param.default is not inspect.Parameter.empty,
        }
        if p_info["has_default"]:
            p_info["default"] = param.default
        if param.annotation is not inspect.Parameter.empty:
            p_info["type"] = str(param.annotation)
        params.append(p_info)

    doc = inspect.getdoc(func) or ""
    return {
        "name": name,
        "params": params,
        "docstring": doc,
        "module": getattr(func, "__module__", "unknown"),
    }


def parse_param_value(value_str: str, param_info: Dict) -> Any:
    """Parse a string parameter value into the appropriate Python type.

    Respects the function's type annotation: if a parameter is typed as str,
    the value is kept as-is (preserving leading zeros like "000001").
    """
    # If the parameter is annotated as str, keep it as a string
    param_type = param_info.get("type", "")
    if "str" in param_type:
        return value_str

    if value_str.lower() in ("none", "null"):
        return None
    if value_str.lower() in ("true", "yes"):
        return True
    if value_str.lower() in ("false", "no"):
        return False

    # Try int (but not if it has leading zeros — likely a code like "000001")
    if not (len(value_str) > 1 and value_str.startswith("0") and value_str != "0"):
        try:
            return int(value_str)
        except ValueError:
            pass

    # Try float
    try:
        return float(value_str)
    except ValueError:
        pass

    return value_str


def _invoke_function(func_name: str, kwargs: Dict[str, str]) -> Any:
    """Resolve, convert params, and invoke a single akshare function.

    Retries on network-level errors (ConnectionError, TimeoutError, OSError)
    using exponential backoff.  Retry count is controlled by set_retry() /
    the CLI --retry flag.

    Returns the result on success.
    Raises ValueError if the function is not found in akshare.
    Raises any exception from the underlying akshare function.
    """
    func = get_function(func_name)
    if func is None:
        raise ValueError(f"Function '{func_name}' not found in akshare")

    info = get_function_info(func_name)
    param_map = {p["name"]: p for p in info["params"]}

    converted_kwargs = {}
    for key, val in kwargs.items():
        if key in param_map:
            converted_kwargs[key] = (
                parse_param_value(val, param_map[key]) if isinstance(val, str) else val
            )
        else:
            converted_kwargs[key] = val

    _auto_fill_date_params(info["params"], converted_kwargs)

    # Check cache
    cached = _result_cache.get(func_name, converted_kwargs)
    if cached is not None:
        print(f"(cached) {func_name}", file=sys.stderr)
        return cached

    warn_if_slow(func_name)

    max_retries = _retry_config["max_retries"]
    base_delay = _retry_config["base_delay"]
    last_error = None

    for attempt in range(max_retries + 1):
        try:
            result = func(**converted_kwargs)

            ttl = get_ttl(func_name)
            if ttl > 0:
                _result_cache.put(func_name, converted_kwargs, result, ttl)

            return result
        except _RETRYABLE_ERRORS as e:
            last_error = e
            if attempt < max_retries:
                delay = base_delay * (2 ** attempt)
                print(
                    f"Retry {attempt + 1}/{max_retries}: {func_name} failed "
                    f"({type(e).__name__}), retrying in {delay:.1f}s...",
                    file=sys.stderr,
                )
                time.sleep(delay)
            else:
                raise
        except Exception:
            # Non-retryable error — raise immediately
            raise

    raise last_error  # should not reach here, but just in case


def call_function(name: str, kwargs: Dict[str, str]) -> Any:
    """
    Call an akshare function by name with string kwargs.

    Strategy: try-first, fallback-on-error.
    1. Apply PARAM_ADAPTERS if the function has a known signature change.
    2. Try calling the primary function.
    3. On runtime error (ConnectionError, TypeError, ValueError, etc.),
       walk the FALLBACK_MAP and try each alternative.
    4. If all alternatives also fail, raise the original error.

    This means: if upstream recovers, the primary function is used automatically.
    """
    # 1. Apply param adapter (for functions with changed signatures)
    adapted_kwargs = kwargs
    if name in PARAM_ADAPTERS:
        adapted_kwargs = PARAM_ADAPTERS[name](dict(kwargs))

    # 2. Try primary function first
    try:
        return _invoke_function(name, adapted_kwargs)
    except ValueError:
        # Function not found at all — go straight to fallback
        primary_error = None
    except Exception as e:
        # Runtime error — save it, try fallback
        primary_error = e

    # 3. Walk fallback chain
    if name in FALLBACK_MAP:
        for alt_name, kwarg_transformer in FALLBACK_MAP[name]:
            alt_kwargs = kwarg_transformer(dict(kwargs))
            try:
                result = _invoke_function(alt_name, alt_kwargs)
                print(
                    f"Note: {name} failed, using {alt_name} instead",
                    file=sys.stderr,
                )
                return result
            except Exception:
                continue  # this alternative also failed, try next

    # 4. All alternatives exhausted
    if primary_error is not None:
        raise primary_error
    raise ValueError(f"Function '{name}' not found in akshare")


def _auto_fill_date_params(params: List[Dict], kwargs: Dict[str, Any]) -> None:
    """Auto-fill date-like parameters with today's date when not provided by user.

    A parameter is considered date-like if:
    1. Its name contains 'date' (case-insensitive)
    2. It has a default value matching YYYYMMDD (8-digit string)
    3. The user did not explicitly provide it
    """
    today_str = _date.today().strftime("%Y%m%d")

    for pinfo in params:
        pname = pinfo["name"]

        # Skip if user explicitly provided this param
        if pname in kwargs:
            continue

        # Must have a default value
        if not pinfo.get("has_default"):
            continue
        default = pinfo.get("default")
        if default is None or not isinstance(default, str):
            continue

        # Parameter name must contain 'date'
        if "date" not in pname.lower():
            continue

        # Default must look like YYYYMMDD
        if not _DATE_DEFAULT_RE.match(default):
            continue

        # Auto-fill with today's date
        kwargs[pname] = today_str
        print(
            f"Note: --{pname} not specified, using today: {today_str}",
            file=sys.stderr,
        )


def get_domains() -> List[str]:
    """Get all unique domain prefixes from function names."""
    domains = set()
    for name in get_all_functions():
        parts = name.split("_")
        if parts:
            domains.add(parts[0])
    return sorted(domains)
