"""
Pydantic models for active-claude-github-mcp.

All models are strict — missing fields raise immediately at
construction time, not at use time. This is the first line of
fail-fast enforcement.
"""

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator


RelationshipType = Literal[
    "subsystem",
    "publish_target",
    "sibling",
    "cousin",
    "neighbor",
    "foreign",
    # Deprecated — pre-1.3.0 umbrella type that lumped sibling/cousin/
    # neighbor/foreign into one. Parsed with a deprecation warning in
    # config.py so existing configs continue to load. Migrate to the
    # precise sub-type at next config edit.
    "contribution_target",
]


class RepoConfig(BaseModel):
    """Public/private repo pair for a project."""

    model_config = {"frozen": True, "extra": "forbid"}

    public: str = Field(..., description="org/repo-name for public mirror")
    private: str = Field(..., description="org/repo-name for private dev repo")


class RelatedRepo(BaseModel):
    """A repo connected to this project via a typed relationship.

    Six relationship types govern how Claude may act on the repo (#98,
    paired with skill §Multi-repo relations / §Cross-repo work protocol
    #97). See that prose for the full execution protocol per type.
    """

    model_config = {"frozen": True, "extra": "forbid"}

    repo: str = Field(
        ...,
        description="owner/name fullname for the related repo (e.g. 'other-org/foo').",
    )
    relationship: RelationshipType = Field(
        ..., description="Relationship type; governs cross-repo mutation rules."
    )
    description: str | None = Field(
        None, description="Optional free-text note about the relationship."
    )

    @field_validator("repo")
    @classmethod
    def _repo_has_slash(cls, v: str) -> str:
        if "/" not in v or v.count("/") != 1 or not all(v.split("/")):
            raise ValueError(
                f"repo must be in 'owner/name' form, got {v!r}"
            )
        return v


class ProjectBoard(BaseModel):
    """GitHub Projects v2 board identifiers."""

    model_config = {"frozen": True, "extra": "forbid"}

    id: str = Field(..., description="GraphQL node ID")
    number: int = Field(..., gt=0)
    url: str = Field(..., description="Web URL for the board")
    status_field_id: str | None = Field(
        None, description="Status single-select field ID"
    )
    status_options: dict[str, str] | None = Field(
        None, description="Column name → option ID"
    )


class DiscussionCategory(BaseModel):
    """A single GitHub Discussion category."""

    model_config = {"frozen": True, "extra": "forbid"}

    id: str = Field(..., description="GraphQL node ID")
    is_answerable: bool = Field(..., alias="isAnswerable")
    note: str | None = Field(None, description="Human-readable annotation")

    model_config = {"frozen": True, "extra": "forbid", "populate_by_name": True}


class DiscussionCategories(BaseModel):
    """Discussion categories declared in github-config.json.

    The five named fields below (Project optional, others required)
    mirror the bootstrap defaults. Additional project-specific
    categories — e.g. a `General` category for admin session records
    — are accepted as extras and validated as `DiscussionCategory`
    instances; they are reachable via `model_extra`.
    """

    model_config = {
        "frozen": True,
        "extra": "allow",
        "populate_by_name": True,
    }

    Project: DiscussionCategory | None = Field(None)
    Architecture: DiscussionCategory
    UX_UI: DiscussionCategory = Field(..., alias="UX / UI")
    Ideas: DiscussionCategory
    claude_use_only: DiscussionCategory = Field(..., alias="claude-use-only")

    @model_validator(mode="after")
    def all_ids_non_empty(self) -> "DiscussionCategories":
        entries: list[tuple[str, DiscussionCategory]] = [
            ("Architecture", self.Architecture),
            ("UX / UI", self.UX_UI),
            ("Ideas", self.Ideas),
            ("claude-use-only", self.claude_use_only),
        ]
        if self.Project is not None:
            entries.append(("Project", self.Project))
        extras = self.model_extra if self.model_extra is not None else {}
        for name, value in list(extras.items()):
            if isinstance(value, DiscussionCategory):
                extra_category = value
            else:
                try:
                    extra_category = DiscussionCategory.model_validate(value)
                except ValidationError as exc:
                    raise ValueError(
                        f"Extra discussion category '{name}' is invalid: {exc}"
                    ) from exc
                extras[name] = extra_category
            entries.append((name, extra_category))
        for field_name, category in entries:
            if not category.id.strip():
                raise ValueError(
                    f"Discussion category '{field_name}' has an empty ID. "
                    "Re-run bootstrap to regenerate github-config.json."
                )
        return self


class GithubConfig(BaseModel):
    """
    Parsed and validated github-config.json.

    Fails immediately at construction if any required field is
    missing or invalid. Downstream code can trust all fields exist.
    """

    model_config = {"frozen": True, "extra": "forbid"}

    version: str
    skill: Literal["active-claude-github"]
    account_type: Literal["org", "personal"]
    owner: str = Field(..., min_length=1)
    repos: RepoConfig
    project_board: ProjectBoard
    project_discussion_id: str = Field(..., min_length=1)
    session_lock_discussion_id: str = Field(..., min_length=1)
    discussion_categories: DiscussionCategories
    related_repos: list[RelatedRepo] = Field(default_factory=list)
    skill_version_at_bootstrap: str
    bootstrapped_at: str
    publish_target_outside_org: bool = Field(
        False,
        description=(
            "When True, the repo-shape audit (#116, #118) does not flag a "
            "publish_target whose owner differs from the project's org as "
            "drift. Used for personal-attribution OSS projects whose "
            "publish target intentionally lives outside the org."
        ),
    )
    publish_target_outside_org_reason: str | None = Field(
        None,
        description=(
            "Optional one-line reason recorded alongside "
            "publish_target_outside_org=True so future operators can see why "
            "the cross-owner placement is intentional."
        ),
    )

    def find_related_repo(self, repo_fullname: str) -> RelatedRepo | None:
        """Return the RelatedRepo entry matching `owner/name`, or None."""
        target = repo_fullname.strip().lower()
        for entry in self.related_repos:
            if entry.repo.lower() == target:
                return entry
        return None

    @model_validator(mode="after")
    def ids_look_like_node_ids(self) -> "GithubConfig":
        """
        Rough sanity check — GitHub node IDs are base64-ish strings.
        Catches copy-paste errors and truncated values early.
        """
        for field, value in [
            ("project_discussion_id", self.project_discussion_id),
            ("session_lock_discussion_id", self.session_lock_discussion_id),
            ("project_board.id", self.project_board.id),
        ]:
            if len(value) < 10:
                raise ValueError(
                    f"Field '{field}' value '{value}' is suspiciously short "
                    "for a GitHub node ID. Check github-config.json."
                )
        return self


class RepoShapeEntry(BaseModel):
    """A single managed-repo entry in the open_session repo-shape audit (#116).

    Reported to Claude at session start so the startup banner can render
    each managed repo's current visibility plus a compliance flag, and
    can offer corrective steps when drift is found (per §Step 3.5
    Migration Completion Checklist).
    """

    model_config = {"frozen": True, "extra": "forbid"}

    path: str = Field(
        ..., description="owner/name fullname for the repo being audited."
    )
    role: Literal["publish_target", "dev", "subsystem", "subsystem_publish"] = Field(
        ..., description="Which managed-repo role this entry represents."
    )
    visibility: Literal["PUBLIC", "PRIVATE", "INTERNAL", "ABSENT"] = Field(
        ...,
        description=(
            "GitHub `visibility` enum (PUBLIC/PRIVATE/INTERNAL) or the "
            "synthetic `ABSENT` for a repo path that returned 404."
        ),
    )
    compliant: bool = Field(
        ..., description="True iff the entry passes §Step 3.5 compliance rules."
    )
    compliance_note: str | None = Field(
        None,
        description=(
            "Reason this row is non-compliant (only set when compliant=False). "
            "Plain English, suitable for direct display in the banner."
        ),
    )


class SessionState(BaseModel):
    """
    Runtime session state — returned by open_session() and held
    in Claude's context for the duration of the session.
    """

    model_config = {"frozen": True, "extra": "forbid"}

    session_id: str
    prior_session_clean: bool
    prior_session_timestamp: str | None
    project_discussion_id: str
    session_lock_discussion_id: str
    category_architecture_id: str
    category_ux_ui_id: str
    category_ideas_id: str
    category_claude_use_only_id: str
    project_board_id: str
    account_type: Literal["org", "personal"]
    skill_version: str | None = Field(
        None,
        description=(
            "Local skill file's `**Version:**` header at session-open time, or "
            "None if the skill file isn't present at the canonical path. (#116)"
        ),
    )
    mcp_server_version: str = Field(
        ...,
        description="Running MCP server's __version__ at session-open time. (#116)",
    )
    latest_skill_version: str | None = Field(
        None,
        description=(
            "Upstream skill version on the public mirror's main branch, or None "
            "on lookup failure (network, repo-not-found, etc.). Compare to "
            "skill_version to detect drift. (#116)"
        ),
    )
    repo_shape: list[RepoShapeEntry] = Field(
        default_factory=list,
        description=(
            "Repo-shape audit — one entry per managed repo (publish_target, "
            "dev, plus each `related_repos` entry typed `subsystem` or "
            "`publish_target`). Empty list if the audit was skipped (e.g. "
            "in the bash MCP-down fallback). (#116)"
        ),
    )


class PostTurnResult(BaseModel):
    model_config = {"frozen": True, "extra": "forbid"}

    comment_id: str
    comment_url: str
    # Present only when the post was to the Project Discussion (#63).
    project_toc_body: str | None = None


class CreateThreadResult(BaseModel):
    model_config = {"frozen": True, "extra": "forbid"}

    thread_id: str
    thread_number: int
    thread_url: str
    type: Literal["discussion", "issue"]
    # Post-mutation Project Discussion body for callers about to update
    # the TOC. Saves one GraphQL round trip (#63).
    project_toc_body: str | None = None


class ClassifyResult(BaseModel):
    model_config = {"frozen": True, "extra": "forbid"}

    destination: Literal["discussion", "issue"]
    category: Literal["Architecture", "UX / UI", "Ideas"] | None
    issue_label: Literal["bug", "rfc", "feature", "refactor", "chore"] | None
    suggested_title: str
    confidence: Literal["high", "medium", "low"]


class ThemeChangeResult(BaseModel):
    model_config = {"frozen": True, "extra": "forbid"}

    theme_changed: bool
    confidence: Literal["high", "medium", "low"]
    fork_point_comment_id: str | None
    original_theme: str
    new_theme: str
    suggested_title: str


class ForkResult(BaseModel):
    model_config = {"frozen": True, "extra": "forbid"}

    new_discussion_id: str
    new_discussion_url: str
    original_updated: bool
    fork_comment_id: str
    # Post-mutation Project Discussion body (#63).
    project_toc_body: str | None = None


class CreateIssueResult(BaseModel):
    model_config = {"frozen": True, "extra": "forbid"}

    issue_id: str
    issue_number: int
    issue_url: str
    project_item_id: str
    # Post-mutation Project Discussion body (#63).
    project_toc_body: str | None = None


class OperationError(BaseModel):
    """
    Returned by tools when a GitHub operation fails after retries.
    The MCP server stays alive; Claude receives this and reports
    to the user.
    """

    model_config = {"frozen": True, "extra": "forbid"}

    success: Literal[False] = False
    error: str
    buffered: bool
    buffer_file: str | None
    retry_tool: str | None
    retry_args: dict | None
    # Set when the failure represents a protocol gate rather than a
    # transient GitHub/network error — e.g. cousin-repo mutation
    # requires explicit user permission per #97 §Cross-repo work
    # protocol. Distinguishes "ask the user, then re-run" from "retry
    # the API call". (#98)
    manual_step: str | None = None
