"""mldash — Export trained ML models to ML-Dash."""

import getpass
import os
import shutil
import tempfile

from ._api import _download, fetch_datasets, fetch_models, fetch_projects, resolve_project_by_name
from ._bundle import build_metadata, create_bundle
from ._credentials import clear as _clear_credentials
from ._credentials import get as _get_credential
from ._credentials import load as _load_credentials
from ._credentials import store as _store_credentials
from ._detect import detect_all
from ._errors import MLDashAuthError, MLDashError, MLDashUploadError, MLDashValidationError
from ._serialize import serialize_artifacts
from ._upload import upload_bundle
from ._validate import validate_all, validate_api_key, validate_url
from ._version import __version__

__all__ = [
    'login', 'logout', 'init', 'export', 'save', 'push', 'whoami',
    'list_projects', 'list_models', 'list_datasets', 'read_dataset',
    'MLDashError', 'MLDashValidationError', 'MLDashAuthError', 'MLDashUploadError',
    '__version__',
]

# Session-level state set via init()
_session = {
    'url': None,
    'api_key': None,
    'project_id': None,
}


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def login(url=None, api_key=None):
    """Authenticate with ML-Dash and store credentials locally.

    Run this once per machine. Credentials are saved to
    ~/.config/mldash/credentials so future export() calls don't need
    url or api_key.

    If url or api_key are not provided, prompts interactively.

    >>> import mldash
    >>> mldash.login()  # prompts for URL and API key
    >>> mldash.export(model=model, name="My Model")
    """
    # Resolve URL
    if not url:
        url = os.environ.get('MLDASH_URL') or _colab_secret('MLDASH_URL')
    if not url:
        existing_url = _get_credential('url')
        default_hint = f' [{existing_url}]' if existing_url else ''
        try:
            url = input(f'ML-Dash URL{default_hint}: ').strip()
        except (EOFError, KeyboardInterrupt):
            print()
            return
        if not url and existing_url:
            url = existing_url
    if not url:
        raise MLDashValidationError("URL is required.")
    validate_url(url)

    # Resolve API key
    if not api_key:
        api_key = os.environ.get('MLDASH_API_KEY') or _colab_secret('MLDASH_API_KEY')
    if not api_key:
        print(f'Get your API key from: {url.rstrip("/")}/developers/keys')
        try:
            api_key = getpass.getpass('API Key: ')
        except (EOFError, KeyboardInterrupt):
            print()
            return
    if not api_key:
        raise MLDashValidationError("API key is required.")
    validate_api_key(api_key)

    # Verify credentials against the server
    _verify_credentials(url, api_key)

    # Store to disk
    _store_credentials(url=url, api_key=api_key)

    # Also set session state so current notebook session works immediately
    _session['url'] = url
    _session['api_key'] = api_key

    print(f'Authenticated with {url}')
    print(f'Credentials saved to ~/.config/mldash/credentials')


def logout():
    """Remove stored credentials."""
    _clear_credentials()
    _session['url'] = None
    _session['api_key'] = None
    _session['project_id'] = None
    print('Logged out. Credentials removed.')


def init(project=None, url=None):
    """Set the active project for this session.

    Call this at the top of a notebook to associate all subsequent
    export() calls with a specific ML-Dash project.

    Accepts either a project name (string) or project ID (int):
        mldash.init(project="Churn Analysis")  # looks up by name
        mldash.init(project=5)                 # uses ID directly

    If no project is set, exports go to an auto-created
    "Imported Models" project.

    >>> mldash.init(project="Churn Analysis")
    >>> mldash.export(model=model, name="RF v2")  # goes to that project
    """
    if url is not None:
        _session['url'] = url

    if project is not None:
        project_id = _resolve_project_arg(project)
        _session['project_id'] = project_id
        _store_credentials(project_id=project_id)
        # Show the resolved project name
        if isinstance(project, str):
            print(f'Project set to: {project} (id={project_id})')
        else:
            print(f'Project set to id={project_id}')


def whoami():
    """Show current authentication status.

    Returns a dict with url, api_key_prefix, and project_id, or None
    if not authenticated.
    """
    creds = _load_credentials()
    url = _session.get('url') or creds.get('url')
    api_key = _session.get('api_key') or creds.get('api_key')
    project_id = _session.get('project_id') or creds.get('project_id')

    if not url and not api_key:
        print('Not logged in. Run mldash.login() to authenticate.')
        return None

    prefix = api_key[:10] + '...' if api_key and len(api_key) > 10 else api_key

    # Try to resolve project name if we have credentials
    project_name = None
    if project_id and url and api_key:
        try:
            projects = fetch_projects(url, api_key)
            for p in projects:
                if p['id'] == project_id:
                    project_name = p['name']
                    break
        except Exception:
            pass

    info = {
        'url': url,
        'api_key_prefix': prefix,
        'project_id': project_id,
        'project_name': project_name,
    }
    print(f'URL:     {url or "(not set)"}')
    print(f'API Key: {prefix or "(not set)"}')
    project_display = f'{project_name} (id={project_id})' if project_name else str(project_id) if project_id else '(default — Imported Models)'
    print(f'Project: {project_display}')
    return info


def list_projects():
    """List all projects on ML-Dash.

    Returns a list of project dicts with keys: id, name, description,
    model_count, dataset_count.

    >>> mldash.list_projects()
    [{'id': 1, 'name': 'Churn Analysis', ...}, ...]
    """
    url = _resolve_url(None)
    api_key = _resolve_api_key(None)
    projects = fetch_projects(url, api_key)

    if not projects:
        print('No projects found.')
    else:
        print(f'{"ID":<6} {"Name":<30} {"Models":<8} {"Datasets":<8}')
        print('-' * 52)
        for p in projects:
            print(f'{p["id"]:<6} {p["name"]:<30} {p.get("model_count", 0):<8} {p.get("dataset_count", 0):<8}')

    return projects


def list_models(project=None):
    """List models on ML-Dash.

    Args:
        project: Optional project name (str) or ID (int) to filter by.

    Returns a list of model dicts.

    >>> mldash.list_models()
    >>> mldash.list_models(project="Churn Analysis")
    """
    url = _resolve_url(None)
    api_key = _resolve_api_key(None)

    project_id = None
    if project is not None:
        project_id = _resolve_project_arg(project)

    models = fetch_models(url, api_key, project_id=project_id)

    if not models:
        print('No models found.')
    else:
        print(f'{"ID":<6} {"Name":<30} {"Type":<15} {"Status":<10}')
        print('-' * 61)
        for m in models:
            print(f'{m["id"]:<6} {m["name"]:<30} {m.get("model_type_display", ""):<15} {m.get("status", ""):<10}')

    return models


def list_datasets(project=None):
    """List datasets on ML-Dash.

    Args:
        project: Optional project name (str) or ID (int) to filter by.

    Returns a list of dataset dicts.

    >>> mldash.list_datasets()
    >>> mldash.list_datasets(project="Churn Analysis")
    """
    url = _resolve_url(None)
    api_key = _resolve_api_key(None)

    project_id = None
    if project is not None:
        project_id = _resolve_project_arg(project)

    datasets = fetch_datasets(url, api_key, project_id=project_id)

    if not datasets:
        print('No datasets found.')
    else:
        print(f'{"ID":<6} {"Name":<30} {"Rows":<8} {"Cols":<6} {"Type":<10} {"Visibility":<10}')
        print('-' * 70)
        for d in datasets:
            print(
                f'{d["id"]:<6} {d["name"]:<30} {d.get("rows", ""):<8} '
                f'{d.get("columns", ""):<6} {d.get("dataset_type", ""):<10} '
                f'{d.get("visibility", ""):<10}'
            )

    return datasets


def read_dataset(dataset, url=None, api_key=None):
    """Read a dataset from ML-Dash as a pandas DataFrame.

    Args:
        dataset: Dataset ID (int), slug (str), or "username/slug" (str)
            for public datasets.
        url: ML-Dash server URL. Resolved automatically from login().
        api_key: API key. Resolved automatically from login().

    Returns:
        pandas.DataFrame

    >>> df = mldash.read_dataset(1)
    >>> df = mldash.read_dataset("my-dataset")
    >>> df = mldash.read_dataset("alice/her-dataset")
    """
    try:
        import pandas as pd
    except ImportError:
        raise ImportError(
            "pandas is required for read_dataset(). Install: pip install pandas"
        )

    url = _resolve_url(url)
    api_key = _resolve_api_key(api_key)

    identifier = str(dataset)

    # Check if it's a "username/slug" pattern for public datasets
    if '/' in identifier and not identifier.isdigit():
        parts = identifier.split('/', 1)
        endpoint = f'/api/v1/datasets/public/{parts[0]}/{parts[1]}/'
    else:
        endpoint = f'/api/v1/datasets/{identifier}/'

    resp = _download(endpoint, url, api_key)

    import io
    df = pd.read_csv(io.StringIO(resp.text))
    print(f'Loaded dataset: {df.shape[0]} rows, {df.shape[1]} columns')
    return df


def export(
    model,
    name,
    url=None,
    api_key=None,
    *,
    scaler=None,
    encoder=None,
    label_map=None,
    description='',
    task_type=None,
    framework=None,
    features=None,
    target=None,
    project=None,
    metrics=None,
):
    """Bundle a trained model and upload it to ML-Dash.

    After calling login() and optionally init(), you typically only need:

        mldash.export(model=model, name="My Model")

    Args:
        model: A fitted model object with a predict() method.
        name: Display name for the model on ML-Dash.
        url: ML-Dash server URL. Resolved automatically from login().
        api_key: API key. Resolved automatically from login().
        scaler: Optional fitted scaler (must have transform()).
        encoder: Optional fitted encoder (must have transform()).
        label_map: Optional dict mapping label indices to names.
        description: Optional model description.
        task_type: 'classification' or 'regression'. Auto-detected if omitted.
        framework: ML framework name. Auto-detected if omitted.
        features: Dict of {column_name: type} where type is 'numeric',
            'categorical', or 'text'.
        target: Dict with a single {column_name: type} entry.
        project: Project name (str) or ID (int). Resolved from init() if
            not provided. If neither is set, uses "Imported Models".
        metrics: Optional dict of evaluation metrics. Supported keys:
            accuracy, precision, recall, f1_score, roc_auc (classification),
            mse, rmse, mae, r2_score (regression).

    Returns:
        dict with keys: model_id, model_name, model_slug, url, auto_detected.

    Raises:
        MLDashValidationError: Invalid inputs.
        MLDashAuthError: Authentication failure.
        MLDashUploadError: Server/network error.
    """
    # Validate inputs
    validate_all(
        model, name, scaler=scaler, encoder=encoder, label_map=label_map,
        task_type=task_type, framework=framework, features=features, target=target,
        metrics=metrics,
    )

    # Resolve url, api_key, project from the fallback chain
    url = _resolve_url(url)
    api_key = _resolve_api_key(api_key)
    validate_url(url)
    validate_api_key(api_key)

    # Resolve project: explicit arg > session > stored > None (server default)
    project_id = None
    if project is not None:
        project_id = _resolve_project_arg(project)
    else:
        project_id = _resolve_stored_project()

    # Auto-detect metadata
    detected = detect_all(model, task_type=task_type, framework=framework)

    # Build, bundle, upload
    tmpdir = tempfile.mkdtemp(prefix='mldash_')
    try:
        artifact_paths = serialize_artifacts(tmpdir, model, scaler, encoder, label_map)
        metadata = build_metadata(name, description, detected, features, target,
                                  scaler, encoder, label_map, metrics=metrics)
        zip_path = create_bundle(tmpdir, artifact_paths, metadata)

        response = upload_bundle(
            zip_path, url, api_key, name,
            description=description,
            task_type=detected.get('task_type', ''),
            framework=detected.get('framework', ''),
            project_id=project_id,
        )
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

    # Format return value
    model_data = response.get('model', {})
    result = {
        'model_id': model_data.get('id'),
        'model_name': model_data.get('name'),
        'model_slug': model_data.get('slug'),
        'url': url,
        'auto_detected': response.get('auto_detected', {}),
    }

    print(f'Exported "{name}" to {url} (id={result["model_id"]})')
    return result


def save(
    model,
    name,
    path='./model.zip',
    *,
    scaler=None,
    encoder=None,
    label_map=None,
    description='',
    task_type=None,
    framework=None,
    features=None,
    target=None,
    metrics=None,
):
    """Bundle a trained model to a local ZIP file (offline mode).

    Same as export() but writes to disk instead of uploading.

    Args:
        model: A fitted model object with a predict() method.
        name: Display name for the model.
        path: Output ZIP file path. Defaults to './model.zip'.
        (remaining args same as export)

    Returns:
        str: Path to the created ZIP file.

    Raises:
        MLDashValidationError: Invalid inputs.
    """
    validate_all(
        model, name, scaler=scaler, encoder=encoder, label_map=label_map,
        task_type=task_type, framework=framework, features=features, target=target,
        metrics=metrics,
    )

    detected = detect_all(model, task_type=task_type, framework=framework)

    tmpdir = tempfile.mkdtemp(prefix='mldash_')
    try:
        artifact_paths = serialize_artifacts(tmpdir, model, scaler, encoder, label_map)
        metadata = build_metadata(name, description, detected, features, target,
                                  scaler, encoder, label_map, metrics=metrics)
        zip_path = create_bundle(tmpdir, artifact_paths, metadata)

        # Copy to final destination
        dest = os.path.abspath(path)
        shutil.copy2(zip_path, dest)
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

    return dest


def push(model, name, **kwargs):
    """Push a trained model to ML-Dash. Alias for export()."""
    return export(model, name, **kwargs)


# ---------------------------------------------------------------------------
# Credential resolution (fallback chains)
# ---------------------------------------------------------------------------

def _resolve_url(explicit):
    """Resolve ML-Dash URL: explicit > session > stored > env > Colab."""
    if explicit:
        return explicit
    if _session['url']:
        return _session['url']
    stored = _get_credential('url')
    if stored:
        return stored
    env = os.environ.get('MLDASH_URL')
    if env:
        return env
    colab = _colab_secret('MLDASH_URL')
    if colab:
        return colab
    raise MLDashValidationError(
        "Not logged in. Run mldash.login() first, or pass url= explicitly."
    )


def _resolve_api_key(explicit):
    """Resolve API key: explicit > session > stored > env > Colab > prompt."""
    if explicit:
        return explicit
    if _session['api_key']:
        return _session['api_key']
    stored = _get_credential('api_key')
    if stored:
        return stored
    env = os.environ.get('MLDASH_API_KEY')
    if env:
        return env
    colab = _colab_secret('MLDASH_API_KEY')
    if colab:
        return colab
    raise MLDashValidationError(
        "Not logged in. Run mldash.login() first, or pass api_key= explicitly."
    )


def _resolve_stored_project():
    """Resolve project from session or stored credentials. Returns ID or None."""
    if _session['project_id']:
        return _session['project_id']
    return _get_credential('project_id')


def _resolve_project_arg(project):
    """Resolve a project argument that could be a name (str) or ID (int).

    Returns the numeric project ID.
    Raises MLDashValidationError if the project name is not found.
    """
    if isinstance(project, int):
        return project

    if isinstance(project, str):
        # Try to parse as int first (e.g. "5")
        try:
            return int(project)
        except ValueError:
            pass

        # Look up by name via the API
        url = _resolve_url(None)
        api_key = _resolve_api_key(None)
        project_id = resolve_project_by_name(url, api_key, project)
        if project_id is None:
            raise MLDashValidationError(
                f'Project "{project}" not found. '
                f'Run mldash.list_projects() to see available projects.'
            )
        return project_id

    raise MLDashValidationError(
        "project must be a project name (str) or project ID (int)."
    )


def _colab_secret(name):
    """Try to read a value from Google Colab Secrets. Returns None on failure."""
    try:
        from google.colab import userdata
        return userdata.get(name)
    except Exception:
        return None


def _verify_credentials(url, api_key):
    """Quick check that the URL and API key are valid.

    Makes a lightweight request to the server. Raises on auth failure.
    """
    import requests

    # Try the models list endpoint — a GET that requires auth
    endpoint = url.rstrip('/') + '/api/models/'
    headers = {'X-API-Key': api_key}
    try:
        resp = requests.get(endpoint, headers=headers, timeout=10)
    except requests.ConnectionError:
        raise MLDashUploadError(
            f"Could not connect to ML-Dash at {url}. Is the server running?"
        )
    except requests.RequestException as exc:
        raise MLDashUploadError(f"Connection failed: {exc}")

    if resp.status_code == 401:
        raise MLDashAuthError("Invalid API key.")
    if resp.status_code == 403:
        raise MLDashAuthError("API key is not authorized.")
    # Any 2xx or other status is fine — we just needed to verify auth
