"""
Dataverse MCP helper utilities for Dynamics 365.

Provides:
- Dataverse API client helpers for read and mutation operations.
- Deterministic plan-hash generation/validation for two-phase writes.
- Runtime diagnostics for the official Dataverse MCP local proxy tool.
"""

from __future__ import annotations

from dataclasses import dataclass
import hashlib
import json
import logging
import os
import re
import shutil
import subprocess
from typing import Any, Dict, List

from g_gremlin.credentials.provider import CredentialProvider
from g_gremlin.scout.providers.dynamics_auth import DataverseAuthenticator
from g_gremlin.scout.providers.dynamics_client import DataverseClient

DATAVERSE_MCP_DOTNET_PACKAGE = "Microsoft.PowerPlatform.Dataverse.MCP"
DYNAMICS_MCP_PROFILE_ENV_VAR = "G_GREMLIN_MCP_DYNAMICS_PROFILE"

DATAVERSE_CLIENT_NOTES: List[str] = [
    "For non-Microsoft MCP clients, enable Dataverse MCP in Power Platform Admin Center (PPAC).",
    "For non-Microsoft MCP clients, add each client to the Dataverse MCP allowed clients list in PPAC.",
]

DATAVERSE_BILLING_NOTES: List[str] = [
    "External AI-agent access to Dataverse MCP can incur Dataverse request/API usage charges.",
    "Review Power Platform licensing and API limits before enabling write tools for autonomous clients.",
]

logger = logging.getLogger(__name__)


class DataverseMCPError(RuntimeError):
    """Base error for Dataverse MCP helper operations."""


@dataclass(frozen=True)
class DataverseCredentials:
    environment_url: str
    tenant_id: str
    client_id: str
    client_secret: str


def resolve_mcp_profile(profile: str | None = None) -> str:
    raw_profile = str(profile or "").strip()
    if raw_profile:
        return raw_profile
    env_profile = str(os.getenv(DYNAMICS_MCP_PROFILE_ENV_VAR) or "").strip()
    if env_profile:
        return env_profile
    return "default"


def _normalize_environment_url(url: str) -> str:
    value = str(url or "").strip()
    if not value:
        return value
    return value.rstrip("/")


def _resolve_dynamics_credential(
    key: str,
    *,
    profile: str | None = None,
    provider: CredentialProvider | None = None,
) -> str:
    env_key = f"G_GREMLIN_DYNAMICS_{key.upper()}"
    env_val = str(os.getenv(env_key) or "").strip()
    if env_val:
        return env_val

    resolved_profile = resolve_mcp_profile(profile)
    cred_provider = provider or CredentialProvider()
    secret = cred_provider.get_secret_or_none("dynamics", key, profile=resolved_profile)
    if secret is None:
        return ""
    return str(secret.value or "").strip()


def load_dataverse_credentials(
    *,
    profile: str | None = None,
    provider: CredentialProvider | None = None,
) -> DataverseCredentials:
    """
    Resolve Dataverse credentials from env + configured g-gremlin credentials.

    Required for Dataverse Web API access:
    - environment_url
    - tenant_id
    - client_id
    - client_secret
    """
    resolved_profile = resolve_mcp_profile(profile)
    environment_url = _normalize_environment_url(
        _resolve_dynamics_credential("environment_url", profile=resolved_profile, provider=provider)
    )
    tenant_id = _resolve_dynamics_credential("tenant_id", profile=resolved_profile, provider=provider)
    client_id = _resolve_dynamics_credential("client_id", profile=resolved_profile, provider=provider)
    client_secret = _resolve_dynamics_credential("client_secret", profile=resolved_profile, provider=provider)

    missing: List[str] = []
    if not environment_url:
        missing.append("environment_url")
    if not tenant_id:
        missing.append("tenant_id")
    if not client_id:
        missing.append("client_id")
    if not client_secret:
        missing.append("client_secret")
    if missing:
        raise DataverseMCPError(
            "Missing Dataverse credentials: "
            + ", ".join(missing)
            + ". Set env vars G_GREMLIN_DYNAMICS_* or run g-gremlin auth set dynamics."
        )

    return DataverseCredentials(
        environment_url=environment_url,
        tenant_id=tenant_id,
        client_id=client_id,
        client_secret=client_secret,
    )


def _escape_odata_string(value: str) -> str:
    return str(value).replace("'", "''")


def _extract_guid(value: str | None) -> str | None:
    if not value:
        return None
    match = re.search(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", value)
    if not match:
        return None
    return match.group(0).lower()


def _validate_filter_expr(filter_expr: str | None) -> str | None:
    if filter_expr is None:
        return None
    raw = str(filter_expr)
    if any(token in raw for token in ("\r", "\n", "\x00")):
        raise DataverseMCPError("filter_expr cannot contain newline or null control characters")
    return raw


class DataverseMCPClient:
    """
    Thin Dataverse client for MCP tools.

    Uses the same Dataverse auth/client primitives used by scout providers so behavior
    stays consistent across g-gremlin surfaces.
    """

    def __init__(self, credentials: DataverseCredentials) -> None:
        authenticator = DataverseAuthenticator(
            tenant_id=credentials.tenant_id,
            client_id=credentials.client_id,
            client_secret=credentials.client_secret,
            environment_url=credentials.environment_url,
        )
        self.credentials = credentials
        self.client = DataverseClient(
            credentials.environment_url,
            authenticator,
            credential_provider=CredentialProvider(),
        )

    @property
    def base_url(self) -> str:
        return self.client.base_url

    def list_tables(self, *, top: int = 200, include_managed: bool = True) -> Dict[str, Any]:
        # Note: EntityDefinitions (metadata endpoint) does NOT support $top.
        # We apply the limit client-side after fetching.
        params: Dict[str, Any] = {
            "$select": ",".join(
                [
                    "LogicalName",
                    "SchemaName",
                    "EntitySetName",
                    "DisplayName",
                    "PrimaryIdAttribute",
                    "PrimaryNameAttribute",
                    "IsCustomEntity",
                    "IsManaged",
                ]
            ),
        }
        if not include_managed:
            params["$filter"] = "IsManaged eq false"

        resp = self.client.get("EntityDefinitions", params=params)
        payload = resp.json()
        rows = list(payload.get("value") or [])
        effective_top = max(int(top), 1)
        truncated = rows[:effective_top]
        return {
            "tables": truncated,
            "count": len(truncated),
            "total_available": len(rows),
            "next_link": payload.get("@odata.nextLink"),
        }

    def describe_table(
        self,
        table: str,
        *,
        include_attributes: bool = True,
        attribute_top: int = 500,
    ) -> Dict[str, Any]:
        logical_name = str(table or "").strip()
        if not logical_name:
            raise DataverseMCPError("table is required for describe_table")

        escaped = _escape_odata_string(logical_name)
        metadata_resp = self.client.get(
            f"EntityDefinitions(LogicalName='{escaped}')",
            params={
                "$select": ",".join(
                    [
                        "LogicalName",
                        "SchemaName",
                        "EntitySetName",
                        "DisplayName",
                        "PrimaryIdAttribute",
                        "PrimaryNameAttribute",
                        "OwnershipType",
                        "ObjectTypeCode",
                        "IsCustomEntity",
                        "IsManaged",
                    ]
                )
            },
        )
        metadata = metadata_resp.json()
        if not include_attributes:
            return {"table": metadata, "attributes": []}

        # Note: $top is silently ignored on metadata sub-endpoints; limit client-side.
        attributes_resp = self.client.get(
            f"EntityDefinitions(LogicalName='{escaped}')/Attributes",
            params={
                "$select": "LogicalName,AttributeType,DisplayName,IsPrimaryId,IsPrimaryName,SchemaName",
            },
        )
        attributes_payload = attributes_resp.json()
        all_attributes = list(attributes_payload.get("value") or [])
        effective_top = max(int(attribute_top), 1)
        attributes = all_attributes[:effective_top]
        return {
            "table": metadata,
            "attributes": attributes,
            "attribute_count": len(attributes),
            "total_available": len(all_attributes),
            "next_link": attributes_payload.get("@odata.nextLink"),
        }

    def read_query(
        self,
        *,
        table: str,
        select: List[str] | None = None,
        filter_expr: str | None = None,
        orderby: str | None = None,
        top: int = 100,
        count: bool = False,
    ) -> Dict[str, Any]:
        logical_name = str(table or "").strip()
        if not logical_name:
            raise DataverseMCPError("table is required for read_query")

        # Trust boundary note:
        # `filter_expr` is caller-provided OData syntax (often AI-agent-generated) and is
        # intentionally passed through for read-only querying flexibility.
        # We only block control characters; Dataverse validates query semantics server-side.
        safe_filter_expr = _validate_filter_expr(filter_expr)

        payload = self.client.query(
            logical_name,
            select=select,
            filter_expr=safe_filter_expr,
            orderby=orderby,
            top=max(int(top), 1),
            count=bool(count),
        )
        rows = list(payload.get("value") or [])
        return {
            "rows": rows,
            "count": len(rows),
            "odata_count": payload.get("@odata.count"),
            "next_link": payload.get("@odata.nextLink"),
            "raw": payload,
        }

    def create_record(self, *, table: str, record: Dict[str, Any]) -> Dict[str, Any]:
        logical_name = str(table or "").strip()
        if not logical_name:
            raise DataverseMCPError("table is required for create_record")

        entity_set = self.client.resolve_entity_set(logical_name)
        response = self.client.post(
            entity_set,
            json_payload=record,
            headers={
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
            allow_statuses=(201, 204),
        )
        entity_header = response.headers.get("OData-EntityId") or response.headers.get("odata-entityid")
        record_id = _extract_guid(entity_header)
        body: Dict[str, Any] = {}
        if response.text:
            try:
                maybe = response.json()
                if isinstance(maybe, dict):
                    body = maybe
            except Exception as exc:
                logger.debug("Dataverse create_record response body was not valid JSON: %s", exc)
                body = {}
        return {
            "table": logical_name,
            "entity_set": entity_set,
            "record_id": record_id,
            "status_code": response.status_code,
            "body": body,
        }

    def update_record(self, *, table: str, record_id: str, fields: Dict[str, Any]) -> Dict[str, Any]:
        logical_name = str(table or "").strip()
        safe_record_id = str(record_id or "").strip().strip("{}")
        if not logical_name:
            raise DataverseMCPError("table is required for update_record")
        if not safe_record_id:
            raise DataverseMCPError("record_id is required for update_record")

        entity_set = self.client.resolve_entity_set(logical_name)
        response = self.client.patch(
            f"{entity_set}({safe_record_id})",
            json_payload=fields,
            headers={
                "Content-Type": "application/json",
                "If-Match": "*",
            },
            allow_statuses=(204,),
        )
        return {
            "table": logical_name,
            "entity_set": entity_set,
            "record_id": safe_record_id,
            "status_code": response.status_code,
        }

    def delete_record(self, *, table: str, record_id: str) -> Dict[str, Any]:
        logical_name = str(table or "").strip()
        safe_record_id = str(record_id or "").strip().strip("{}")
        if not logical_name:
            raise DataverseMCPError("table is required for delete_record")
        if not safe_record_id:
            raise DataverseMCPError("record_id is required for delete_record")

        entity_set = self.client.resolve_entity_set(logical_name)
        response = self.client.delete(
            f"{entity_set}({safe_record_id})",
            allow_statuses=(204,),
        )
        return {
            "table": logical_name,
            "entity_set": entity_set,
            "record_id": safe_record_id,
            "status_code": response.status_code,
        }

    def create_table(self, *, definition: Dict[str, Any]) -> Dict[str, Any]:
        response = self.client.post(
            "EntityDefinitions",
            json_payload=definition,
            headers={
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
            allow_statuses=(200, 201, 204),
        )
        body: Dict[str, Any] = {}
        if response.text:
            try:
                maybe = response.json()
                if isinstance(maybe, dict):
                    body = maybe
            except Exception as exc:
                logger.debug("Dataverse create_table response body was not valid JSON: %s", exc)
                body = {}
        return {
            "status_code": response.status_code,
            "logical_name": body.get("LogicalName") or body.get("logical_name"),
            "body": body,
        }

    def update_table(self, *, table: str, definition: Dict[str, Any]) -> Dict[str, Any]:
        logical_name = str(table or "").strip()
        if not logical_name:
            raise DataverseMCPError("table is required for update_table")
        escaped = _escape_odata_string(logical_name)
        response = self.client.put(
            f"EntityDefinitions(LogicalName='{escaped}')",
            json_payload=definition,
            headers={
                "Content-Type": "application/json",
                "If-Match": "*",
            },
            allow_statuses=(200, 204),
        )
        return {
            "table": logical_name,
            "status_code": response.status_code,
        }

    def delete_table(self, *, table: str) -> Dict[str, Any]:
        logical_name = str(table or "").strip()
        if not logical_name:
            raise DataverseMCPError("table is required for delete_table")
        escaped = _escape_odata_string(logical_name)
        response = self.client.delete(
            f"EntityDefinitions(LogicalName='{escaped}')",
            allow_statuses=(200, 204),
        )
        return {
            "table": logical_name,
            "status_code": response.status_code,
        }


def create_dataverse_client(*, profile: str | None = None) -> DataverseMCPClient:
    creds = load_dataverse_credentials(profile=profile)
    return DataverseMCPClient(creds)


def _stable_json(value: Any) -> str:
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def compute_plan_hash(*, operation: str, payload: Dict[str, Any]) -> str:
    digest = hashlib.sha256()
    digest.update(_stable_json({"operation": operation, "payload": payload}).encode("utf-8"))
    return f"sha256:{digest.hexdigest()}"


def build_plan(*, operation: str, payload: Dict[str, Any]) -> Dict[str, Any]:
    plan_hash = compute_plan_hash(operation=operation, payload=payload)
    return {
        "operation": operation,
        "payload": payload,
        "plan_hash": plan_hash,
    }


def enforce_plan_hash(
    *,
    operation: str,
    payload: Dict[str, Any],
    plan_hash: str,
    tool_name: str,
) -> str:
    supplied = str(plan_hash or "").strip()
    if not supplied:
        raise ValueError(f"plan_hash is required for {tool_name}")
    expected = compute_plan_hash(operation=operation, payload=payload)
    if supplied != expected:
        raise ValueError(
            f"plan_hash mismatch for {tool_name}. "
            f"Expected {expected}; got {supplied}. Re-run the corresponding *.plan tool."
        )
    return supplied


def dataverse_proxy_diagnostics(*, timeout_seconds: int = 8) -> Dict[str, Any]:
    checks: List[Dict[str, Any]] = []
    status = "ok"

    dotnet = shutil.which("dotnet")
    if dotnet:
        checks.append({"name": "dotnet", "status": "ok", "detail": dotnet})
    else:
        checks.append({"name": "dotnet", "status": "warn", "detail": "dotnet not found on PATH"})
        status = "warn"
        return {
            "status": status,
            "proxy_package": DATAVERSE_MCP_DOTNET_PACKAGE,
            "checks": checks,
            "client_setup_notes": DATAVERSE_CLIENT_NOTES,
            "billing_notes": DATAVERSE_BILLING_NOTES,
        }

    try:
        proc = subprocess.run(
            [dotnet, "tool", "list", "-g"],
            check=False,
            capture_output=True,
            text=True,
            timeout=max(int(timeout_seconds), 1),
        )
    except Exception as exc:
        checks.append({"name": "dotnet_tool_list", "status": "warn", "detail": str(exc)})
        status = "warn"
        return {
            "status": status,
            "proxy_package": DATAVERSE_MCP_DOTNET_PACKAGE,
            "checks": checks,
            "client_setup_notes": DATAVERSE_CLIENT_NOTES,
            "billing_notes": DATAVERSE_BILLING_NOTES,
        }

    if proc.returncode != 0:
        detail = (proc.stderr or proc.stdout or "").strip() or f"exit={proc.returncode}"
        checks.append({"name": "dotnet_tool_list", "status": "warn", "detail": detail})
        status = "warn"
        return {
            "status": status,
            "proxy_package": DATAVERSE_MCP_DOTNET_PACKAGE,
            "checks": checks,
            "client_setup_notes": DATAVERSE_CLIENT_NOTES,
            "billing_notes": DATAVERSE_BILLING_NOTES,
        }

    tool_list_output = proc.stdout or ""
    if DATAVERSE_MCP_DOTNET_PACKAGE.lower() in tool_list_output.lower():
        checks.append(
            {
                "name": "dataverse_mcp_proxy",
                "status": "ok",
                "detail": f"{DATAVERSE_MCP_DOTNET_PACKAGE} found in dotnet global tools",
            }
        )
    else:
        checks.append(
            {
                "name": "dataverse_mcp_proxy",
                "status": "warn",
                "detail": (
                    f"{DATAVERSE_MCP_DOTNET_PACKAGE} not found. "
                    "Install with: dotnet tool install --global Microsoft.PowerPlatform.Dataverse.MCP"
                ),
            }
        )
        status = "warn"

    return {
        "status": status,
        "proxy_package": DATAVERSE_MCP_DOTNET_PACKAGE,
        "checks": checks,
        "client_setup_notes": DATAVERSE_CLIENT_NOTES,
        "billing_notes": DATAVERSE_BILLING_NOTES,
    }


__all__ = [
    "DATAVERSE_BILLING_NOTES",
    "DATAVERSE_CLIENT_NOTES",
    "DATAVERSE_MCP_DOTNET_PACKAGE",
    "DYNAMICS_MCP_PROFILE_ENV_VAR",
    "DataverseCredentials",
    "DataverseMCPClient",
    "DataverseMCPError",
    "build_plan",
    "compute_plan_hash",
    "create_dataverse_client",
    "dataverse_proxy_diagnostics",
    "enforce_plan_hash",
    "load_dataverse_credentials",
    "resolve_mcp_profile",
]
