"""Async client for NiSource utility portals.

NiSource utilities (Columbia Gas, NIPSCO) don't have a public API.
Instead, their customer portal is a server-rendered MVC app that embeds
usage and billing data as JavaScript objects in the HTML:

    <script>
        window.VueTables.usageData = [{...}, {...}, ...];
    </script>

This client logs into the portal with a standard form POST, then scrapes
these embedded JSON objects from the usage and billing pages.
"""

from __future__ import annotations

import json
import logging
import re
from datetime import datetime, timezone
from typing import Any

import aiohttp

from .const import NiSourceUtility
from .exceptions import AuthenticationError, CannotConnectError, InvalidResponseError
from .models import AccountInfo, AccountStatus, BillRecord, UsageRead

_LOGGER = logging.getLogger(__name__)

# This regex finds all "window.VueTables.XXXX = [...];" patterns in the HTML.
# Group 1 = table name (e.g., "usageData"), Group 2 = JSON array content.
# The DOTALL flag lets "." match newlines, since the JSON can span multiple lines.
_VUE_TABLES_PATTERN = re.compile(
    r"window\.VueTables\.(\w+)\s*=\s*(\[.*?\])\s*;", re.DOTALL
)


class NiSourceClient:
    """Async client for NiSource utility portals.

    This client scrapes server-rendered HTML pages that contain
    usage and billing data as embedded window.VueTables.* JSON objects.

    The client does NOT manage its own aiohttp session - you must provide one
    with a CookieJar(unsafe=True) for cross-redirect cookies.
    """

    def __init__(
        self,
        session: aiohttp.ClientSession,
        utility: NiSourceUtility,
        email: str,
        password: str,
    ) -> None:
        """Initialize the client.

        Args:
            session: An aiohttp session with CookieJar(unsafe=True).
            utility: Which NiSource utility to connect to.
            email: User's portal email address.
            password: User's portal password.
        """
        self._session = session
        self._utility = utility
        self._email = email
        self._password = password
        self._base_url = utility.base_url
        self._account_info: AccountInfo | None = None

    @property
    def utility(self) -> NiSourceUtility:
        """Return the configured utility."""
        return self._utility

    @property
    def account_info(self) -> AccountInfo | None:
        """Return account info from the last login, if available."""
        return self._account_info

    async def async_login(self) -> AccountInfo:
        """Log in to the NiSource portal.

        Performs the 3-step login:
        1. GET /login (capture cookies)
        2. POST /login with form data
        3. Follow redirect to dashboard

        Returns:
            AccountInfo with the account ID and display name.

        Raises:
            AuthenticationError: If credentials are invalid.
            CannotConnectError: If the portal cannot be reached.
        """
        login_url = f"{self._base_url}/login"

        try:
            # Step 1: GET the login page first. This is required because the
            # server sets session cookies (F5 load balancer, XSRF token, etc.)
            # that must be present when we POST the login form.
            async with self._session.get(login_url) as resp:
                if resp.status != 200:
                    raise CannotConnectError(
                        f"Failed to load login page: HTTP {resp.status}"
                    )

            # Step 2: POST the login form. The portal expects standard
            # form-encoded data (not JSON). On success, it returns a 302
            # redirect to /dashboard/{accountNumber}/{ldcCode}?dlp=LoginSuccess.
            # We set allow_redirects=True so aiohttp follows the redirect chain
            # automatically, ending up on the dashboard page.
            form_data = {
                "ReturnUrl": "",
                "Username": self._email,
                "Password": self._password,
                "rememberme": "true",
            }
            async with self._session.post(
                login_url,
                data=form_data,
                allow_redirects=True,
            ) as resp:
                # After following redirects, we should be on the dashboard
                final_url = str(resp.url)
                html = await resp.text()

                if resp.status != 200:
                    raise AuthenticationError(
                        f"Login failed: HTTP {resp.status}"
                    )

                # Check if we're still on the login page (failed login)
                if "/login" in final_url and "LoginSuccess" not in final_url:
                    raise AuthenticationError("Invalid email or password")

        except aiohttp.ClientError as err:
            raise CannotConnectError(f"Cannot connect to portal: {err}") from err

        # After a successful login, the portal sets several cookies with
        # account info. We extract the account ID and display name from these
        # cookies rather than parsing the HTML, since they're more reliable.
        #   - "selectedAccount" = the account number (e.g., "12345678")
        #   - "isAuthenticated" = JSON with display name and notification count
        account_id = ""
        display_name = ""
        for cookie in self._session.cookie_jar:
            if cookie.key == "selectedAccount":
                account_id = cookie.value or ""
            elif cookie.key == "isAuthenticated":
                try:
                    auth_data = json.loads(cookie.value or "{}")
                    display_name = auth_data.get("displayName", "")
                except (json.JSONDecodeError, TypeError):
                    pass

        if not account_id:
            # Fallback: try to extract from redirect URL
            # e.g., /dashboard/12345678/OH?dlp=LoginSuccess
            match = re.search(r"/dashboard/(\d+)/", final_url)
            if match:
                account_id = match.group(1)

        self._account_info = AccountInfo(
            account_id=account_id,
            display_name=display_name,
        )
        return self._account_info

    async def async_check_status(self) -> AccountStatus:
        """Check if the current session is authenticated.

        Returns:
            AccountStatus with login state.

        Raises:
            CannotConnectError: If the portal cannot be reached.
            InvalidResponseError: If the response is unexpected.
        """
        url = f"{self._base_url}/nisource-api/ldc/status"

        try:
            async with self._session.get(url) as resp:
                if resp.status != 200:
                    raise CannotConnectError(
                        f"Status check failed: HTTP {resp.status}"
                    )
                data = await resp.json()
        except aiohttp.ClientError as err:
            raise CannotConnectError(f"Cannot connect to portal: {err}") from err
        except (json.JSONDecodeError, aiohttp.ContentTypeError) as err:
            raise InvalidResponseError(
                f"Unexpected status response: {err}"
            ) from err

        return AccountStatus(
            display_name=data.get("displayName", ""),
            notification_count=int(data.get("notificationCount", 0)),
            logged_in=data.get("loggedIn", False),
        )

    async def async_get_usage(self) -> list[UsageRead]:
        """Scrape the usage page for usage data.

        Returns:
            List of UsageRead records (most recent first).

        Raises:
            CannotConnectError: If the portal cannot be reached.
            InvalidResponseError: If usage data cannot be extracted.
        """
        html = await self._async_get_page("/usage")
        vue_tables = self._extract_vue_tables(html)

        usage_data = vue_tables.get("usageData")
        if usage_data is None:
            raise InvalidResponseError("No usageData found on usage page")

        reads: list[UsageRead] = []
        for record in usage_data:
            try:
                reads.append(
                    UsageRead(
                        service_account_id=str(
                            record.get("serviceAccountId", "")
                        ),
                        meter_id=str(record.get("meterId", "")),
                        read_date=_parse_date(record["readDate"]),
                        read_method=record.get("readMethod", ""),
                        reading=float(record.get("reading", 0)),
                        average_temp=_safe_float(record.get("averageTemp")),
                        number_of_billing_days=int(
                            record.get("numberOfBillingDays", 0)
                        ),
                        units_used=float(record.get("unitsUsed", 0)),
                        bill_amount=float(record.get("billAmount", 0)),
                        cost_per_day=_safe_float(record.get("costPerDay")),
                    )
                )
            except (KeyError, ValueError, TypeError) as err:
                _LOGGER.debug("Skipping malformed usage record: %s", err)
                continue

        return reads

    async def async_get_bills(self) -> list[BillRecord]:
        """Scrape the bills page for billing history.

        Returns:
            List of BillRecord entries.

        Raises:
            CannotConnectError: If the portal cannot be reached.
            InvalidResponseError: If billing data cannot be extracted.
        """
        html = await self._async_get_page("/bills")
        vue_tables = self._extract_vue_tables(html)

        history_data = vue_tables.get("historyData")
        if history_data is None:
            raise InvalidResponseError("No historyData found on bills page")

        records: list[BillRecord] = []
        for record in history_data:
            try:
                records.append(
                    BillRecord(
                        description=record.get("Description", ""),
                        date=_parse_date(record["Date"]),
                        amount=float(record.get("Amount", 0)),
                        record_type=record.get("Type", ""),
                        bill_id=record.get("BillId"),
                        url_link=record.get("UrlLink") or None,
                    )
                )
            except (KeyError, ValueError, TypeError) as err:
                _LOGGER.debug("Skipping malformed bill record: %s", err)
                continue

        return records

    async def _async_get_page(self, path: str) -> str:
        """Fetch an HTML page from the portal.

        Args:
            path: URL path (e.g., "/usage").

        Returns:
            HTML content as a string.

        Raises:
            CannotConnectError: If the portal cannot be reached.
        """
        url = f"{self._base_url}{path}"
        try:
            async with self._session.get(url) as resp:
                if resp.status != 200:
                    raise CannotConnectError(
                        f"Failed to load {path}: HTTP {resp.status}"
                    )
                return await resp.text()
        except aiohttp.ClientError as err:
            raise CannotConnectError(f"Cannot connect to portal: {err}") from err

    @staticmethod
    def _extract_vue_tables(html: str) -> dict[str, list[dict[str, Any]]]:
        """Extract all window.VueTables.* JSON data from HTML.

        Args:
            html: Full HTML page content.

        Returns:
            Dict mapping table name to parsed JSON list.
        """
        tables: dict[str, list[dict[str, Any]]] = {}
        for match in _VUE_TABLES_PATTERN.finditer(html):
            name = match.group(1)
            try:
                data = json.loads(match.group(2))
                if isinstance(data, list):
                    tables[name] = data
            except json.JSONDecodeError:
                _LOGGER.debug("Failed to parse VueTables.%s JSON", name)
                continue
        return tables


def _parse_date(value: str) -> datetime:
    """Parse an ISO date string from the portal.

    Handles both formats:
    - "2025-02-04T05:00:00.000Z" (with milliseconds and UTC marker)
    - "2025-02-04T00:00:00" (without timezone)

    Always returns a timezone-aware datetime in UTC. The HA recorder
    requires timezone-aware timestamps for external statistics.
    """
    value = value.rstrip("Z")
    for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
        try:
            dt = datetime.strptime(value, fmt)
            return dt.replace(tzinfo=timezone.utc)
        except ValueError:
            continue
    raise ValueError(f"Cannot parse date: {value}")


def _safe_float(value: Any) -> float | None:
    """Convert a value to float, returning None if not possible."""
    if value is None:
        return None
    try:
        return float(value)
    except (ValueError, TypeError):
        return None
