"""Entity-centric orchestration views backed by substrate + run artifacts."""

from __future__ import annotations

import json
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

from .field_provenance import FieldProvenanceStore
from .identity_graph import IdentityGraph


def _utc_now() -> datetime:
    return datetime.now(timezone.utc)


def _parse_iso(value: Any) -> datetime | None:
    if not isinstance(value, str) or not value.strip():
        return None
    raw = value.strip()
    if raw.endswith("Z"):
        raw = raw[:-1] + "+00:00"
    try:
        parsed = datetime.fromisoformat(raw)
    except Exception:
        return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def _iso(value: datetime) -> str:
    return value.astimezone(timezone.utc).isoformat()


def _safe_json(path: Path) -> dict[str, Any] | None:
    if not path.exists():
        return None
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return None
    return payload if isinstance(payload, dict) else None


def _normalize_text(value: Any) -> str:
    return str(value or "").strip()


def _collect_step_record_ids(step: dict[str, Any]) -> set[str]:
    args = step.get("args")
    if not isinstance(args, dict):
        return set()
    record_ids: set[str] = set()
    raw_ids = args.get("record_ids")
    if isinstance(raw_ids, list):
        for item in raw_ids:
            record_id = _normalize_text(item)
            if record_id:
                record_ids.add(record_id)
    rows = args.get("records")
    if isinstance(rows, list):
        for row in rows:
            if not isinstance(row, dict):
                continue
            record_id = _normalize_text(row.get("record_id"))
            if record_id:
                record_ids.add(record_id)
    return record_ids


def _collect_targets(change: dict[str, Any]) -> set[str]:
    raw_targets = change.get("targets")
    if not isinstance(raw_targets, list):
        return set()
    values: set[str] = set()
    for item in raw_targets:
        target = _normalize_text(item)
        if target:
            values.add(target)
    return values


def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
    row = conn.execute(
        """
        SELECT name
        FROM sqlite_master
        WHERE type = 'table' AND name = ?
        LIMIT 1
        """,
        (table_name,),
    ).fetchone()
    return row is not None


class EntityViewService:
    """Aggregates identity, provenance, and run history into entity views."""

    def __init__(self, state_dir: Path | str):
        self.state_dir = Path(state_dir).resolve()
        self.db_path = self.state_dir / "db" / "platform.sqlite"
        self.identity = IdentityGraph(self.db_path)
        self.provenance = FieldProvenanceStore(self.db_path)

    def lookup_entity(
        self,
        *,
        source_system: str | None = None,
        source_record_id: str | None = None,
        source_org_id: str | None = None,
        email: str | None = None,
        domain: str | None = None,
    ) -> dict[str, Any] | None:
        normalized_source_system = _normalize_text(source_system).lower()
        normalized_record_id = _normalize_text(source_record_id)
        normalized_org_id = _normalize_text(source_org_id).lower() or None
        normalized_email = _normalize_text(email)
        normalized_domain = _normalize_text(domain)

        entity: dict[str, Any] | None = None
        if normalized_source_system and normalized_record_id:
            entity = self.identity.find_entity_by_record(
                normalized_source_system,
                normalized_record_id,
                normalized_org_id,
            )
        elif normalized_email:
            entity = self.identity.find_entity_by_email(normalized_email)
        elif normalized_domain:
            entity = self.identity.find_entity_by_domain(normalized_domain)

        if not entity:
            return None
        return self._entity_search_projection(entity)

    def search_entities(
        self,
        *,
        query: str | None = None,
        source_system: str | None = None,
        limit: int = 20,
    ) -> list[dict[str, Any]]:
        bounded_limit = max(1, min(int(limit), 200))
        normalized_query = _normalize_text(query).lower()
        normalized_source_system = _normalize_text(source_system).lower()
        like = f"%{normalized_query}%"

        if not self.db_path.exists():
            return []

        with sqlite3.connect(self.db_path) as conn:
            conn.row_factory = sqlite3.Row
            if not _table_exists(conn, "entities"):
                return []
            rows = conn.execute(
                """
                SELECT
                    e.entity_id,
                    e.entity_type,
                    e.canonical_name,
                    e.canonical_domain,
                    e.canonical_email,
                    e.created_at,
                    e.updated_at,
                    COUNT(l.link_id) AS link_count,
                    GROUP_CONCAT(DISTINCT l.source_system) AS source_systems
                FROM entities AS e
                LEFT JOIN entity_links AS l ON l.entity_id = e.entity_id
                WHERE
                    (
                        ? = ''
                        OR LOWER(COALESCE(e.entity_id, '')) LIKE ?
                        OR LOWER(COALESCE(e.canonical_name, '')) LIKE ?
                        OR LOWER(COALESCE(e.canonical_domain, '')) LIKE ?
                        OR LOWER(COALESCE(e.canonical_email, '')) LIKE ?
                        OR LOWER(COALESCE(l.source_record_id, '')) LIKE ?
                    )
                    AND (? = '' OR LOWER(COALESCE(l.source_system, '')) = ?)
                GROUP BY
                    e.entity_id,
                    e.entity_type,
                    e.canonical_name,
                    e.canonical_domain,
                    e.canonical_email,
                    e.created_at,
                    e.updated_at
                ORDER BY e.updated_at DESC, e.entity_id ASC
                LIMIT ?
                """,
                (
                    normalized_query,
                    like,
                    like,
                    like,
                    like,
                    like,
                    normalized_source_system,
                    normalized_source_system,
                    bounded_limit,
                ),
            ).fetchall()

        results: list[dict[str, Any]] = []
        for row in rows:
            source_systems = str(row["source_systems"] or "").strip()
            systems = [value for value in source_systems.split(",") if value] if source_systems else []
            results.append(
                {
                    "entity_id": row["entity_id"],
                    "entity_type": row["entity_type"],
                    "canonical_name": row["canonical_name"],
                    "canonical_domain": row["canonical_domain"],
                    "canonical_email": row["canonical_email"],
                    "created_at": row["created_at"],
                    "updated_at": row["updated_at"],
                    "link_count": int(row["link_count"] or 0),
                    "source_systems": sorted(set(systems)),
                }
            )
        return results

    def get_entity_view(
        self,
        entity_id: str,
        *,
        lookback_days: int = 30,
        activity_limit: int = 50,
        provenance_limit: int = 500,
    ) -> dict[str, Any] | None:
        normalized_entity_id = _normalize_text(entity_id)
        if not normalized_entity_id:
            return None

        entity = self._load_entity(normalized_entity_id)
        if not entity:
            return None

        linked_records = self.identity.get_linked_records(normalized_entity_id)
        linked_record_ids = {
            _normalize_text(record.get("source_record_id"))
            for record in linked_records
            if isinstance(record, dict)
        }
        linked_record_ids.discard("")

        observations = self.provenance.list_entity_observations(
            normalized_entity_id,
            limit=max(1, min(int(provenance_limit), 5000)),
        )
        fields = self._summarize_provenance_fields(normalized_entity_id, observations)
        timeline = self._build_timeline(
            normalized_entity_id,
            linked_record_ids=linked_record_ids,
            lookback_days=lookback_days,
            limit=activity_limit,
        )

        source_systems = sorted(
            {
                _normalize_text(record.get("source_system")).lower()
                for record in linked_records
                if isinstance(record, dict)
            }
            - {""}
        )

        return {
            "entity": entity,
            "linked_records": linked_records,
            "provenance": {
                "observations": observations,
                "fields": fields,
            },
            "timeline": timeline,
            "summary": {
                "linked_record_count": len(linked_records),
                "source_systems": source_systems,
                "provenance_field_count": len(fields),
                "timeline_event_count": len(timeline),
            },
        }

    def _entity_search_projection(self, entity: dict[str, Any]) -> dict[str, Any]:
        entity_id = _normalize_text(entity.get("entity_id"))
        linked_records = self.identity.get_linked_records(entity_id) if entity_id else []
        source_systems = sorted(
            {
                _normalize_text(record.get("source_system")).lower()
                for record in linked_records
                if isinstance(record, dict)
            }
            - {""}
        )
        return {
            "entity_id": entity.get("entity_id"),
            "entity_type": entity.get("entity_type"),
            "canonical_name": entity.get("canonical_name"),
            "canonical_domain": entity.get("canonical_domain"),
            "canonical_email": entity.get("canonical_email"),
            "created_at": entity.get("created_at"),
            "updated_at": entity.get("updated_at"),
            "link_count": len(linked_records),
            "source_systems": source_systems,
        }

    def _load_entity(self, entity_id: str) -> dict[str, Any] | None:
        if not self.db_path.exists():
            return None
        with sqlite3.connect(self.db_path) as conn:
            conn.row_factory = sqlite3.Row
            if not _table_exists(conn, "entities"):
                return None
            row = conn.execute(
                """
                SELECT entity_id, entity_type, canonical_name, canonical_domain, canonical_email, created_at, updated_at
                FROM entities
                WHERE entity_id = ?
                LIMIT 1
                """,
                (entity_id,),
            ).fetchone()
            if row is None:
                return None
            return {key: row[key] for key in row.keys()}

    def _summarize_provenance_fields(
        self,
        entity_id: str,
        observations: list[dict[str, Any]],
    ) -> list[dict[str, Any]]:
        by_field: dict[str, list[dict[str, Any]]] = {}
        for item in observations:
            if not isinstance(item, dict):
                continue
            field_name = _normalize_text(item.get("field_name")).lower()
            if not field_name:
                continue
            by_field.setdefault(field_name, []).append(item)

        fields: list[dict[str, Any]] = []
        for field_name in sorted(by_field.keys()):
            rows = by_field[field_name]
            source_systems = sorted(
                {
                    _normalize_text(row.get("source_system")).lower()
                    for row in rows
                    if isinstance(row, dict)
                }
                - {""}
            )
            unique_values = {
                _normalize_text(row.get("field_value"))
                for row in rows
                if _normalize_text(row.get("field_value"))
            }
            authoritative = self.provenance.get_authoritative_value(entity_id, field_name)
            fields.append(
                {
                    "field_name": field_name,
                    "observation_count": len(rows),
                    "source_systems": source_systems,
                    "last_observed_at": max(
                        [_normalize_text(row.get("last_observed_at")) for row in rows],
                        default="",
                    )
                    or None,
                    "has_drift": len(unique_values) > 1,
                    "authoritative": authoritative,
                }
            )
        return fields

    def _build_timeline(
        self,
        entity_id: str,
        *,
        linked_record_ids: set[str],
        lookback_days: int,
        limit: int,
    ) -> list[dict[str, Any]]:
        bounded_limit = max(1, min(int(limit), 200))
        window_start = _utc_now() - timedelta(days=max(1, min(int(lookback_days), 3650)))
        receipts_dir = self.state_dir / "receipts"
        if not receipts_dir.exists():
            return []

        run_dirs = sorted(
            [path for path in receipts_dir.iterdir() if path.is_dir()],
            key=lambda item: item.stat().st_mtime,
            reverse=True,
        )

        events: list[dict[str, Any]] = []
        for run_dir in run_dirs:
            if len(events) >= bounded_limit:
                break
            scan_payload = _safe_json(run_dir / "scan.json")
            plan_payload = _safe_json(run_dir / "plan.json")
            receipt_payload = _safe_json(run_dir / "receipt.json")
            if scan_payload is None and plan_payload is None and receipt_payload is None:
                continue

            occurred_at = self._run_timestamp(scan_payload, receipt_payload, run_dir)
            if occurred_at < window_start:
                continue

            run_record_ids = self._run_record_ids(scan_payload, plan_payload, receipt_payload)
            matched_record_ids = sorted(set(run_record_ids).intersection(linked_record_ids))
            if not matched_record_ids and not self._run_mentions_entity_id(
                entity_id,
                scan_payload=scan_payload,
                plan_payload=plan_payload,
            ):
                continue

            scouts = self._extract_scout_entries(
                scan_payload,
                entity_id=entity_id,
                linked_record_ids=linked_record_ids,
            )
            actions = self._extract_action_entries(
                receipt_payload,
                linked_record_ids=linked_record_ids,
            )
            if not scouts and not actions:
                continue

            events.append(
                {
                    "run_id": run_dir.name,
                    "occurred_at": _iso(occurred_at),
                    "outcome": _normalize_text((receipt_payload or {}).get("outcome")) or None,
                    "matched_record_ids": matched_record_ids,
                    "scouts": scouts,
                    "actions": actions,
                }
            )
        return events

    def _run_timestamp(
        self,
        scan_payload: dict[str, Any] | None,
        receipt_payload: dict[str, Any] | None,
        run_dir: Path,
    ) -> datetime:
        for raw in (
            (receipt_payload or {}).get("completed_at"),
            (receipt_payload or {}).get("started_at"),
            (scan_payload or {}).get("ended_at"),
            (scan_payload or {}).get("started_at"),
        ):
            parsed = _parse_iso(raw)
            if parsed is not None:
                return parsed
        return datetime.fromtimestamp(run_dir.stat().st_mtime, tz=timezone.utc)

    def _run_record_ids(
        self,
        scan_payload: dict[str, Any] | None,
        plan_payload: dict[str, Any] | None,
        receipt_payload: dict[str, Any] | None,
    ) -> set[str]:
        record_ids: set[str] = set()
        findings = (scan_payload or {}).get("findings")
        if isinstance(findings, list):
            for finding in findings:
                if not isinstance(finding, dict):
                    continue
                evidence = finding.get("evidence")
                if not isinstance(evidence, list):
                    continue
                for row in evidence:
                    if not isinstance(row, dict):
                        continue
                    record_id = _normalize_text(row.get("record_id"))
                    if record_id:
                        record_ids.add(record_id)

        steps = (plan_payload or {}).get("steps")
        if isinstance(steps, list):
            for step in steps:
                if not isinstance(step, dict):
                    continue
                record_ids.update(_collect_step_record_ids(step))

        changes = (receipt_payload or {}).get("changes")
        if isinstance(changes, list):
            for change in changes:
                if not isinstance(change, dict):
                    continue
                record_ids.update(_collect_targets(change))

        return record_ids

    def _run_mentions_entity_id(
        self,
        entity_id: str,
        *,
        scan_payload: dict[str, Any] | None,
        plan_payload: dict[str, Any] | None,
    ) -> bool:
        normalized_entity_id = _normalize_text(entity_id).lower()
        if not normalized_entity_id:
            return False

        findings = (scan_payload or {}).get("findings")
        if isinstance(findings, list):
            for finding in findings:
                if not isinstance(finding, dict):
                    continue
                if _normalize_text(finding.get("entity_id")).lower() == normalized_entity_id:
                    return True
                evidence = finding.get("evidence")
                if not isinstance(evidence, list):
                    continue
                for row in evidence:
                    if not isinstance(row, dict):
                        continue
                    if _normalize_text(row.get("entity_id")).lower() == normalized_entity_id:
                        return True

        steps = (plan_payload or {}).get("steps")
        if isinstance(steps, list):
            for step in steps:
                if not isinstance(step, dict):
                    continue
                args = step.get("args")
                if not isinstance(args, dict):
                    continue
                if _normalize_text(args.get("entity_id")).lower() == normalized_entity_id:
                    return True
                rows = args.get("records")
                if not isinstance(rows, list):
                    continue
                for row in rows:
                    if not isinstance(row, dict):
                        continue
                    if _normalize_text(row.get("entity_id")).lower() == normalized_entity_id:
                        return True
        return False

    def _extract_scout_entries(
        self,
        scan_payload: dict[str, Any] | None,
        *,
        entity_id: str,
        linked_record_ids: set[str],
    ) -> list[dict[str, Any]]:
        findings = (scan_payload or {}).get("findings")
        if not isinstance(findings, list):
            return []

        output: list[dict[str, Any]] = []
        normalized_entity_id = _normalize_text(entity_id).lower()
        for finding in findings:
            if not isinstance(finding, dict):
                continue
            scout_name = _normalize_text(finding.get("scout_name"))
            if not scout_name:
                continue
            evidence = finding.get("evidence")
            matches = False
            reason_codes: list[str] = []
            if isinstance(evidence, list):
                for row in evidence:
                    if not isinstance(row, dict):
                        continue
                    record_id = _normalize_text(row.get("record_id"))
                    row_entity_id = _normalize_text(row.get("entity_id")).lower()
                    if record_id in linked_record_ids or row_entity_id == normalized_entity_id:
                        matches = True
                    reason_code = _normalize_text(row.get("reason_code"))
                    if reason_code and reason_code not in reason_codes:
                        reason_codes.append(reason_code)
            if not matches and _normalize_text(finding.get("entity_id")).lower() != normalized_entity_id:
                continue
            output.append(
                {
                    "scout_name": scout_name,
                    "severity": _normalize_text(finding.get("severity")).lower() or "unknown",
                    "findings_count": int(finding.get("findings_count") or 0),
                    "reason_codes": reason_codes,
                }
            )
        return output

    def _extract_action_entries(
        self,
        receipt_payload: dict[str, Any] | None,
        *,
        linked_record_ids: set[str],
    ) -> list[dict[str, Any]]:
        changes = (receipt_payload or {}).get("changes")
        if not isinstance(changes, list):
            return []

        entries: list[dict[str, Any]] = []
        for change in changes:
            if not isinstance(change, dict):
                continue
            tool = _normalize_text(change.get("tool"))
            if not tool:
                continue
            targets = _collect_targets(change)
            if targets:
                matched = sorted(set(targets).intersection(linked_record_ids))
                if not matched:
                    continue
            else:
                matched = []
            entries.append(
                {
                    "tool": tool,
                    "target_count": len(targets),
                    "matched_targets": matched[:10],
                }
            )
        return entries


__all__ = ["EntityViewService"]
