"""Command-line interface for A-Alpha Bio Data Portal.

Access AlphaSeq data blocks directly from your terminal.

## Installation

```bash
pip install aablocks
```

After installation, the `aablocks` command will be available in your terminal.

## Quick Start

```bash
# Log in (opens browser)
$ aablocks login

# List available blocks
$ aablocks list

# Download a block
$ aablocks get ab1001 -o data.csv
```

## Common Examples

### Download and Analyze

```bash
# Download block to file
$ aablocks get ab1001 -o ab1001.csv

# Load in Python
$ python
>>> import pandas as pd
>>> df = pd.read_csv("ab1001.csv")
>>> df.head()
```

### Quick Preview

```bash
# Display as table
$ aablocks get ab1001 -f table

# Check column names
$ aablocks get ab1001 | head -1
```

### Verbose Mode

```bash
# Run with verbose output
$ aablocks -v list

# Enable verbose mode permanently
$ aablocks config cli_verbose true
```

### Scripting

```bash
# List block IDs only
$ aablocks list -f csv | tail -n +2 | cut -d, -f1

# Download all accessible blocks
$ for id in $(aablocks list -f csv | tail -n +2 | cut -d, -f1); do
    aablocks get $id -o "${id}.csv"
done
```
"""

from __future__ import annotations

import csv
import io
import json
import sys
import traceback

import click
import requests

from .auth import login, logout, set_client_type
from .client import (
    download_structure,
    download_structures,
    get_data,
    get_datacard,
    get_details,
    get_schema,
    list_blocks,
)
from .config import (
    get_api_format,
    get_api_verbose,
    get_auth_domain,
    get_banners_enabled,
    get_base_url,
    get_cli_format,
    get_cli_verbose,
    get_client_id,
    get_pool_id,
    get_progress,
    get_version_check_enabled,
    get_web_url,
    set_config,
)
from .logging import log_error
from .notices import show_startup_notices


def _format_error(e: Exception) -> str:
    """Format an exception into a user-friendly error message."""
    if isinstance(e, requests.exceptions.ConnectionError):
        return "Connection error: Unable to reach the API server. Check your network connection."
    if isinstance(e, requests.exceptions.Timeout):
        return "Request timed out. The server may be slow or unreachable."
    if isinstance(e, requests.exceptions.HTTPError):
        resp = e.response
        if resp is not None:
            if resp.status_code == 401:
                return "Authentication failed. Try running: aablocks login"
            if resp.status_code == 403:
                return (
                    "Access denied. You don't have permission to access this resource."
                )
            if resp.status_code == 404:
                return "Not found. The requested block or resource doesn't exist."
            if resp.status_code >= 500:
                return f"Server error ({resp.status_code}). Please try again later."
            return f"HTTP error {resp.status_code}: {resp.reason}"
    if isinstance(e, ValueError):
        return str(e)
    if isinstance(e, RuntimeError):
        return str(e)
    # Generic fallback
    return f"{type(e).__name__}: {e}"


def _handle_error(e: Exception) -> None:
    """Print a formatted error message, log full details, and exit."""
    # Log full traceback to file
    log_error(traceback.format_exc())

    # Show friendly message to user
    click.secho(f"Error: {_format_error(e)}", fg="red", err=True)
    sys.exit(1)


HELP_TEXT = """
A-Alpha Bio Data Portal CLI

Access AlphaSeq blocks from the command line.

\b
GETTING STARTED
  aablocks login              Open browser to log in
  aablocks list               Show available blocks
  aablocks get <id>           Download block data

\b
COMMANDS
  login       Log in to the Data Portal (opens browser)
  logout      Log out and clear cached credentials
  list        List all accessible blocks
  details     Show metadata for a block
  datacard    Show the datacard for a block
  get         Download CSV data for a block
  structures  Download structure files as a zip archive
  config      Get or set configuration options

\b
EXAMPLES
  aablocks list                         List blocks as a table
  aablocks list -f json                 List as JSON
  aablocks details ab1001               Show block metadata
  aablocks datacard ab1001              Show block datacard
  aablocks get ab1001                   Print CSV to stdout
  aablocks get ab1001 -o data.csv.gz    Download to file
  aablocks get ab1001 --mode ml         Get ML-ready variant
  aablocks get ab1001 --load-negatives  Include negative controls
  aablocks structures ab1614            Download structures zip
  aablocks --verbose list               Verbose output (show log messages)

\b
CONFIGURATION
  aablocks config                      Show current settings
  aablocks config base_url             Show API endpoint
  aablocks config auth_domain          Show Cognito auth domain
  aablocks config pool_id              Show Cognito user pool ID
  aablocks config client_id            Show Cognito app client ID
  aablocks config api_format pandas    Set Python API default format
  aablocks config cli_format table     Set CLI default output format
  aablocks config progress false       Disable progress bars
  aablocks config cli_verbose true     Enable CLI verbose logging
  aablocks config api_verbose true     Enable API verbose logging
  aablocks config banners false        Disable site-wide banner notices
  aablocks config version_check false  Disable out-of-date version warnings

\b
FILES
  ~/.aab/config.json                  User configuration
  ~/.aab/token.json                   Cached tokens (per environment)
  ~/.aab/logs/<domain>/activity.log   API activity logs

For more information, visit: https://atlas.aalphabio.com
"""


@click.group(help=HELP_TEXT)
@click.version_option("-v", "--version", package_name="aablocks")
@click.option(
    "--verbose",
    is_flag=True,
    default=None,
    help="Enable verbose output (echo log messages)",
)
@click.pass_context
def main(ctx: click.Context, verbose: bool | None) -> None:
    """A-Alpha Bio Data Portal CLI."""
    ctx.ensure_object(dict)

    # Set client type for API headers
    set_client_type("cli")

    # Use config value if --verbose not explicitly passed
    if verbose is None:
        verbose = get_cli_verbose()

    ctx.obj["verbose"] = verbose

    # Enable verbose if cli_verbose or api_verbose is set
    if verbose or get_api_verbose():
        from .logging import set_verbose

        set_verbose(True)

    # First use of the CLI: show banners and the out-of-date warning. Covers
    # commands that never hit the API (e.g. `config`); the client triggers the
    # same notices on its first API call. Both are once-per-process and
    # best-effort, so this never double-fetches and never raises.
    show_startup_notices()


@main.command("login")
@click.option(
    "--no-callback",
    is_flag=True,
    help="Manual login for when the client cannot open the callback port",
)
def login_cmd(no_callback: bool) -> None:
    """Log in to the Data Portal."""
    try:
        login(no_callback=no_callback)
    except Exception as e:
        _handle_error(e)


@main.command("logout")
def logout_cmd() -> None:
    """Log out and clear cached credentials."""
    logout()


@main.command("config")
@click.argument("key", required=False)
@click.argument("value", required=False)
def config_cmd(key: str | None, value: str | None) -> None:
    """Get or set configuration options.

    \b
    Examples:
        aablocks config                     # Show current config
        aablocks config default_format      # Show default_format value
        aablocks config default_format csv  # Set default_format to csv

    \b
    Options:
        base_url: API endpoint URL (default: https://api.atlas.aalphabio.com)
        web_url: Data Portal web app URL (default: https://atlas.aalphabio.com)
        auth_domain: Cognito auth domain (default: auth.atlas.aalphabio.com)
        pool_id: Cognito user pool ID
        client_id: Cognito app client ID
        api_format: csv, pandas, or polars (default format for Python API)
        cli_format: csv or table (default format for CLI output)
        progress: true or false
        cli_verbose: true or false (echo CLI activity to console)
        api_verbose: true or false (echo API calls to console)
        banners: true or false (show site-wide banner notices)
        version_check: true or false (warn when a newer release is on PyPI)
    """
    if key is None:
        # Show all config
        click.echo(f"base_url: {get_base_url()}")
        click.echo(f"web_url: {get_web_url()}")
        click.echo(f"auth_domain: {get_auth_domain()}")
        click.echo(f"pool_id: {get_pool_id()}")
        click.echo(f"client_id: {get_client_id()}")
        click.echo(f"api_format: {get_api_format()}")
        click.echo(f"cli_format: {get_cli_format()}")
        click.echo(f"progress: {str(get_progress()).lower()}")
        click.echo(f"cli_verbose: {str(get_cli_verbose()).lower()}")
        click.echo(f"api_verbose: {str(get_api_verbose()).lower()}")
        click.echo(f"banners: {str(get_banners_enabled()).lower()}")
        click.echo(f"version_check: {str(get_version_check_enabled()).lower()}")
    elif value is None:
        # Show specific key
        if key == "base_url":
            click.echo(get_base_url())
        elif key == "web_url":
            click.echo(get_web_url())
        elif key == "auth_domain":
            click.echo(get_auth_domain())
        elif key == "pool_id":
            click.echo(get_pool_id())
        elif key == "client_id":
            click.echo(get_client_id())
        elif key == "api_format":
            click.echo(get_api_format())
        elif key == "cli_format":
            click.echo(get_cli_format())
        elif key == "progress":
            click.echo(str(get_progress()).lower())
        elif key == "cli_verbose":
            click.echo(str(get_cli_verbose()).lower())
        elif key == "api_verbose":
            click.echo(str(get_api_verbose()).lower())
        elif key == "banners":
            click.echo(str(get_banners_enabled()).lower())
        elif key == "version_check":
            click.echo(str(get_version_check_enabled()).lower())
        else:
            click.echo(f"Unknown config key: {key}")
    else:
        # Set value
        if key == "base_url":
            if not value.startswith("https://"):
                click.echo(f"Invalid value for base_url: {value}")
                click.echo("Must be a valid HTTPS URL")
                return
            set_config(key, value)
            click.echo(f"Set {key} to {value}")
        elif key == "web_url":
            # Allows http:// so a local dev server (e.g. http://localhost:5173)
            # can be used as the manual-login redirect target.
            if not value.startswith(("https://", "http://")):
                click.echo(f"Invalid value for web_url: {value}")
                click.echo("Must be a valid HTTP(S) URL")
                return
            set_config(key, value)
            click.echo(f"Set {key} to {value}")
        elif key in ("auth_domain", "pool_id", "client_id"):
            set_config(key, value)
            click.echo(f"Set {key} to {value}")
        elif key == "api_format":
            if value not in ("csv", "pandas", "polars"):
                click.echo(f"Invalid value for api_format: {value}")
                click.echo("Must be one of: csv, pandas, polars")
                return
            # Check if the required library is installed
            if value == "pandas":
                try:
                    import pandas  # noqa: F401
                except ImportError:
                    click.secho("Error: pandas is not installed.", fg="red", err=True)
                    click.echo("You can install it with: pip install pandas")
                    return
            elif value == "polars":
                try:
                    import polars  # noqa: F401
                except ImportError:
                    click.secho("Error: polars is not installed.", fg="red", err=True)
                    click.echo("You can install it with: pip install polars")
                    return
            set_config(key, value)
            click.echo(f"Set {key} to {value}")
        elif key == "cli_format":
            if value not in ("csv", "table"):
                click.echo(f"Invalid value for cli_format: {value}")
                click.echo("Must be one of: csv, table")
                return
            set_config(key, value)
            click.echo(f"Set {key} to {value}")
        elif key == "progress":
            if value.lower() not in ("true", "false"):
                click.echo(f"Invalid value for progress: {value}")
                click.echo("Must be one of: true, false")
                return
            set_config(key, value.lower() == "true")
            click.echo(f"Set {key} to {value.lower()}")
        elif key == "cli_verbose":
            if value.lower() not in ("true", "false"):
                click.echo(f"Invalid value for cli_verbose: {value}")
                click.echo("Must be one of: true, false")
                return
            set_config(key, value.lower() == "true")
            click.echo(f"Set {key} to {value.lower()}")
        elif key == "api_verbose":
            if value.lower() not in ("true", "false"):
                click.echo(f"Invalid value for api_verbose: {value}")
                click.echo("Must be one of: true, false")
                return
            set_config(key, value.lower() == "true")
            click.echo(f"Set {key} to {value.lower()}")
        elif key == "banners":
            if value.lower() not in ("true", "false"):
                click.echo(f"Invalid value for banners: {value}")
                click.echo("Must be one of: true, false")
                return
            set_config(key, value.lower() == "true")
            click.echo(f"Set {key} to {value.lower()}")
        elif key == "version_check":
            if value.lower() not in ("true", "false"):
                click.echo(f"Invalid value for version_check: {value}")
                click.echo("Must be one of: true, false")
                return
            set_config(key, value.lower() == "true")
            click.echo(f"Set {key} to {value.lower()}")
        else:
            click.echo(f"Unknown config key: {key}")


@main.command("list")
@click.option("--all-versions", is_flag=True, help="Include all versions of each block")
@click.option(
    "--format",
    "-f",
    "fmt",
    type=click.Choice(["table", "csv", "json"]),
    default="table",
    help="Output format: table, csv, or json (default: table)",
)
def list_cmd(all_versions: bool, fmt: str) -> None:
    """List all accessible blocks."""
    try:
        blocks = list_blocks(all_versions=all_versions, format="list")
    except Exception as e:
        _handle_error(e)

    if fmt == "json":
        output = [
            {
                "id": d.id,
                "name": d.name,
                "version": d.version,
                "modes": d.modes,
                "consortia": (
                    d.product_display_name if d.product_kind == "consortium" else None
                ),
                "release": d.source,
                "locked": d.locked,
                "url": d.url,
                "structure_count": d.structure_count,
                "a_size": d.a_size,
                "alpha_size": d.alpha_size,
                "total_ppi_count": d.total_ppi_count,
                "unique_ppi_count": d.unique_ppi_count,
            }
            for d in blocks
        ]
        click.echo(json.dumps(output, indent=2))
    elif fmt == "csv":
        output = io.StringIO()
        writer = csv.writer(output)
        writer.writerow(
            [
                "id",
                "name",
                "version",
                "modes",
                "consortia",
                "release",
                "locked",
                "url",
                "structure_count",
                "a_size",
                "alpha_size",
                "total_ppi_count",
                "unique_ppi_count",
            ]
        )
        for d in blocks:
            writer.writerow(
                [
                    d.id,
                    d.name,
                    d.version,
                    ";".join(d.modes),
                    d.product_display_name if d.product_kind == "consortium" else "",
                    d.source or "",
                    d.locked,
                    d.url,
                    d.structure_count,
                    d.a_size,
                    d.alpha_size,
                    d.total_ppi_count,
                    d.unique_ppi_count,
                ]
            )
        click.echo(output.getvalue(), nl=False)
    else:  # table
        if not blocks:
            click.echo("No blocks found.")
            return

        # Size the Name column to the longest name so all names fit
        # without truncation.
        name_width = max(len(d.name) for d in blocks)
        header = f"{'ID':<12} {'Name':<{name_width}} {'Version':<8} {'Consortia':<22.22} {'Release':<18.18} {'Structures':<12} {'A Size':<10} {'Alpha Size':<10} {'Total PPI':<12} {'Unique PPI':<12} {'Modes':<20.20}"
        click.echo(header)
        click.echo("-" * len(header))
        for d in blocks:
            modes = ", ".join(d.modes)
            consortia = (
                d.product_display_name if d.product_kind == "consortium" else ""
            ) or ""
            release = d.source or ""
            structures = str(d.structure_count) if d.structure_count is not None else ""
            a_size = str(d.a_size) if d.a_size is not None else ""
            alpha_size = str(d.alpha_size) if d.alpha_size is not None else ""
            total_ppi = str(d.total_ppi_count) if d.total_ppi_count is not None else ""
            unique_ppi = (
                str(d.unique_ppi_count) if d.unique_ppi_count is not None else ""
            )
            click.echo(
                f"{d.id:<12} {d.name:<{name_width}} {d.version:<8} {consortia:<22.22} {release:<18.18} {structures:<12} {a_size:<10} {alpha_size:<10} {total_ppi:<12} {unique_ppi:<12} {modes:<20.20}"
            )


@main.command("details")
@click.argument("block_id")
@click.option("--version", "-v", help="Specific version to retrieve")
def details_cmd(block_id: str, version: str | None) -> None:
    """Show detailed metadata for a block."""
    try:
        block = get_details(block_id, version=version)
    except Exception as e:
        _handle_error(e)

    click.echo(f"ID:           {block.id}")
    click.echo(f"Name:         {block.name}")
    click.echo(f"Version:      {block.version}")
    consortia = (
        block.product_display_name if block.product_kind == "consortium" else None
    )
    click.echo(f"Consortia:    {consortia or 'N/A'}")
    click.echo(f"Release:      {block.source or 'N/A'}")
    click.echo(f"Modes:        {', '.join(block.modes)}")
    click.echo(f"URL:          {block.url or 'N/A'}")
    if block.a_size is not None:
        click.echo(f"A Size:       {block.a_size}")
    if block.alpha_size is not None:
        click.echo(f"Alpha Size:   {block.alpha_size}")
    if block.total_ppi_count is not None:
        click.echo(f"Total PPI:    {block.total_ppi_count}")
    if block.unique_ppi_count is not None:
        click.echo(f"Unique PPI:   {block.unique_ppi_count}")
    if block.experiment:
        click.echo(f"Experiment:   {block.experiment}")
    if block.details:
        click.echo("Details:")
        try:
            from rich.console import Console
            from rich.markdown import Markdown

            console = Console()
            console.print(Markdown(block.details))
        except ImportError:
            click.echo(block.details)


@main.command("datacard")
@click.argument("block_id")
@click.option("--version", "-v", help="Specific version to retrieve")
@click.option("--raw", is_flag=True, help="Output raw JSON instead of formatted text")
def datacard_cmd(block_id: str, version: str | None, raw: bool) -> None:
    """Show the datacard for a block."""
    try:
        datacard = get_datacard(block_id, version=version)
    except Exception as e:
        _handle_error(e)

    if raw:
        click.echo(json.dumps(datacard, indent=2))
        return

    click.echo(_format_datacard(datacard))


def _format_datacard(datacard: dict) -> str:
    """Render a DatacardResponse dict as plain text with section headers.

    The payload groups fields by detail-page tab (`exec`, `ml`, `bio`).
    Sections whose source field is missing or empty are omitted — no header
    is printed for a section with no body.
    """
    lines: list[str] = []
    exec_ = datacard.get("exec") or {}
    ml = datacard.get("ml") or {}
    bio = datacard.get("bio") or {}

    def _make_title(title: str) -> None:
        if lines:
            lines.append("")
        lines.append(title)
        lines.append("-" * len(title))

    for title, source, key in (
        ("Scientific value", exec_, "scientific_value"),
        ("Recommended splits", ml, "recommended_splits"),
        ("Biological system", bio, "biological_system"),
    ):
        if text := (source.get(key) or "").strip():
            _make_title(title)
            lines.append(text)

    if stats := (exec_.get("stat_subs") or {}):
        _make_title("Stats")
        for k, v in stats.items():
            lines.append(f"  {k}: {v}")

    if use_cases := (exec_.get("use_cases") or []):
        _make_title("Use cases")
        for x in use_cases:
            lines.append(f"  * {x.get('title', '')}: {x.get('description', '')}")

    if diffs := (exec_.get("differentiations") or []):
        _make_title("Differentiations")
        for x in diffs:
            lines.append(f"  * {x.get('title', '')}: {x.get('body', '')}")

    if cols := (ml.get("schema_columns") or []):
        _make_title("Schema (key columns)")
        for x in cols:
            lines.append(
                f"  {x.get('name', '')} ({x.get('type', '')}): {x.get('description', '')}"
            )

    if signals := (ml.get("noise_signals") or []):
        _make_title("Noise & signal")
        for x in signals:
            suffix = f" — {x['sub']}" if x.get("sub") else ""
            lines.append(f"  {x.get('label', '')}: {x.get('value', '')}{suffix}")

    if views := (ml.get("data_views") or []):
        _make_title("Data views")
        for x in views:
            lines.append(f"  * {x.get('name', '')}: {x.get('description', '')}")

    if figs := (bio.get("figures") or []):
        _make_title("Figures")
        for x in figs:
            suffix = f" — {x['caption']}" if x.get("caption") else ""
            lines.append(f"  * {x.get('title', '')} [{x.get('filename', '')}]{suffix}")

    if findings := (bio.get("findings") or []):
        _make_title("Key findings")
        for x in findings:
            lines.append(f"  * {x.get('title', '')}: {x.get('body', '')}")

    if considerations := (bio.get("considerations") or []):
        _make_title("Considerations")
        for x in considerations:
            lines.append(f"  * {x}")

    return "\n".join(lines)


@main.command("schema")
@click.argument("block_id")
@click.option("--version", "-v", help="Specific version to retrieve")
@click.option("--mode", "-m", help="Data mode (e.g., source, ml). Default: source")
def schema_cmd(block_id: str, version: str | None, mode: str | None) -> None:
    """Show the CSV schema for a block."""
    try:
        schema = get_schema(block_id, version=version, mode=mode)
    except Exception as e:
        _handle_error(e)

    click.echo(json.dumps(schema, indent=2))


@main.command("get")
@click.argument("block_id")
@click.option("--version", "-v", help="Specific version to retrieve")
@click.option("--mode", "-m", help="Data mode (e.g., source, ml). Default: source")
@click.option(
    "--format",
    "-f",
    "fmt",
    type=click.Choice(["csv", "table", "gz"]),
    help="Output format: csv, table, or gz (compressed)",
)
@click.option(
    "--output",
    "-o",
    type=click.Path(),
    help="Output file path (use with -f csv or -f gz)",
)
@click.option(
    "--progress/--no-progress",
    default=None,
    help="Show download progress bar (default: from config)",
)
@click.option(
    "--load-negatives",
    is_flag=True,
    default=False,
    help="Include negative control rows (ANeg/AlphaNeg). Excluded by default.",
)
def get_cmd(
    block_id: str,
    version: str | None,
    mode: str | None,
    fmt: str | None,
    output: str | None,
    progress: bool | None,
    load_negatives: bool,
) -> None:
    """Get CSV data for a block."""
    # gz format only makes sense with -o
    if fmt == "gz" and not output:
        click.secho("Error: -format gz requires -o <output_file>", fg="red", err=True)
        sys.exit(1)

    try:
        # Write to file if output specified
        if output:
            # Determine if we should write compressed based on format
            write_compressed = fmt == "gz"
            get_data(
                block_id,
                version=version,
                mode=mode,
                output_path=output,
                output_compressed=write_compressed,
                progress=progress,
                load_negatives=load_negatives,
            )
            click.echo(f"Data written to {output}")
            return

        # Fetch as CSV for terminal output
        csv_data = get_data(
            block_id,
            version=version,
            mode=mode,
            format="csv",
            load_negatives=load_negatives,
        )
    except Exception as e:
        _handle_error(e)

    # Determine output format
    if not sys.stdout.isatty():
        use_table = False
    elif fmt:
        use_table = fmt == "table"
    else:
        # Use config default for terminal output
        use_table = get_cli_format() == "table"

    if use_table:
        try:
            from io import StringIO

            import pandas as pd

            df = pd.read_csv(StringIO(csv_data))  # type: ignore[arg-type]
            click.echo(df.to_string(index=False))
        except ImportError:
            click.echo(csv_data)
    else:
        click.echo(csv_data)


@main.command("structures")
@click.argument("block_id")
@click.option("--version", "-v", help="Specific version to retrieve")
@click.option(
    "--file",
    "-f",
    "filename",
    help="Download a single structure file by name",
)
@click.option(
    "--output",
    "-o",
    "output",
    is_flag=False,
    flag_value="",
    default=None,
    help="Write to file at the given path. If no path is given, defaults to the --file filename or the server-provided filename.",
)
@click.option(
    "--progress/--no-progress",
    default=None,
    help="Show download progress bar (default: from config)",
)
def structures_cmd(
    block_id: str,
    version: str | None,
    filename: str | None,
    output: str | None,
    progress: bool | None,
) -> None:
    """Download structure files for a block.

    By default, downloads all structures as a zip archive.
    Use --file to download a single structure file.
    """
    try:
        if filename:
            # -o with no value resolves to the structure filename
            if output is not None:
                out_path = output if output else filename
                result = download_structure(
                    block_id,
                    filename,
                    version=version,
                    output_path=out_path,
                )
                click.echo(f"Structure written to {result}")
            else:
                result = download_structure(
                    block_id,
                    filename,
                    version=version,
                )
                click.echo(result)
        else:
            out = download_structures(
                block_id,
                version=version,
                output_path=output if output else None,
                progress=progress,
            )
            click.echo(f"Structures written to {out}")
    except Exception as e:
        _handle_error(e)


if __name__ == "__main__":
    main()
