from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation
from enum import Enum

from sqlalchemy import select
from sqlalchemy.orm import Session

from quartermaster.groups import (
    GROUP_DEFAULT_OPEN,
    GROUP_LABELS,
    Group,
    group_order,
    sections_in_group,
)
from quartermaster.models import (
    SECTION_LABELS,
    DebtTarget,
    Entry,
    Month,
    MonthDebtTarget,
    MonthEntry,
    MonthState,
    Posting,
    Section,
)

logger = logging.getLogger("quartermaster.month_service")

YEAR_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")


class DeviationState(str, Enum):
    unchanged = "unchanged"
    edited = "edited"
    new_in_month = "new_in_month"


@dataclass(frozen=True)
class MonthRow:
    entry: MonthEntry
    state: DeviationState


@dataclass(frozen=True)
class MonthSectionView:
    section: Section
    label: str
    rows: list[MonthRow]
    total_planned: Decimal
    total_applied: Decimal


@dataclass(frozen=True)
class ZeroAmounts:
    planned: Decimal
    applied: Decimal


@dataclass(frozen=True)
class MonthGroupView:
    group: Group
    label: str
    default_open: bool
    sections: list[MonthSectionView]
    total_planned: Decimal
    total_applied: Decimal


def valid_year_month(year_month: str) -> bool:
    return bool(YEAR_MONTH_RE.match(year_month))


def current_year_month() -> str:
    today = date.today()
    return f"{today.year:04d}-{today.month:02d}"


def shift_year_month(year_month: str, delta: int) -> str:
    year, month = (int(part) for part in year_month.split("-"))
    index = (year * 12 + (month - 1)) + delta
    new_year, new_month0 = divmod(index, 12)
    return f"{new_year:04d}-{new_month0 + 1:02d}"


_MONTH_NAMES = [
    "", "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
]


def pretty_year_month(year_month: str) -> str:
    if not valid_year_month(year_month):
        return year_month
    year, month = year_month.split("-")
    return f"{_MONTH_NAMES[int(month)]} {year}"


def get_month(db: Session, year_month: str) -> Month | None:
    stmt = select(Month).where(Month.year_month == year_month)
    return db.scalar(stmt)


def list_months(db: Session) -> list[Month]:
    stmt = select(Month).order_by(Month.year_month)
    return list(db.scalars(stmt))


def create_month(db: Session, year_month: str) -> Month:
    if not valid_year_month(year_month):
        raise ValueError("year_month must be formatted as YYYY-MM")
    existing = get_month(db, year_month)
    if existing is not None:
        return existing

    month = Month(year_month=year_month)
    db.add(month)
    db.flush()

    budget_entries = list(db.scalars(select(Entry).order_by(Entry.id)))
    source_to_month_entry: dict[int, MonthEntry] = {}
    for e in budget_entries:
        month_entry = MonthEntry(
            month_id=month.id,
            section=e.section,
            name=e.name,
            planned=e.amount,
            notes=e.notes,
            origin_name=e.name,
            origin_planned=e.amount,
            source_entry_id=e.id,
        )
        db.add(month_entry)
        source_to_month_entry[e.id] = month_entry

    db.flush()

    budget_target = db.get(DebtTarget, 1)
    target_entry_id: int | None = None
    if budget_target is not None and budget_target.debt_minimum_id is not None:
        mapped = source_to_month_entry.get(budget_target.debt_minimum_id)
        if mapped is not None:
            target_entry_id = mapped.id

    db.add(
        MonthDebtTarget(month_id=month.id, month_entry_id=target_entry_id)
    )
    db.commit()
    db.refresh(month)
    logger.info(
        "created month snapshot",
        extra={"event": "month_created", "year_month": month.year_month},
    )
    return month


def deviation_state(entry: MonthEntry) -> DeviationState:
    if entry.origin_name is None or entry.origin_planned is None:
        return DeviationState.new_in_month
    if entry.name != entry.origin_name or entry.planned != entry.origin_planned:
        return DeviationState.edited
    return DeviationState.unchanged


def _rows(entries: list[MonthEntry]) -> list[MonthRow]:
    return [MonthRow(entry=e, state=deviation_state(e)) for e in entries]


def month_zero(month: Month) -> ZeroAmounts:
    income_planned = Decimal("0")
    income_applied = Decimal("0")
    other_planned = Decimal("0")
    other_applied = Decimal("0")
    for entry in month.entries:
        if entry.section == Section.income:
            income_planned += entry.planned
            income_applied += entry.applied
        else:
            other_planned += entry.planned
            other_applied += entry.applied
    return ZeroAmounts(
        planned=(income_planned - other_planned).quantize(Decimal("0.01")),
        applied=(income_applied - other_applied).quantize(Decimal("0.01")),
    )


def month_group_views(month: Month) -> list[MonthGroupView]:
    views: list[MonthGroupView] = []
    for group in group_order():
        sections = [
            section_view(month, s, SECTION_LABELS[s])
            for s in sections_in_group(group)
        ]
        planned = sum((sv.total_planned for sv in sections), Decimal("0"))
        applied = sum((sv.total_applied for sv in sections), Decimal("0"))
        views.append(
            MonthGroupView(
                group=group,
                label=GROUP_LABELS[group],
                default_open=GROUP_DEFAULT_OPEN[group],
                sections=sections,
                total_planned=planned,
                total_applied=applied,
            )
        )
    return views


def section_view(month: Month, section: Section, label: str) -> MonthSectionView:
    entries = [e for e in month.entries if e.section == section]
    entries.sort(key=lambda e: e.id)
    rows = _rows(entries)
    total_planned = sum((e.planned for e in entries), Decimal("0"))
    total_applied = sum((e.applied for e in entries), Decimal("0"))
    return MonthSectionView(
        section=section,
        label=label,
        rows=rows,
        total_planned=total_planned,
        total_applied=total_applied,
    )


def _clean_notes(raw: str | None) -> str | None:
    if raw is None:
        return None
    stripped = raw.strip()
    return stripped if stripped else None


def add_month_entry(
    db: Session,
    month: Month,
    section: Section,
    name: str,
    planned: Decimal,
    notes: str | None = None,
) -> MonthEntry:
    entry = MonthEntry(
        month_id=month.id,
        section=section,
        name=name.strip(),
        planned=planned,
        notes=_clean_notes(notes),
        origin_name=None,
        origin_planned=None,
        source_entry_id=None,
    )
    db.add(entry)
    db.commit()
    db.refresh(entry)
    return entry


def get_month_entry(db: Session, month: Month, entry_id: int) -> MonthEntry | None:
    entry = db.get(MonthEntry, entry_id)
    if entry is None or entry.month_id != month.id:
        return None
    return entry


def delete_month_entry(db: Session, month: Month, entry_id: int) -> Section | None:
    entry = get_month_entry(db, month, entry_id)
    if entry is None:
        return None
    section = entry.section
    db.delete(entry)
    db.commit()
    return section


_NOTES_SENTINEL = object()


def update_month_entry(
    db: Session,
    month: Month,
    entry_id: int,
    *,
    name: str | None = None,
    planned: Decimal | None = None,
    notes: str | None | object = _NOTES_SENTINEL,
) -> MonthEntry | None:
    entry = get_month_entry(db, month, entry_id)
    if entry is None:
        return None
    if name is not None:
        entry.name = name.strip()
    if planned is not None:
        entry.planned = planned
    if notes is not _NOTES_SENTINEL:
        entry.notes = _clean_notes(notes)  # type: ignore[arg-type]
    db.commit()
    db.refresh(entry)
    return entry


def add_posting(
    db: Session,
    month: Month,
    entry_id: int,
    occurred_on: date,
    amount: Decimal,
    description: str | None = None,
    payee: str | None = None,
) -> Posting | None:
    entry = get_month_entry(db, month, entry_id)
    if entry is None:
        return None
    posting = Posting(
        month_entry_id=entry.id,
        occurred_on=occurred_on,
        amount=amount,
        description=_clean_notes(description),
        payee=_clean_notes(payee),
    )
    db.add(posting)
    db.commit()
    db.refresh(posting)
    logger.info(
        "added posting",
        extra={
            "event": "posting_added",
            "posting_id": posting.id,
            "month_entry_id": posting.month_entry_id,
            "amount": str(posting.amount),
        },
    )
    return posting


def get_posting(
    db: Session, month: Month, posting_id: int
) -> Posting | None:
    posting = db.get(Posting, posting_id)
    if posting is None:
        return None
    # Ensure posting belongs to this month via its entry
    if posting.entry is None or posting.entry.month_id != month.id:
        return None
    return posting


_POSTING_SENTINEL = object()


def update_posting(
    db: Session,
    month: Month,
    posting_id: int,
    *,
    occurred_on: date | None = None,
    amount: Decimal | None = None,
    description: str | None | object = _POSTING_SENTINEL,
    payee: str | None | object = _POSTING_SENTINEL,
) -> Posting | None:
    posting = get_posting(db, month, posting_id)
    if posting is None:
        return None
    if occurred_on is not None:
        posting.occurred_on = occurred_on
    if amount is not None:
        posting.amount = amount
    if description is not _POSTING_SENTINEL:
        posting.description = _clean_notes(description)  # type: ignore[arg-type]
    if payee is not _POSTING_SENTINEL:
        posting.payee = _clean_notes(payee)  # type: ignore[arg-type]
    db.commit()
    db.refresh(posting)
    return posting


def delete_posting(
    db: Session, month: Month, posting_id: int
) -> MonthEntry | None:
    """Delete a posting; return the parent entry so callers can re-render its section."""
    posting = get_posting(db, month, posting_id)
    if posting is None:
        return None
    entry = posting.entry
    db.delete(posting)
    db.commit()
    logger.info(
        "deleted posting",
        extra={"event": "posting_deleted", "posting_id": posting_id},
    )
    return entry


def get_month_target(db: Session, month: Month) -> MonthDebtTarget:
    if month.target is not None:
        return month.target
    target = MonthDebtTarget(month_id=month.id, month_entry_id=None)
    db.add(target)
    db.commit()
    db.refresh(target)
    return target


def set_month_target(
    db: Session, month: Month, month_entry_id: int | None
) -> MonthDebtTarget:
    target = get_month_target(db, month)
    if month_entry_id is not None:
        candidate = get_month_entry(db, month, month_entry_id)
        if candidate is None or candidate.section != Section.debt_minimum:
            raise ValueError(
                "month_entry_id must reference a debt minimum entry in this month"
            )
    target.month_entry_id = month_entry_id
    db.commit()
    db.refresh(target)
    return target


class MonthLifecycleError(Exception):
    """Raised when a state transition or edit is rejected."""


def activate_month(db: Session, month: Month) -> Month:
    if month.state != MonthState.planning:
        raise MonthLifecycleError(
            f"cannot activate a {month.state.value} month"
        )
    month.state = MonthState.active
    month.activated_at = datetime.now(timezone.utc)
    db.commit()
    db.refresh(month)
    return month


def close_month(db: Session, month: Month) -> Month:
    if month.state != MonthState.active:
        raise MonthLifecycleError(
            f"cannot close a {month.state.value} month"
        )
    zero = month_zero(month)
    if zero.applied != Decimal("0.00"):
        raise MonthLifecycleError(
            "applied balance must equal $0.00 before closing; "
            f"currently ${zero.applied}"
        )
    month.state = MonthState.closed
    month.closed_at = datetime.now(timezone.utc)
    db.commit()
    db.refresh(month)
    logger.info(
        "closed month",
        extra={"event": "month_closed", "year_month": month.year_month},
    )
    return month


def reopen_month(db: Session, month: Month) -> Month:
    if month.state != MonthState.closed:
        raise MonthLifecycleError(
            f"cannot reopen a {month.state.value} month"
        )
    month.state = MonthState.active
    month.closed_at = None
    db.commit()
    db.refresh(month)
    return month


def ensure_editable(month: Month) -> None:
    if month.state == MonthState.closed:
        raise MonthLifecycleError("month is closed; reopen to edit")
