"""
Phase 17: Swarm Consensus Protocol

Democratic decision-making for agent swarms. Agents create proposals,
vote on options, and results are resolved with proper tie-breaking,
quorum requirements, and vote revocation.
"""

import json
import time
import uuid
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional


@dataclass
class Proposal:
    proposal_id: str
    description: str
    options: list[str]
    votes: dict[str, str]        # agent_id -> option
    vote_timestamps: dict[str, float]  # agent_id -> when they voted
    status: str                  # open, resolved, expired, cancelled
    expires_at: float
    created_by: str = ""
    created_at: float = 0.0
    resolved_at: float = 0.0
    winner: str = ""
    quorum: int = 1              # minimum votes required
    tally: dict[str, int] = field(default_factory=dict)


class ConsensusManager:
    def __init__(self, storage_dir: str = "state/broker/consensus"):
        self.storage_dir = Path(storage_dir)
        self.storage_dir.mkdir(parents=True, exist_ok=True)

    # ── Persistence ───────────────────────────────────────────────────

    def _save(self, p: Proposal) -> None:
        with open(self.storage_dir / f"{p.proposal_id}.json", "w", encoding="utf-8") as f:
            json.dump(asdict(p), f, indent=2)

    def _load(self, pid: str) -> Optional[Proposal]:
        path = self.storage_dir / f"{pid}.json"
        if not path.exists():
            return None
        try:
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
            return Proposal(**data)
        except (json.JSONDecodeError, TypeError, KeyError):
            return None

    def _list_proposals(self, status: Optional[str] = None) -> list[Proposal]:
        proposals = []
        for f in self.storage_dir.glob("*.json"):
            p = self._load(f.stem)
            if p and (status is None or p.status == status):
                proposals.append(p)
        proposals.sort(key=lambda p: p.created_at, reverse=True)
        return proposals

    # ── Create ────────────────────────────────────────────────────────

    def create_proposal(
        self,
        description: str,
        options: list[str],
        created_by: str = "",
        duration_sec: int = 300,
        quorum: int = 1,
    ) -> dict:
        """Create a new proposal for agents to vote on.

        Returns {"proposal_id": ..., "expires_at": ..., "quorum": ...}.
        """
        if len(options) < 2:
            return {"error": "Need at least 2 options"}

        # Deduplicate options preserving order
        seen = set()
        unique_options = []
        for opt in options:
            if opt not in seen:
                seen.add(opt)
                unique_options.append(opt)

        pid = uuid.uuid4().hex[:8]
        now = time.time()
        p = Proposal(
            proposal_id=pid,
            description=description,
            options=unique_options,
            votes={},
            vote_timestamps={},
            status="open",
            expires_at=now + duration_sec,
            created_by=created_by,
            created_at=now,
            quorum=max(1, quorum),
        )
        self._save(p)
        return {"proposal_id": pid, "expires_at": p.expires_at, "quorum": p.quorum}

    # ── Vote ──────────────────────────────────────────────────────────

    def vote(self, proposal_id: str, agent_id: str, option: str) -> dict:
        """Cast or change a vote. Agents can revote at any time while open.

        Returns {"ok": True, "previous_vote": ...} or {"ok": False, "error": ...}.
        """
        p = self._load(proposal_id)
        if not p:
            return {"ok": False, "error": "Proposal not found"}

        # Auto-expire
        if p.status == "open" and time.time() > p.expires_at:
            p.status = "expired"
            self._save(p)
            return {"ok": False, "error": "Proposal has expired"}

        if p.status != "open":
            return {"ok": False, "error": f"Proposal is {p.status}, not open"}

        if option not in p.options:
            return {"ok": False, "error": f"Invalid option. Choose from: {p.options}"}

        previous = p.votes.get(agent_id)
        p.votes[agent_id] = option
        p.vote_timestamps[agent_id] = time.time()
        self._save(p)

        result = {"ok": True, "vote": option, "total_votes": len(p.votes)}
        if previous and previous != option:
            result["changed_from"] = previous
        return result

    def revoke_vote(self, proposal_id: str, agent_id: str) -> dict:
        """Withdraw a vote entirely."""
        p = self._load(proposal_id)
        if not p:
            return {"ok": False, "error": "Proposal not found"}
        if p.status != "open":
            return {"ok": False, "error": f"Proposal is {p.status}, not open"}
        if agent_id not in p.votes:
            return {"ok": False, "error": "You haven't voted on this proposal"}

        removed = p.votes.pop(agent_id)
        p.vote_timestamps.pop(agent_id, None)
        self._save(p)
        return {"ok": True, "revoked": removed, "total_votes": len(p.votes)}

    # ── Resolve ───────────────────────────────────────────────────────

    def _tally(self, p: Proposal) -> dict[str, int]:
        """Count votes per option."""
        tally: dict[str, int] = {opt: 0 for opt in p.options}
        for vote in p.votes.values():
            tally[vote] = tally.get(vote, 0) + 1
        return tally

    def _break_tie(self, p: Proposal, tied_options: list[str]) -> str:
        """Tie-breaking rules:
        1. Option that received the first vote wins (earliest voter preference).
        2. If still tied, option listed first in the proposal wins.
        """
        # Find earliest vote timestamp for each tied option
        earliest: dict[str, float] = {}
        for agent_id, option in p.votes.items():
            if option in tied_options:
                ts = p.vote_timestamps.get(agent_id, float("inf"))
                if option not in earliest or ts < earliest[option]:
                    earliest[option] = ts

        # Sort by earliest vote, then by original option order
        tied_options.sort(key=lambda opt: (earliest.get(opt, float("inf")), p.options.index(opt)))
        return tied_options[0]

    def resolve(self, proposal_id: str) -> dict:
        """Resolve a proposal and determine the winner.

        Returns {"winner": ..., "tally": ..., "total_votes": ..., "quorum_met": ...}
        or {"error": ...}.
        """
        p = self._load(proposal_id)
        if not p:
            return {"error": "Proposal not found"}
        if p.status == "resolved":
            return {"winner": p.winner, "tally": p.tally, "already_resolved": True}

        tally = self._tally(p)
        total_votes = len(p.votes)
        quorum_met = total_votes >= p.quorum

        if total_votes == 0:
            p.status = "resolved"
            p.resolved_at = time.time()
            p.winner = ""
            p.tally = tally
            self._save(p)
            return {"winner": "", "tally": tally, "total_votes": 0, "quorum_met": False, "note": "No votes cast"}

        if not quorum_met:
            p.status = "resolved"
            p.resolved_at = time.time()
            p.winner = ""
            p.tally = tally
            self._save(p)
            return {
                "winner": "",
                "tally": tally,
                "total_votes": total_votes,
                "quorum_met": False,
                "note": f"Quorum not met: {total_votes}/{p.quorum} votes",
            }

        # Find winner(s)
        max_count = max(tally.values())
        top_options = [opt for opt, count in tally.items() if count == max_count]

        if len(top_options) == 1:
            winner = top_options[0]
        else:
            winner = self._break_tie(p, top_options)

        p.status = "resolved"
        p.resolved_at = time.time()
        p.winner = winner
        p.tally = tally
        self._save(p)

        result = {
            "winner": winner,
            "tally": tally,
            "total_votes": total_votes,
            "quorum_met": True,
        }
        if len(top_options) > 1:
            result["tie_broken"] = True
            result["tied_options"] = top_options
        return result

    def cancel(self, proposal_id: str, cancelled_by: str = "") -> dict:
        """Cancel an open proposal."""
        p = self._load(proposal_id)
        if not p:
            return {"ok": False, "error": "Proposal not found"}
        if p.status != "open":
            return {"ok": False, "error": f"Proposal is already {p.status}"}
        p.status = "cancelled"
        p.resolved_at = time.time()
        self._save(p)
        return {"ok": True, "cancelled_by": cancelled_by}

    # ── Query ─────────────────────────────────────────────────────────

    def get_proposal(self, proposal_id: str) -> Optional[dict]:
        """Get full proposal details including current tally."""
        p = self._load(proposal_id)
        if not p:
            return None
        data = asdict(p)
        data["current_tally"] = self._tally(p)
        data["time_remaining"] = max(0, p.expires_at - time.time()) if p.status == "open" else 0
        return data

    def list_open(self) -> list[dict]:
        """List all open proposals."""
        results = []
        for p in self._list_proposals(status="open"):
            # Auto-expire
            if time.time() > p.expires_at:
                p.status = "expired"
                self._save(p)
                continue
            data = asdict(p)
            data["current_tally"] = self._tally(p)
            data["time_remaining"] = max(0, p.expires_at - time.time())
            results.append(data)
        return results

    def list_resolved(self, limit: int = 20) -> list[dict]:
        """List recently resolved proposals."""
        results = []
        for p in self._list_proposals():
            if p.status in ("resolved", "expired", "cancelled"):
                results.append(asdict(p))
        return results[:limit]
