"""Telegram channel repository — typed CRUD with junction-table assignments.

Channels live in ``telegram_channels``.  Assignment state lives exclusively
in the ``channel_accounts`` junction table (one account per type per channel).
"""

from __future__ import annotations

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

from repo_core import BaseRepository
from repo_core.domains.telegram.channel.model import TelegramChannel

logger = logging.getLogger(__name__)


class TelegramChannelRepository(BaseRepository[TelegramChannel]):
    """CRUD for ``telegram_channels``.

    PostgreSQL-only — channel metadata for analytics is resolved at the
    service layer by batch-fetching from the primary database.

    Usage::

        repo = TelegramChannelRepository(model=TelegramChannel)
        async with repo:
            channels, total = await repo.list_channels(limit=50, offset=0)
    """

    TABLE = "telegram_channels"
    _backends = {"db"}

    # ── Channel CRUD ──────────────────────────────────────────────────────

    async def create_channel(self, data: dict[str, Any]) -> TelegramChannel:
        """Insert a new channel row."""
        data.setdefault("id", uuid.uuid4())
        await self.emit("before_create", data)
        created = await self.insert_one(self.TABLE, data, returning=["*"])
        await self.emit("after_create", created)
        return created

    async def upsert_channel(self, data: dict[str, Any]) -> TelegramChannel:
        """Insert or update a channel by its Telegram ``channel_id``.

        Uses the ``save`` method which upserts based on the id field.
        First looks up by ``channel_id`` to preserve the existing UUID
        if the channel already exists.

        Emits ``after_create`` for new channels and ``after_update`` for
        existing ones.
        """
        existing = await self.get_by_channel_id(data["channel_id"])
        is_new = existing is None

        if not is_new:
            data["id"] = existing.id
            data.setdefault("title", existing.title)
        else:
            data.setdefault("id", uuid.uuid4())

        # Ensure non-nullable fields with defaults are never None
        data.setdefault("total_posts_analyzed", 0)
        data.setdefault("backfill_threshold", 30)

        if is_new:
            await self.emit("before_create", data)
            result = await self.save(self.TABLE, data, returning=["*"])
            await self.emit("after_create", result)
        else:
            await self.emit("before_save", data)
            result = await self.save(self.TABLE, data, returning=["*"])
            await self.emit("after_update", result)
        return result

    async def get_by_id(self, channel_id: uuid.UUID) -> TelegramChannel | None:
        """Get a channel by its primary key."""
        return await self.find_by_id(self.TABLE, channel_id)

    async def get_by_channel_id(self, channel_id: int) -> TelegramChannel | None:
        """Get a channel by its numeric Telegram channel ID."""
        return await self.first(self.TABLE, {"channel_id": channel_id})

    async def list_channels(
        self,
        *,
        kind: str | None = None,
        assigned: bool | None = None,
        search: str | None = None,
        account_id: uuid.UUID | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> tuple[list[dict[str, Any]], int]:
        """List channels with optional filters.

        Returns enriched dicts that include account info from the
        ``channel_accounts`` junction table joined to ``telegram_accounts``.

        The deprecated ``account_id`` FK on ``telegram_channels`` is
        **not** consulted — all assignment lookups go through the junction.
        """
        qb = self.table(self.TABLE)

        if kind is not None:
            qb = qb.where("kind", kind)

        # ── Assigned / unassigned filter via junction ──────────────
        if assigned is True or account_id is not None:
            # Get channel IDs that have active assignments
            jqb = self.table(self.JUNCTION).where("is_active", True)
            if account_id is not None:
                jqb = jqb.where("account_id", account_id)
            junction_rows = await jqb.select("channel_id").all()
            assigned_channel_ids = {r["channel_id"] for r in junction_rows}
            if not assigned_channel_ids:
                return [], 0
            # Use raw where_in on the channel table
            all_rows: list[dict[str, Any]] = await qb.order_by("-updated_at").all()
            all_rows = [r for r in all_rows if r.get("id") in assigned_channel_ids]
        elif assigned is False:
            # Get channel IDs that have NO active assignments
            j_rows = await self.table(self.JUNCTION) \
                .where("is_active", True) \
                .select("channel_id") \
                .all()
            assigned_ids = {r["channel_id"] for r in j_rows}
            all_rows: list[dict[str, Any]] = await qb.order_by("-updated_at").all()
            all_rows = [r for r in all_rows if r.get("id") not in assigned_ids]
        else:
            all_rows: list[dict[str, Any]] = await qb.order_by("-updated_at").all()

        # ── Enrich with junction-based account info ─────────────────
        # Batch-fetch all junction rows for all channels in one pass
        all_channel_db_ids = [r["id"] for r in all_rows]
        junction_map: dict[str, list[dict[str, Any]]] = {}
        if all_channel_db_ids:
            all_junctions = await self.table(self.JUNCTION) \
                .where("is_active", True) \
                .all()
            for j in all_junctions:
                cid = str(j["channel_id"])
                junction_map.setdefault(cid, []).append(j)

        # Batch-fetch all accounts referenced by junctions
        account_ids_in_use: set[str] = set()
        for junctions in junction_map.values():
            for j in junctions:
                account_ids_in_use.add(str(j["account_id"]))
        account_map: dict[str, dict[str, Any]] = {}
        if account_ids_in_use:
            for aid in account_ids_in_use:
                acc = await self.table("telegram_accounts").where("id", aid).first()
                if acc:
                    account_map[aid] = acc

        enriched: list[dict[str, Any]] = []
        for row in all_rows:
            row_dict = dict(row)
            cid = str(row_dict.get("id", ""))

            assigned_accounts: list[dict[str, Any]] = []
            for j in junction_map.get(cid, []):
                aid = str(j["account_id"])
                acc = account_map.get(aid)
                if acc:
                    assigned_accounts.append({
                        "account_id": aid,
                        "phone_number": acc.get("phone_number", ""),
                        "username": acc.get("username"),
                        "status": acc.get("status", "unknown"),
                        "account_type": j.get("account_type") or acc.get("account_type") or "crawler",
                        "assigned_at": j.get("assigned_at"),
                    })
            row_dict["assigned_accounts"] = assigned_accounts
            row_dict["assigned_count"] = len(assigned_accounts)

            # Search filter (case-insensitive)
            if search:
                s = search.lower().strip()
                title = str(row_dict.get("title", "")).lower()
                username = str(row_dict.get("username", "")).lower()
                cid_str = str(row_dict.get("channel_id", 0)).lower()
                account_match = any(
                    s in str(a.get("phone_number", "")).lower()
                    or s in str(a.get("username", "") or "").lower()
                    for a in row_dict["assigned_accounts"]
                )
                if s not in title and s not in username and s not in cid_str and not account_match:
                    continue

            enriched.append(row_dict)

        total = len(enriched)
        start = max(0, min(offset, total))
        end = min(start + max(1, limit), total)
        page = enriched[start:end]

        return page, total

    async def update_channel(
        self, channel_id: uuid.UUID, values: dict[str, Any],
    ) -> TelegramChannel | None:
        """Update channel fields by primary key."""
        # Serialize dict/list values to JSON strings for PostgreSQL JSONB columns
        for key, val in values.items():
            if isinstance(val, (dict, list)):
                values[key] = json.dumps(val)
        updated = await self.update_by_id(self.TABLE, channel_id, values)
        if updated is not None:
            await self.emit("after_update", updated)
        return updated

    async def delete_channel(self, channel_id: uuid.UUID) -> int:
        """Delete a channel by primary key."""
        channel = await self.get_by_id(channel_id)
        await self.emit("before_delete", channel_id)
        result = await self.remove_by_id(self.TABLE, channel_id)
        if channel is not None:
            await self.emit("after_delete", channel)
        return result

    # ── Assignment (channel_accounts junction table) ────────────────────

    JUNCTION = "channel_accounts"

    async def assign_channel(
        self,
        channel_db_id: uuid.UUID,
        account_id: uuid.UUID,
        *,
        assigned_by: str | None = None,
    ) -> dict[str, Any]:
        """Assign a channel to an account via the ``channel_accounts`` junction.

        Derives ``account_type`` from the account row.  The UNIQUE constraint
        on ``(channel_id, account_type)`` prevents two accounts of the same
        type from being assigned to the same channel.

        The deprecated ``account_id`` FK on ``telegram_channels`` is
        **not** updated — all assignment state lives in the junction table.
        """
        now = datetime.now(timezone.utc)

        # Validate account exists and read its type
        account = await self.table("telegram_accounts").where("id", account_id).first()
        if account is None:
            raise ValueError(f"Account '{account_id}' not found")

        acc_status = account.get("status", "")
        if acc_status in ("banned", "deleted"):
            raise ValueError(f"Account status '{acc_status}' prevents assignment")

        account_type = account.get("account_type", "crawler")

        # Manager accounts (account_type='manager') must never be assigned
        # to channels via the junction table.
        if account_type == "manager":
            raise ValueError(
                "Manager accounts (account_type='manager') cannot be "
                "assigned to channels."
            )

        # A downloader requires a crawler to already be assigned to the
        # channel.  Crawlers discover & scrape messages; downloaders only
        # download media for channels that are already being scraped.
        if account_type == "downloader":
            has_crawler = await self.table(self.JUNCTION) \
                .where("channel_id", channel_db_id) \
                .where("account_type", "crawler") \
                .where("is_active", True) \
                .first()
            if not has_crawler:
                raise ValueError(
                    "Cannot assign a downloader to a channel that has no "
                    "crawler.  Assign a crawler first."
                )

        # Check for existing assignment of the SAME account to this channel
        existing_same_account = await self.table(self.JUNCTION) \
            .where("channel_id", channel_db_id) \
            .where("account_id", account_id) \
            .first()
        if existing_same_account:
            return {
                "channel_id": channel_db_id,
                "status": "already_assigned",
                "account_id": account_id,
                "account_type": account_type,
            }

        # Check for existing assignment of the SAME TYPE (different account)
        # to this channel — the UNIQUE(channel_id, account_type) constraint
        # enforces one account per type per channel (e.g. one crawler AND
        # one downloader, but NOT two crawlers).
        existing_same_type = await self.table(self.JUNCTION) \
            .where("channel_id", channel_db_id) \
            .where("account_type", account_type) \
            .where("is_active", True) \
            .first()
        if existing_same_type:
            existing_account_id = existing_same_type.get("account_id")
            return {
                "channel_id": channel_db_id,
                "status": "already_assigned",
                "account_id": account_id,
                "account_type": account_type,
                "message": (
                    f"Channel already has a '{account_type}' account assigned "
                    f"(account {existing_account_id}). "
                    f"You can assign a different account type (e.g. downloader) instead."
                ),
            }

        # Insert junction row — UNIQUE(channel_id, account_type) acts as
        # a safety net in case of a race
        try:
            await self.table(self.JUNCTION).create({
                "id": uuid.uuid4(),
                "channel_id": channel_db_id,
                "account_id": account_id,
                "account_type": account_type,
                "assigned_at": now,
                "assigned_by": assigned_by,
                "is_active": True,
            })
        except Exception as exc:
            err = str(exc).lower()
            if "unique" in err or "duplicate" in err:
                raise ValueError(
                    f"Channel already has a '{account_type}' account assigned. "
                    f"Only one account per type per channel is allowed."
                ) from exc
            raise

        logger.info(
            "Channel %s assigned to account %s (type=%s)",
            channel_db_id, account_id, account_type,
        )
        return {
            "channel_id": channel_db_id,
            "status": "assigned",
            "account_id": account_id,
            "account_type": account_type,
        }

    async def unassign_channel(
        self,
        channel_db_id: uuid.UUID,
        account_id: uuid.UUID | None = None,
    ) -> dict[str, Any]:
        """Remove one or all assignments from the junction table.

        If *account_id* is given, only that account's assignment is removed.
        Otherwise all assignments for the channel are deleted.
        """
        qb = self.table(self.JUNCTION).where("channel_id", channel_db_id)
        if account_id is not None:
            qb = qb.where("account_id", account_id)

        rows = await qb.all()
        if not rows:
            return {
                "channel_id": channel_db_id,
                "status": "not_assigned",
            }

        await qb.delete()
        removed_ids = [str(r["account_id"]) for r in rows]
        logger.info(
            "Channel %s unassigned from accounts: %s",
            channel_db_id, removed_ids,
        )
        return {
            "channel_id": channel_db_id,
            "status": "unassigned",
            "removed_account_ids": removed_ids,
        }

    async def is_channel_assigned(self, channel_db_id: uuid.UUID) -> bool:
        """Check if a channel has any active assignments in the junction."""
        row = await self.table(self.JUNCTION) \
            .where("channel_id", channel_db_id) \
            .where("is_active", True) \
            .first()
        return row is not None

    async def list_channels_for_account(
        self, account_id: uuid.UUID,
    ) -> tuple[list[dict[str, Any]], int]:
        """List all channels assigned to a specific account via the junction."""
        # Get channel IDs from junction
        junction_rows = await self.table(self.JUNCTION) \
            .select("channel_id", "account_type") \
            .where("account_id", account_id) \
            .where("is_active", True) \
            .all()

        if not junction_rows:
            return [], 0

        channel_db_ids = [r["channel_id"] for r in junction_rows]
        # Build a map of channel_db_id → account_type for enrichment
        type_map: dict[str, str] = {}
        for r in junction_rows:
            cid = str(r["channel_id"])
            if cid not in type_map:
                type_map[cid] = r.get("account_type", "crawler")

        # Fetch channel rows
        channels: list[dict[str, Any]] = []
        for cid in channel_db_ids:
            ch = await self.table(self.TABLE).where("id", cid).first()
            if ch:
                ch_dict = dict(ch)
                ch_dict["account_type"] = type_map.get(str(cid), "")
                channels.append(ch_dict)

        return channels, len(channels)

    async def get_account_ids_with_channels(self) -> set[str]:
        """Return account IDs that have >=1 active channel via the junction."""
        rows = await self.table(self.JUNCTION) \
            .select("account_id") \
            .where("is_active", True) \
            .group_by("account_id") \
            .all()
        return {str(r["account_id"]) for r in rows if r.get("account_id")}
