"""Mighty Spatial API client for IFC-to-GIS conversion.

Usage::

    from mightyspatial import MightySpatial

    ms = MightySpatial(api_key="ms_...")
    output = ms.convert("building.ifc", output_format="geojson")
"""

from __future__ import annotations

import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Union

import httpx

from ._version import __version__
from .exceptions import (
    AuthenticationError,
    ConversionError,
    FileTooLargeError,
    MightySpatialError,
    RateLimitError,
)

__all__ = ["MightySpatial"]

_FORMAT_EXTENSIONS = {
    "geojson": ".geojson",
    "geopackage": ".gpkg",
    "shapefile": ".shp.zip",
    "filegdb": ".gdb.zip",
}

_FREE_TIER_NOTICE_SHOWN = False


def _show_free_tier_notice() -> None:
    global _FREE_TIER_NOTICE_SHOWN
    if not _FREE_TIER_NOTICE_SHOWN:
        print(
            "\u2139\ufe0f  Using Mighty Spatial free tier (5 conversions/month, up to 25 MB). "
            "Get an API key for more: https://mightyspatial.com/dashboard",
            file=sys.stderr,
        )
        _FREE_TIER_NOTICE_SHOWN = True


def _handle_error_response(response: httpx.Response) -> None:
    """Raise the appropriate exception for non-2xx responses."""
    status = response.status_code

    # Try to extract a detail message from JSON body
    detail = ""
    try:
        body = response.json()
        detail = body.get("detail", "")
    except Exception:
        detail = response.text

    if status == 429:
        raise RateLimitError()
    if status == 413:
        raise FileTooLargeError(detail or None)
    if status == 401:
        raise AuthenticationError(detail or None)
    if status == 403:
        raise AuthenticationError(
            detail or "Forbidden. Your tier may not support this operation. "
            "Upgrade at https://mightyspatial.com/dashboard"
        )
    if 400 <= status < 500:
        raise ConversionError(detail or f"Client error (HTTP {status})")
    if status >= 500:
        raise ConversionError(detail or f"Server error (HTTP {status})")


class MightySpatial:
    """Client for the Mighty Spatial IFC-to-GIS conversion API.

    Parameters
    ----------
    api_key:
        Your API key. Falls back to the ``MIGHTY_SPATIAL_API_KEY``
        environment variable when *None*.
    base_url:
        Override the API base URL (useful for self-hosted deployments).
    timeout:
        Request timeout in seconds (default 300 -- conversions can be slow
        for large files).
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: str = "https://mightyspatial.com/api",
        timeout: float = 300,
    ) -> None:
        self.api_key = api_key or os.environ.get("MIGHTY_SPATIAL_API_KEY")
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout

        if not self.api_key:
            _show_free_tier_notice()

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _headers(self) -> Dict[str, str]:
        headers: Dict[str, str] = {
            "User-Agent": f"mightyspatial-python/{__version__}",
        }
        if self.api_key:
            headers["X-API-Key"] = self.api_key
        return headers

    def _client(self) -> httpx.Client:
        return httpx.Client(
            headers=self._headers(),
            timeout=self.timeout,
        )

    @staticmethod
    def _resolve_output_path(
        file_path: Optional[str],
        output_path: Optional[str],
        output_format: str,
    ) -> str:
        """Determine where to save the downloaded file."""
        if output_path:
            return output_path

        ext = _FORMAT_EXTENSIONS.get(output_format, ".geojson")

        if file_path:
            base = os.path.splitext(file_path)[0]
            # For zip-based formats, strip the double extension nicely
            return base + ext

        return "output" + ext

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

    def convert(
        self,
        file_path: Optional[str] = None,
        file_url: Optional[str] = None,
        output_format: str = "geojson",
        output_path: Optional[str] = None,
        source_crs: Optional[str] = None,
        strip_prefix: Optional[str] = None,
        strip_suffix: Optional[str] = None,
        include_csv: bool = False,
    ) -> str:
        """Convert an IFC file to a GIS format in one step.

        Provide either *file_path* (local file) or *file_url* (remote URL).
        Returns the path to the saved output file.

        Parameters
        ----------
        file_path:
            Path to a local ``.ifc`` file.
        file_url:
            URL of a remote ``.ifc`` file.
        output_format:
            One of ``"geojson"``, ``"geopackage"``, ``"shapefile"``,
            ``"filegdb"``.
        output_path:
            Where to save the result. Defaults to the input filename with the
            appropriate extension in the same directory.
        source_crs:
            Override the source coordinate reference system (e.g.
            ``"EPSG:28356"``).
        strip_prefix:
            Remove this prefix from all property names.
        strip_suffix:
            Remove this suffix from all property names.
        include_csv:
            Also include a CSV export of attribute tables.

        Returns
        -------
        str
            Absolute path to the saved output file.
        """
        if not file_path and not file_url:
            raise ValueError("Provide either file_path or file_url.")

        data: Dict[str, Any] = {
            "output_format": output_format,
            "include_csv": str(include_csv).lower(),
        }
        if source_crs:
            data["source_crs"] = source_crs
        if strip_prefix:
            data["strip_prefix"] = strip_prefix
        if strip_suffix:
            data["strip_suffix"] = strip_suffix

        files = None
        if file_path:
            file_path = os.path.abspath(file_path)
            if not os.path.isfile(file_path):
                raise FileNotFoundError(f"IFC file not found: {file_path}")
            files = {"file": (os.path.basename(file_path), open(file_path, "rb"), "application/octet-stream")}
        elif file_url:
            data["file_url"] = file_url

        with self._client() as client:
            try:
                response = client.post(
                    f"{self.base_url}/convert",
                    data=data,
                    files=files,
                )
            finally:
                # Close the file handle if we opened one
                if files and "file" in files:
                    files["file"][1].close()

        if not response.is_success:
            _handle_error_response(response)

        dest = self._resolve_output_path(file_path, output_path, output_format)
        dest = os.path.abspath(dest)

        # If include_csv is True the server returns a zip regardless
        if include_csv and not dest.endswith(".zip"):
            dest = os.path.splitext(dest)[0] + ".zip"

        os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
        with open(dest, "wb") as fh:
            fh.write(response.content)

        # Sales funnel: nudge free-tier users after success
        if not self.api_key:
            remaining = response.headers.get("X-RateLimit-Remaining", "?")
            print(
                f"\u2713 Converted successfully. {remaining} free conversions "
                f"remaining this month. https://mightyspatial.com/convert",
                file=sys.stderr,
            )

        return dest

    def preview(
        self,
        file_path: Optional[str] = None,
        file_url: Optional[str] = None,
        source_crs: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Upload an IFC file and get a preview with layer metadata.

        Returns a dict with ``job_id``, ``layers``, ``detected_crs``,
        and ``available_attributes``.
        """
        if not file_path and not file_url:
            raise ValueError("Provide either file_path or file_url.")

        data: Dict[str, Any] = {}
        if source_crs:
            data["source_crs"] = source_crs

        files = None
        if file_path:
            file_path = os.path.abspath(file_path)
            if not os.path.isfile(file_path):
                raise FileNotFoundError(f"IFC file not found: {file_path}")
            files = {"file": (os.path.basename(file_path), open(file_path, "rb"), "application/octet-stream")}
        elif file_url:
            data["file_url"] = file_url

        with self._client() as client:
            try:
                response = client.post(
                    f"{self.base_url}/convert/preview",
                    data=data,
                    files=files,
                )
            finally:
                if files and "file" in files:
                    files["file"][1].close()

        if not response.is_success:
            _handle_error_response(response)

        return response.json()

    def download(
        self,
        job_id: str,
        output_format: str = "geojson",
        output_path: Optional[str] = None,
    ) -> str:
        """Download converted data from a previous :meth:`preview` call.

        Parameters
        ----------
        job_id:
            The ``job_id`` returned by :meth:`preview`.
        output_format:
            Target format.
        output_path:
            Where to save the file. Defaults to ``<job_id>.<ext>``.

        Returns
        -------
        str
            Absolute path to the saved output file.
        """
        with self._client() as client:
            response = client.get(
                f"{self.base_url}/convert/download/{job_id}",
                params={"output_format": output_format},
            )

        if not response.is_success:
            _handle_error_response(response)

        if not output_path:
            ext = _FORMAT_EXTENSIONS.get(output_format, ".geojson")
            output_path = f"{job_id}{ext}"

        output_path = os.path.abspath(output_path)
        os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
        with open(output_path, "wb") as fh:
            fh.write(response.content)

        return output_path

    def generate_pipes(
        self,
        job_id: str,
        source_layer: str,
        features: List[Dict[str, Any]],
        *,
        diameter_field: Optional[str] = None,
        config_field: Optional[str] = None,
        uom: str = "mm",
        default_diameter: float = 300,
        centerline_mode: str = "centerline",
        bank_centerline_mode: str = "centerline",
        bank_stack_direction: str = "bottomUp",
        bank_gap: float = 50,
        bank_expression_schema: Optional[Dict[str, Any]] = None,
        segments: int = 8,
    ) -> Dict[str, Any]:
        """Generate 3D pipe geometry from polyline features.

        Parameters
        ----------
        job_id:
            The ``job_id`` from a previous :meth:`preview`.
        source_layer:
            Name of the source polyline layer.
        features:
            List of GeoJSON feature dicts from the source layer.
        **kwargs:
            Pipe configuration options (diameter_field, uom, segments, etc.).

        Returns
        -------
        dict
            A dict with a ``layer`` key containing the generated pipe
            FeatureCollection.
        """
        body: Dict[str, Any] = {
            "source_layer": source_layer,
            "features": features,
            "uom": uom,
            "default_diameter": default_diameter,
            "centerline_mode": centerline_mode,
            "bank_centerline_mode": bank_centerline_mode,
            "bank_stack_direction": bank_stack_direction,
            "bank_gap": bank_gap,
            "segments": segments,
        }
        if diameter_field is not None:
            body["diameter_field"] = diameter_field
        if config_field is not None:
            body["config_field"] = config_field
        if bank_expression_schema is not None:
            body["bank_expression_schema"] = bank_expression_schema

        with self._client() as client:
            response = client.post(
                f"{self.base_url}/convert/pipes/{job_id}",
                json=body,
            )

        if not response.is_success:
            _handle_error_response(response)

        return response.json()

    def formats(self) -> List[Dict[str, Any]]:
        """List supported output formats and their tier requirements.

        Returns
        -------
        list[dict]
            Each dict has ``format``, ``extension``, ``mime_type``,
            ``min_tier``, and ``description``.
        """
        with self._client() as client:
            response = client.get(f"{self.base_url}/formats")

        if not response.is_success:
            _handle_error_response(response)

        return response.json().get("formats", [])

    def health(self) -> Dict[str, Any]:
        """Check API health status.

        Returns
        -------
        dict
            Server health information.
        """
        with self._client() as client:
            response = client.get(f"{self.base_url}/health")

        if not response.is_success:
            _handle_error_response(response)

        return response.json()

    # ------------------------------------------------------------------
    # DataFrame output
    # ------------------------------------------------------------------

    def _convert_raw(
        self,
        file_path: Optional[str] = None,
        file_url: Optional[str] = None,
        source_crs: Optional[str] = None,
        strip_prefix: Optional[str] = None,
        strip_suffix: Optional[str] = None,
    ) -> bytes:
        """Run conversion and return the raw GeoJSON bytes (no disk write)."""
        if not file_path and not file_url:
            raise ValueError("Provide either file_path or file_url.")

        data: Dict[str, Any] = {
            "output_format": "geojson",
            "include_csv": "false",
        }
        if source_crs:
            data["source_crs"] = source_crs
        if strip_prefix:
            data["strip_prefix"] = strip_prefix
        if strip_suffix:
            data["strip_suffix"] = strip_suffix

        files = None
        if file_path:
            file_path = os.path.abspath(file_path)
            if not os.path.isfile(file_path):
                raise FileNotFoundError(f"IFC file not found: {file_path}")
            files = {
                "file": (
                    os.path.basename(file_path),
                    open(file_path, "rb"),
                    "application/octet-stream",
                ),
            }
        elif file_url:
            data["file_url"] = file_url

        with self._client() as client:
            try:
                response = client.post(
                    f"{self.base_url}/convert",
                    data=data,
                    files=files,
                )
            finally:
                if files and "file" in files:
                    files["file"][1].close()

        if not response.is_success:
            _handle_error_response(response)

        if not self.api_key:
            remaining = response.headers.get("X-RateLimit-Remaining", "?")
            print(
                f"\u2713 Converted successfully. {remaining} free conversions "
                f"remaining this month. https://mightyspatial.com/convert",
                file=sys.stderr,
            )

        return response.content

    def to_geodataframe(
        self,
        file_path: Optional[str] = None,
        file_url: Optional[str] = None,
        source_crs: Optional[str] = None,
        strip_prefix: Optional[str] = None,
        strip_suffix: Optional[str] = None,
    ) -> Any:
        """Convert an IFC file and return a :class:`geopandas.GeoDataFrame`.

        Requires ``geopandas``. Install with::

            pip install mightyspatial[geo]

        Parameters
        ----------
        file_path:
            Path to a local ``.ifc`` file.
        file_url:
            URL of a remote ``.ifc`` file.
        source_crs:
            Override source CRS (e.g. ``"EPSG:28356"``).
        strip_prefix:
            Remove this prefix from property names.
        strip_suffix:
            Remove this suffix from property names.

        Returns
        -------
        geopandas.GeoDataFrame
            A GeoDataFrame with geometry and all IFC properties.

        Examples
        --------
        >>> ms = MightySpatial(api_key="ms_...")
        >>> gdf = ms.to_geodataframe("building.ifc")
        >>> gdf.plot()
        """
        try:
            import geopandas as gpd
        except ImportError:
            raise ImportError(
                "geopandas is required for to_geodataframe(). "
                "Install it with: pip install mightyspatial[geo]"
            )

        import io
        import json

        raw = self._convert_raw(
            file_path=file_path,
            file_url=file_url,
            source_crs=source_crs,
            strip_prefix=strip_prefix,
            strip_suffix=strip_suffix,
        )

        geojson = json.loads(raw)

        # Handle FeatureCollection or raw feature list
        if isinstance(geojson, dict) and geojson.get("type") == "FeatureCollection":
            return gpd.GeoDataFrame.from_features(
                geojson["features"], crs=geojson.get("crs", {}).get("properties", {}).get("name")
            )

        # Fallback: try reading from bytes
        return gpd.read_file(io.BytesIO(raw), driver="GeoJSON")

    def to_dataframe(
        self,
        file_path: Optional[str] = None,
        file_url: Optional[str] = None,
        source_crs: Optional[str] = None,
        strip_prefix: Optional[str] = None,
        strip_suffix: Optional[str] = None,
    ) -> Any:
        """Convert an IFC file and return a :class:`pandas.DataFrame`.

        Returns only the attribute columns (no geometry). For spatial data,
        use :meth:`to_geodataframe` instead.

        Requires ``pandas``. Install with::

            pip install mightyspatial[pandas]

        Parameters
        ----------
        file_path:
            Path to a local ``.ifc`` file.
        file_url:
            URL of a remote ``.ifc`` file.
        source_crs:
            Override source CRS (e.g. ``"EPSG:28356"``).
        strip_prefix:
            Remove this prefix from property names.
        strip_suffix:
            Remove this suffix from property names.

        Returns
        -------
        pandas.DataFrame
            A DataFrame with all IFC properties (geometry excluded).

        Examples
        --------
        >>> ms = MightySpatial(api_key="ms_...")
        >>> df = ms.to_dataframe("building.ifc")
        >>> df.head()
        """
        try:
            import pandas as pd
        except ImportError:
            raise ImportError(
                "pandas is required for to_dataframe(). "
                "Install it with: pip install mightyspatial[pandas]"
            )

        import json

        raw = self._convert_raw(
            file_path=file_path,
            file_url=file_url,
            source_crs=source_crs,
            strip_prefix=strip_prefix,
            strip_suffix=strip_suffix,
        )

        geojson = json.loads(raw)

        if isinstance(geojson, dict) and geojson.get("type") == "FeatureCollection":
            features = geojson["features"]
        elif isinstance(geojson, list):
            features = geojson
        else:
            features = [geojson]

        rows = []
        for f in features:
            props = dict(f.get("properties", {}))
            # Include geometry type as a column for reference
            geom = f.get("geometry")
            if geom:
                props["geometry_type"] = geom.get("type")
            rows.append(props)

        return pd.DataFrame(rows)

    # ------------------------------------------------------------------
    # Dunder methods
    # ------------------------------------------------------------------

    def __repr__(self) -> str:
        tier = "authenticated" if self.api_key else "free"
        return (
            f"<MightySpatial tier={tier} base_url={self.base_url!r}> "
            f"Docs: https://mightyspatial.com/docs"
        )
