"""Typed discovery models and persistence helpers."""

from __future__ import annotations

import json
import secrets
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal

from pydantic import BaseModel, ConfigDict, Field, computed_field

from g_gremlin.scout.engine import ScoutEngine
from g_gremlin.scout.models import ScoutFinding
from g_gremlin.scout.providers.sfdc_provider import SFDCProvider

if TYPE_CHECKING:
    from .ai_provider import AIProviderConfig


_SUBSTRATE_SCOUTS = {
    "do_not_contact",
    "sequence_collision",
    "customer_open_ticket_guardrail",
    "meeting_followup_breach",
}


def _utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _parse_iso_utc(value: str) -> datetime:
    normalized = value.replace("Z", "+00:00")
    try:
        return datetime.fromisoformat(normalized)
    except ValueError:
        return datetime.min.replace(tzinfo=timezone.utc)


class DiscoveryRun(BaseModel):
    model_config = ConfigDict(extra="ignore")

    run_id: str = Field(default_factory=lambda: secrets.token_hex(8))
    started_at: str = Field(default_factory=_utc_now_iso)
    completed_at: str | None = None
    status: Literal["pending", "running", "completed", "error"] = "pending"
    crm_provider: Literal["salesforce", "hubspot"]
    org_id: str | None = None
    schema_version: str = "1"

    @computed_field(alias="$schema")
    @property
    def schema(self) -> str:
        return "DiscoveryRun/v1"

    def to_dict(self) -> dict[str, Any]:
        return self.model_dump(exclude_none=False, by_alias=True)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "DiscoveryRun":
        return cls.model_validate(data)


class ObjectProfile(BaseModel):
    model_config = ConfigDict(extra="ignore")

    sobject: str
    record_count: int
    key_fields: list[str]
    custom_field_count: int
    last_modified: str | None = None


class FieldMappingSuggestion(BaseModel):
    model_config = ConfigDict(extra="ignore")

    target: str
    source_field: str
    confidence: float = Field(ge=0.0, le=1.0)
    reason: str
    needs_confirmation: bool


class ScoutRecommendation(BaseModel):
    model_config = ConfigDict(extra="ignore")

    scout_name: str
    action: Literal["enable", "configure", "review"]
    rationale: str
    suggested_params: dict[str, Any] = Field(default_factory=dict)
    priority: Literal["high", "medium", "low"]


class ReadinessScore(BaseModel):
    model_config = ConfigDict(extra="ignore")

    overall: float = Field(ge=0.0, le=1.0)
    breakdown: dict[str, float] = Field(default_factory=dict)


class DiscoveryReport(BaseModel):
    model_config = ConfigDict(extra="ignore")

    run_id: str
    schema_version: str = "1"
    object_profiles: list[ObjectProfile] = Field(default_factory=list)
    field_mapping_suggestions: list[FieldMappingSuggestion] = Field(default_factory=list)
    scout_recommendations: list[ScoutRecommendation] = Field(default_factory=list)
    readiness: ReadinessScore
    scout_findings_summary: dict[str, Any] = Field(default_factory=dict)

    @computed_field(alias="$schema")
    @property
    def schema(self) -> str:
        return "DiscoveryReport/v1"

    def to_dict(self) -> dict[str, Any]:
        return self.model_dump(exclude_none=False, by_alias=True)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "DiscoveryReport":
        return cls.model_validate(data)


class DiscoveryStore:
    def __init__(self, state_dir: Path):
        self.state_dir = Path(state_dir)

    def save_run(self, run: DiscoveryRun) -> Path:
        run_dir = self.state_dir / "discovery" / run.run_id
        run_dir.mkdir(parents=True, exist_ok=True)
        run_path = run_dir / "run.json"
        with open(run_path, "w", encoding="utf-8") as f:
            json.dump(run.to_dict(), f, indent=2)
        return run_path

    def save_report(self, report: DiscoveryReport) -> Path:
        run_dir = self.state_dir / "discovery" / report.run_id
        run_dir.mkdir(parents=True, exist_ok=True)
        report_path = run_dir / "report.json"
        with open(report_path, "w", encoding="utf-8") as f:
            json.dump(report.to_dict(), f, indent=2)
        return report_path

    def load_run(self, run_id: str) -> DiscoveryRun | None:
        run_path = self.state_dir / "discovery" / run_id / "run.json"
        if not run_path.exists():
            return None
        with open(run_path, encoding="utf-8") as f:
            payload = json.load(f)
        return DiscoveryRun.from_dict(payload)

    def load_report(self, run_id: str) -> DiscoveryReport | None:
        report_path = self.state_dir / "discovery" / run_id / "report.json"
        if not report_path.exists():
            return None
        with open(report_path, encoding="utf-8") as f:
            payload = json.load(f)
        return DiscoveryReport.from_dict(payload)

    def latest_run_id(self) -> str | None:
        runs = self.list_runs()
        if not runs:
            return None
        return runs[0].run_id

    def list_runs(self) -> list[DiscoveryRun]:
        discovery_dir = self.state_dir / "discovery"
        if not discovery_dir.exists():
            return []

        runs: list[DiscoveryRun] = []
        for run_dir in discovery_dir.iterdir():
            if not run_dir.is_dir():
                continue
            run = self.load_run(run_dir.name)
            if run is not None:
                runs.append(run)

        runs.sort(key=lambda item: _parse_iso_utc(item.started_at), reverse=True)
        return runs


class DiscoveryRunner:
    CORE_OBJECTS: tuple[str, ...] = (
        "Lead",
        "Contact",
        "Account",
        "Opportunity",
        "Case",
        "Task",
        "Event",
    )

    def __init__(
        self,
        store: DiscoveryStore,
        engine: ScoutEngine,
        *,
        crm: str = "salesforce",
        org_alias: str | None = None,
        overrides: dict[str, dict[str, Any]] | None = None,
        ai_config: AIProviderConfig | None = None,
        identity_graph: Any | None = None,
        field_provenance_store: Any | None = None,
    ) -> None:
        self.store = store
        self.engine = engine
        self.crm = (crm or "salesforce").lower()
        self.org_alias = org_alias
        self.overrides = overrides or {}
        self._ai_config = ai_config
        self._identity_graph = identity_graph
        self._field_provenance_store = field_provenance_store
        self._init_substrate_stores()

    def run(self) -> DiscoveryReport:
        run = DiscoveryRun(crm_provider=self.crm, status="running")
        self.store.save_run(run)

        try:
            object_profiles = self._build_object_profiles()
            from .discovery_field_mapper import DiscoveryFieldMapper
            from .discovery_recommender import DiscoveryRecommender

            field_mapping_suggestions = DiscoveryFieldMapper(
                object_profiles,
                crm=self.crm,
            ).suggest()
            if self._ai_enabled():
                from .ai_provider import ai_field_mapping_pass

                ambiguous = [
                    suggestion.model_dump(exclude_none=False)
                    for suggestion in field_mapping_suggestions
                    if suggestion.needs_confirmation
                ]
                if ambiguous:
                    adjustments = ai_field_mapping_pass(
                        self._ai_config,
                        ambiguous,
                        [profile.model_dump(exclude_none=False) for profile in object_profiles],
                    )
                    field_mapping_suggestions = self._apply_ai_adjustments(
                        field_mapping_suggestions,
                        adjustments,
                    )
            scout_summary = self._run_scouts()
            scout_recommendations = DiscoveryRecommender(
                self.engine.list_scouts(),
                object_profiles,
                field_mapping_suggestions,
                scout_summary,
                crm=self.crm,
            ).recommend()
            readiness = self._compute_readiness(object_profiles, scout_summary)

            report = DiscoveryReport(
                run_id=run.run_id,
                object_profiles=object_profiles,
                field_mapping_suggestions=field_mapping_suggestions,
                scout_recommendations=scout_recommendations,
                readiness=readiness,
                scout_findings_summary=scout_summary,
            )
            self.store.save_report(report)

            run.status = "completed"
            run.completed_at = _utc_now_iso()
            self.store.save_run(run)
            return report
        except Exception:
            run.status = "error"
            run.completed_at = _utc_now_iso()
            self.store.save_run(run)
            raise

    def _ai_enabled(self) -> bool:
        return bool(self._ai_config is not None and self._ai_config.enabled)

    @staticmethod
    def _apply_ai_adjustments(
        suggestions: list[FieldMappingSuggestion],
        adjustments: list[dict[str, Any]],
    ) -> list[FieldMappingSuggestion]:
        by_key: dict[tuple[str, str], dict[str, Any]] = {}
        for adjustment in adjustments:
            target = str(adjustment.get("target") or "").strip().lower()
            source_field = str(adjustment.get("source_field") or "").strip().lower()
            if not target or not source_field:
                continue
            by_key[(target, source_field)] = adjustment

        updated: list[FieldMappingSuggestion] = []
        for suggestion in suggestions:
            key = (suggestion.target.lower(), suggestion.source_field.lower())
            adjustment = by_key.get(key)
            if adjustment is None:
                updated.append(suggestion.model_copy(deep=True))
                continue

            try:
                delta = float(adjustment.get("confidence_adjustment", 0.0))
            except Exception:
                delta = 0.0
            confidence = max(0.0, min(1.0, float(suggestion.confidence) + delta))
            rationale = str(adjustment.get("rationale") or "").strip()
            reason = suggestion.reason
            if rationale:
                reason = f"{reason} [AI: {rationale}]"

            updated.append(
                FieldMappingSuggestion(
                    target=suggestion.target,
                    source_field=suggestion.source_field,
                    confidence=confidence,
                    reason=reason,
                    needs_confirmation=False if confidence >= 0.8 else suggestion.needs_confirmation,
                )
            )
        return updated

    def _build_object_profiles(self) -> list[ObjectProfile]:
        if self.crm.lower() != "salesforce":
            # TODO: implement HubSpot discovery object profiling.
            return []

        provider = SFDCProvider(org_alias=self.org_alias)
        profiles: list[ObjectProfile] = []
        for object_name in self.CORE_OBJECTS:
            try:
                describe_payload = provider.describe(object_name)
                record_count = provider.count(object_name, [])
                field_names = self._extract_field_names(describe_payload)
                custom_field_count = sum(
                    1 for field_name in field_names if field_name.lower().endswith("__c")
                )
                profiles.append(
                    ObjectProfile(
                        sobject=object_name,
                        record_count=int(record_count),
                        key_fields=field_names,
                        custom_field_count=custom_field_count,
                        last_modified=None,
                    )
                )
            except Exception:
                continue
        return profiles

    def _run_scouts(self) -> dict[str, Any]:
        enabled_scouts = [
            scout
            for scout in self.engine.list_scouts()
            if scout.enabled and self._supports_crm(scout.supported_crms)
        ]
        scout_names = [scout.name for scout in enabled_scouts]

        if not scout_names:
            return {}

        categories = {scout.name: scout.category for scout in enabled_scouts}
        summary: dict[str, Any] = {
            scout_name: {
                "status": "error",
                "findings_count": 0,
                "total_records_scanned": 0,
                "warning_count": 0,
                "category": categories.get(scout_name),
            }
            for scout_name in scout_names
        }

        for scout_name in scout_names:
            params = dict(self.overrides.get(scout_name, {}))
            try:
                finding = self.engine.run_scout(
                    scout_name,
                    params=params,
                    top_n=20,
                    org_alias=self.org_alias,
                    crm=self.crm,
                )
            except Exception:
                continue
            finding = self._coerce_finding(finding)
            self._record_substrate_observations(finding)
            summary[finding.scout_name] = {
                "status": finding.status,
                "findings_count": int(finding.findings_count),
                "total_records_scanned": int(finding.total_records_scanned),
                "warning_count": len(finding.warnings or []),
                "category": finding.category or categories.get(finding.scout_name),
            }
        return summary

    def _compute_readiness(
        self, profiles: list[ObjectProfile], scout_summary: dict[str, Any]
    ) -> ReadinessScore:
        total_objects = len(self.CORE_OBJECTS)
        objects_with_data = len([profile for profile in profiles if profile.record_count > 0])
        data_coverage = (objects_with_data / total_objects) if total_objects else 0.0

        total_scouts = len(scout_summary)
        healthy_statuses = {"ok", "findings"}
        healthy_scouts = len(
            [
                payload
                for payload in scout_summary.values()
                if str(payload.get("status", "")).lower() in healthy_statuses
            ]
        )
        scout_readiness = (healthy_scouts / total_scouts) if total_scouts else 0.0

        overall = (0.4 * data_coverage) + (0.6 * scout_readiness)
        return ReadinessScore(
            overall=overall,
            breakdown={
                "data_coverage": data_coverage,
                "scout_readiness": scout_readiness,
            },
        )

    def _supports_crm(self, supported_crms: list[str]) -> bool:
        if not supported_crms:
            return True
        crm = self.crm.lower()
        normalized = {entry.lower() for entry in supported_crms}
        if crm == "salesforce":
            return "salesforce" in normalized or "sfdc" in normalized
        return crm in normalized

    @staticmethod
    def _extract_field_names(describe_payload: dict[str, Any]) -> list[str]:
        raw_fields: list[dict[str, Any]] = []
        if isinstance(describe_payload.get("result"), dict):
            fields = describe_payload.get("result", {}).get("fields")
            if isinstance(fields, list):
                raw_fields = [item for item in fields if isinstance(item, dict)]
        elif isinstance(describe_payload.get("fields"), list):
            raw_fields = [item for item in describe_payload.get("fields", []) if isinstance(item, dict)]

        names: list[str] = []
        seen: set[str] = set()
        for item in raw_fields:
            name = str(item.get("name") or "").strip()
            if not name or name in seen:
                continue
            names.append(name)
            seen.add(name)
        return names

    @staticmethod
    def _coerce_finding(finding: ScoutFinding) -> ScoutFinding:
        return finding

    def _init_substrate_stores(self) -> None:
        if self._identity_graph is not None and self._field_provenance_store is not None:
            return
        db_path = self.store.state_dir / "db" / "platform.sqlite"
        if self._identity_graph is None:
            try:
                from .identity_graph import IdentityGraph

                self._identity_graph = IdentityGraph(db_path)
            except Exception:
                self._identity_graph = None
        if self._field_provenance_store is None:
            try:
                from .field_provenance import FieldProvenanceStore

                self._field_provenance_store = FieldProvenanceStore(db_path)
            except Exception:
                self._field_provenance_store = None

    def _record_substrate_observations(self, finding: ScoutFinding) -> None:
        if finding.scout_name not in _SUBSTRATE_SCOUTS:
            return
        if self._identity_graph is None or self._field_provenance_store is None:
            return

        source_system = self.crm or "salesforce"
        actor = f"scout:{finding.scout_name}"
        observations: list[dict[str, Any]] = []

        try:
            evidence_rows = list(finding.evidence or [])
        except Exception:
            evidence_rows = []
        for row in evidence_rows:
            record_id = str(getattr(row, "record_id", "") or "").strip()
            if not record_id:
                continue
            source_object = str(getattr(row, "sobject", "") or "Lead").strip() or "Lead"
            raw_fields = getattr(row, "fields", {})
            fields: dict[str, Any] = raw_fields if isinstance(raw_fields, dict) else {}

            email = str(fields.get("Email") or fields.get("email") or "").strip().lower()
            domain = self._domain_from_email(email)
            canonical_name = str(
                fields.get("Name")
                or fields.get("name")
                or fields.get("Company")
                or fields.get("company")
                or fields.get("Subject")
                or fields.get("subject")
                or ""
            ).strip()
            anchored_object, anchored_record_id, entity_type, match_method, confidence = self._substrate_anchor(
                scout_name=finding.scout_name,
                source_object=source_object,
                fallback_record_id=record_id,
                fields=fields,
            )
            if not anchored_record_id:
                continue
            try:
                entity_id = self._identity_graph.link_record(
                    source_system=source_system,
                    source_object=anchored_object,
                    source_record_id=anchored_record_id,
                    source_org_id=self.org_alias,
                    entity_type=entity_type,
                    canonical_name=canonical_name,
                    canonical_domain=domain,
                    canonical_email=email,
                    confidence=confidence,
                    match_method=match_method,
                    linked_by=actor,
                )
            except Exception:
                continue

            scalar_fields = self._extract_scalar_fields(fields)
            if email:
                scalar_fields.setdefault("email", email)
            if domain:
                scalar_fields.setdefault("domain", domain)

            reason_code = str(getattr(row, "reason_code", "") or "").strip()
            if reason_code:
                scalar_fields.setdefault("reason_code", reason_code)
            severity = str(getattr(row, "severity", "") or "").strip().lower()
            if severity:
                scalar_fields.setdefault("severity", severity)

            for field_name, field_value in scalar_fields.items():
                observations.append(
                    {
                        "entity_id": entity_id,
                        "source_system": source_system,
                        "source_object": anchored_object,
                        "source_record_id": anchored_record_id,
                        "field_name": self._normalize_field_name(field_name),
                        "field_value": field_value,
                        "confidence": confidence,
                        "updated_by": actor,
                    }
                )

        if not observations:
            return
        try:
            self._field_provenance_store.record_observations_batch(observations)
        except Exception:
            return

    @staticmethod
    def _extract_scalar_fields(values: dict[str, Any]) -> dict[str, Any]:
        result: dict[str, Any] = {}
        for field_name, field_value in values.items():
            if field_value is None:
                continue
            if isinstance(field_value, str):
                normalized = field_value.strip()
                if not normalized:
                    continue
                result[str(field_name)] = normalized
                continue
            if isinstance(field_value, (int, float, bool)):
                result[str(field_name)] = field_value
                continue
        return result

    @staticmethod
    def _normalize_field_name(field_name: str) -> str:
        text = str(field_name or "").strip().lower()
        if not text:
            return "unknown_field"
        normalized = []
        for char in text:
            if char.isalnum():
                normalized.append(char)
            else:
                normalized.append("_")
        compact = "".join(normalized)
        while "__" in compact:
            compact = compact.replace("__", "_")
        return compact.strip("_") or "unknown_field"

    @staticmethod
    def _domain_from_email(email: str) -> str:
        if "@" not in email:
            return ""
        return email.split("@", 1)[1].strip().lower()

    @staticmethod
    def _substrate_anchor(
        *,
        scout_name: str,
        source_object: str,
        fallback_record_id: str,
        fields: dict[str, Any],
    ) -> tuple[str, str, str, str, float]:
        account_id = str(fields.get("AccountId") or fields.get("account_id") or "").strip()
        if account_id:
            return "Account", account_id, "account", "account_id", 0.95

        if scout_name == "meeting_followup_breach":
            who_id = str(fields.get("WhoId") or fields.get("who_id") or "").strip()
            if who_id:
                inferred_object = "Contact" if who_id.startswith("003") else source_object
                return inferred_object, who_id, "person", "who_id", 0.8
            what_id = str(fields.get("WhatId") or fields.get("what_id") or "").strip()
            if what_id:
                if what_id.startswith("001"):
                    return "Account", what_id, "account", "what_id", 0.8
                inferred_object = "Opportunity" if what_id.startswith("006") else source_object
                return inferred_object, what_id, "activity", "what_id", 0.75

        email = str(fields.get("Email") or fields.get("email") or "").strip().lower()
        if email:
            return source_object, fallback_record_id, "person", "exact_email", 1.0
        return source_object, fallback_record_id, "activity", "record_id", 0.65


__all__ = [
    "DiscoveryRun",
    "ObjectProfile",
    "FieldMappingSuggestion",
    "ScoutRecommendation",
    "ReadinessScore",
    "DiscoveryReport",
    "DiscoveryStore",
    "DiscoveryRunner",
]
