from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence

from sprint_forecast import WorkItemEstimate
from sprint_forecast.integrations.pert_hours_export import PertHours, load_pert_hours_export
from zenhub_client import ZenhubClient
from zenhub_client.models import SprintIssue, SprintInfo


@dataclass(frozen=True, slots=True)
class ProjectItem:
    issue_id: str
    issue_number: int
    title: str
    html_url: str | None
    state: str | None
    pipeline: str | None
    status: str
    assignees: tuple[str, ...]
    labels: tuple[str, ...]
    pert: PertHours | None
    included_in_forecast: bool

    def to_json(self) -> dict[str, Any]:
        return {
            "issue_id": self.issue_id,
            "issue_number": self.issue_number,
            "title": self.title,
            "html_url": self.html_url,
            "state": self.state,
            "pipeline": self.pipeline,
            "status": self.status,
            "assignees": list(self.assignees),
            "labels": list(self.labels),
            "pert": self.pert.to_json() if self.pert is not None else None,
            "included_in_forecast": bool(self.included_in_forecast),
        }


@dataclass(frozen=True, slots=True)
class ZenhubSprintWorkItemsResult:
    sprint: SprintInfo
    items: list[WorkItemEstimate]
    project_items: list[ProjectItem]
    meta: dict[str, Any]


def _bool(cfg: Mapping[str, Any], key: str, default: bool) -> bool:
    v = cfg.get(key, default)
    return bool(v)


def _as_str_tuple(value: Any) -> tuple[str, ...]:
    if value is None:
        return ()
    if isinstance(value, str):
        return (value,)
    if isinstance(value, Sequence):
        out: list[str] = []
        for x in value:
            if x is None:
                continue
            out.append(str(x))
        return tuple(out)
    return (str(value),)


def build_work_items_from_zenhub_sprint(
    *,
    zh: ZenhubClient,
    source_cfg: Mapping[str, Any],
    fallback_owner_id: str,
    developer_ids: Sequence[str] | None = None,
) -> ZenhubSprintWorkItemsResult:
    workspace_id = source_cfg.get("workspace_id")
    if not workspace_id:
        raise ValueError("workload_source.workspace_id is required for zenhub_sprint_issues")

    sprint_id = source_cfg.get("sprint_id")
    use_current_open = bool(source_cfg.get("use_current_open_sprint", True))
    if not sprint_id and use_current_open:
        sprint = zh.sprints.get_current_open_sprint(str(workspace_id))
        sprint_id = sprint.id

    if not sprint_id:
        raise ValueError(
            "Provide workload_source.sprint_id, or set use_current_open_sprint=true with a valid workspace_id."
        )

    sprint, issues = zh.sprints.get_sprint_issues(str(sprint_id), workspace_id=str(workspace_id))

    labels_cfg = source_cfg.get("labels", {})
    if not isinstance(labels_cfg, Mapping):
        labels_cfg = {}
    required_labels = set(_as_str_tuple(labels_cfg.get("required")))
    required_prefixes = _as_str_tuple(labels_cfg.get("required_prefixes"))
    ai_label = str(labels_cfg.get("ai_label", "ai"))
    require_labels = _bool(labels_cfg, "enabled", True) and (bool(required_labels) or bool(required_prefixes))

    pert_cfg = source_cfg.get("pert", {})
    if not isinstance(pert_cfg, Mapping):
        pert_cfg = {}
    pert_source = str(pert_cfg.get("source", "pert_hours_export"))
    pert_require_actual = _bool(pert_cfg, "require_actual", True)
    pert_require_triad = _bool(pert_cfg, "require_triad", True)

    pert_by_issue: dict[int, PertHours] = {}
    pert_units: str | None = None
    if pert_source == "pert_hours_export":
        path_raw = pert_cfg.get("path", ".scarf/pert-hours.json")
        path = Path(str(path_raw))
        if not path.exists():
            raise FileNotFoundError(
                f"PERT export not found at {path}. Configure workload_source.pert.path or disable strict PERT validation."
            )
        pert_units, pert_by_issue = load_pert_hours_export(path)
    elif pert_source == "none":
        pert_by_issue = {}
    else:
        raise ValueError(f"Unsupported workload_source.pert.source: {pert_source!r}")

    assignment_cfg = source_cfg.get("assignment", {})
    if not isinstance(assignment_cfg, Mapping):
        assignment_cfg = {}
    require_assignee = _bool(assignment_cfg, "require_assignee", True)
    assignee_mode = str(assignment_cfg.get("assignee_mode", "first")).lower()
    dev_map_raw = assignment_cfg.get("developer_map", {})
    developer_map: dict[str, str] = {}
    if isinstance(dev_map_raw, Mapping):
        developer_map = {str(k): str(v) for k, v in dev_map_raw.items()}
    fallback_owner_override = assignment_cfg.get("fallback_owner", None)
    if fallback_owner_override is not None:
        fallback_owner_id = str(fallback_owner_override)

    status_cfg = source_cfg.get("status", {})
    if not isinstance(status_cfg, Mapping):
        status_cfg = {}
    pipeline_to_status: dict[str, str] = {}
    pipeline_map_raw = status_cfg.get("pipeline_to_status", {})
    if isinstance(pipeline_map_raw, Mapping):
        pipeline_to_status = {str(k): str(v) for k, v in pipeline_map_raw.items()}
    default_status = str(status_cfg.get("default_status", "open"))
    done_statuses = set(_as_str_tuple(status_cfg.get("done_statuses", ("done",))))
    done_pipelines = set(_as_str_tuple(status_cfg.get("done_pipelines", ())))
    status_overrides_raw = status_cfg.get("overrides", {})
    status_overrides: dict[int, str] = {}
    if isinstance(status_overrides_raw, Mapping):
        for k, v in status_overrides_raw.items():
            try:
                status_overrides[int(k)] = str(v)
            except (TypeError, ValueError):
                continue

    errors: list[str] = []
    project_items: list[ProjectItem] = []
    work_items: list[WorkItemEstimate] = []

    def _has_required_label(label_set: set[str]) -> bool:
        if required_labels and not required_labels.issubset(label_set):
            return False
        for prefix in required_prefixes:
            if not any(l.startswith(prefix) for l in label_set):
                return False
        return True

    for issue in issues:
        issue_labels = set(issue.labels)

        if require_labels and not issue.labels:
            errors.append(
                f"Issue #{issue.number} missing labels in Zenhub response; cannot validate sprint tag. "
                f"Either update Zenhub query support or set workload_source.labels.enabled=false."
            )
        elif require_labels and not _has_required_label(issue_labels):
            errors.append(
                f"Issue #{issue.number} missing required sprint tag(s). "
                f"required={sorted(required_labels)}, required_prefixes={list(required_prefixes)}"
            )

        pert = pert_by_issue.get(issue.number)
        if pert_source != "none":
            if pert is None:
                errors.append(
                    f"Issue #{issue.number} missing PERT export entry. Ensure it exists in the PERT Hours export file."
                )
            else:
                if pert_require_triad and not pert.is_complete_triad():
                    errors.append(f"Issue #{issue.number} missing one or more PERT triad fields in export.")
                if pert_require_actual and pert.actual is None:
                    errors.append(f"Issue #{issue.number} missing actual field in export.")

        assignees = issue.assignees
        if require_assignee and not assignees:
            errors.append(f"Issue #{issue.number} missing assignee(s).")

        if assignee_mode not in {"first", "error_if_multiple"}:
            raise ValueError("workload_source.assignment.assignee_mode must be 'first' or 'error_if_multiple'")
        if assignee_mode == "error_if_multiple" and len(assignees) > 1:
            errors.append(f"Issue #{issue.number} has multiple assignees: {list(assignees)}")

        assignee_login = assignees[0] if assignees else None
        owner_id = (
            developer_map.get(assignee_login, assignee_login) if assignee_login is not None else fallback_owner_id
        )

        if developer_ids is not None and owner_id not in set(developer_ids):
            errors.append(
                f"Issue #{issue.number} assignee {assignee_login!r} maps to {owner_id!r}, "
                f"which is not a configured developer id."
            )

        pipeline = issue.pipeline_name
        status = default_status
        if issue.state and str(issue.state).upper() == "CLOSED":
            status = "done"
        elif pipeline and pipeline in pipeline_to_status:
            status = pipeline_to_status[pipeline]
        elif pipeline and pipeline in done_pipelines:
            status = "done"

        if issue.number in status_overrides:
            status = status_overrides[issue.number]

        is_done = status in done_statuses or (issue.state and str(issue.state).upper() == "CLOSED")
        project_items.append(
            ProjectItem(
                issue_id=issue.id,
                issue_number=issue.number,
                title=issue.title,
                html_url=issue.html_url,
                state=issue.state,
                pipeline=issue.pipeline_name,
                status=status,
                assignees=issue.assignees,
                labels=issue.labels,
                pert=pert,
                included_in_forecast=not is_done,
            )
        )
        if is_done:
            continue

        if pert and pert.is_complete_triad():
            o = float(pert.optimistic)  # type: ignore[arg-type]
            m = float(pert.most_likely)  # type: ignore[arg-type]
            p = float(pert.pessimistic)  # type: ignore[arg-type]
        else:
            errors.append(f"Issue #{issue.number} cannot be used for forecast; missing PERT triad.")
            continue

        ai_intensity = 1.0 if (pert and pert.ai_assisted is True) else (1.0 if ai_label in issue.labels else 0.0)

        work_items.append(
            WorkItemEstimate(
                str(issue.number),
                str(owner_id),
                o,
                m,
                p,
                ai_intensity=ai_intensity,
            )
        )

    if errors:
        raise ValueError("Zenhub sprint issue validation failed:\n- " + "\n- ".join(errors))

    meta = {
        "source": "zenhub_sprint_issues",
        "workspace_id": str(workspace_id),
        "view_number": source_cfg.get("view_number"),
        "sprint": {
            "id": sprint.id,
            "name": sprint.name,
            "state": sprint.state,
            "startAt": sprint.start_at,
            "endAt": sprint.end_at,
        },
        "counts": {
            "total_issues": len(issues),
            "remaining_issues": len(work_items),
        },
        "pert": {
            "source": pert_source,
            "units": pert_units,
            "require_actual": pert_require_actual,
            "require_triad": pert_require_triad,
        },
        "labels": {
            "enabled": bool(require_labels),
            "required": sorted(required_labels),
            "required_prefixes": list(required_prefixes),
            "ai_label": ai_label,
        },
    }

    return ZenhubSprintWorkItemsResult(
        sprint=sprint,
        items=work_items,
        project_items=project_items,
        meta=meta,
    )
