# This module defines functions to parse Numpy-style docstrings into structured data.
#
# Based on https://numpydoc.readthedocs.io/en/latest/format.html,
# it seems Numpydoc is a superset of RST.
# Since fully parsing RST is a non-goal of this project,
# some things are stripped from the Numpydoc specification.
#
# Rejected as non particularly Pythonic or useful as sections:
#
# - See also: this section feels too subjective (specially crafted as a standard for Numpy itself),
#     and there are may ways to reference related items in a docstring, depending on the chosen markup.
#
# Rejected as naturally handled by the user-chosen markup:
#
# - Warnings: this is just markup.
# - Notes: again, just markup.
# - References: again, just markup.


from __future__ import annotations

import re
from contextlib import suppress
from textwrap import dedent
from typing import TYPE_CHECKING, TypedDict

from griffe._internal.docstrings.models import (
    DocstringAttribute,
    DocstringClass,
    DocstringFunction,
    DocstringModule,
    DocstringParameter,
    DocstringRaise,
    DocstringReceive,
    DocstringReturn,
    DocstringSection,
    DocstringSectionAdmonition,
    DocstringSectionAttributes,
    DocstringSectionClasses,
    DocstringSectionDeprecated,
    DocstringSectionExamples,
    DocstringSectionFunctions,
    DocstringSectionModules,
    DocstringSectionOtherParameters,
    DocstringSectionParameters,
    DocstringSectionRaises,
    DocstringSectionReceives,
    DocstringSectionReturns,
    DocstringSectionText,
    DocstringSectionTypeAliases,
    DocstringSectionTypeParameters,
    DocstringSectionWarns,
    DocstringSectionYields,
    DocstringTypeAlias,
    DocstringTypeParameter,
    DocstringWarn,
    DocstringYield,
)
from griffe._internal.docstrings.utils import docstring_warning, parse_docstring_annotation
from griffe._internal.enumerations import DocstringSectionKind, LogLevel
from griffe._internal.expressions import ExprName

if TYPE_CHECKING:
    from re import Pattern
    from typing import Any, Literal

    from griffe._internal.expressions import Expr
    from griffe._internal.models import Docstring


_section_kind = {
    "deprecated": DocstringSectionKind.deprecated,
    "parameters": DocstringSectionKind.parameters,
    "other parameters": DocstringSectionKind.other_parameters,
    "type parameters": DocstringSectionKind.type_parameters,
    "returns": DocstringSectionKind.returns,
    "yields": DocstringSectionKind.yields,
    "receives": DocstringSectionKind.receives,
    "raises": DocstringSectionKind.raises,
    "warns": DocstringSectionKind.warns,
    "examples": DocstringSectionKind.examples,
    "attributes": DocstringSectionKind.attributes,
    "functions": DocstringSectionKind.functions,
    "methods": DocstringSectionKind.functions,
    "classes": DocstringSectionKind.classes,
    "type aliases": DocstringSectionKind.type_aliases,
    "modules": DocstringSectionKind.modules,
}


def _is_empty_line(line: str) -> bool:
    return not line.strip()


def _is_dash_line(line: str) -> bool:
    return not _is_empty_line(line) and _is_empty_line(line.replace("-", ""))


def _read_block_items(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,  # noqa: ARG001
) -> tuple[list[list[str]], int]:
    lines = docstring.lines
    if offset >= len(lines):
        return [], offset

    new_offset = offset
    items: list[list[str]] = []

    # skip first empty lines
    while _is_empty_line(lines[new_offset]):
        new_offset += 1

    # start processing first item
    current_item = [lines[new_offset]]
    new_offset += 1

    # loop on next lines
    while new_offset < len(lines):
        line = lines[new_offset]

        if _is_empty_line(line):
            # empty line: preserve it in the current item
            current_item.append("")

        elif line.startswith(4 * " "):
            # continuation line
            current_item.append(line[4:])

        elif line.startswith(" "):
            # indent between initial and continuation: append but warn
            cont_indent = len(line) - len(line.lstrip())
            current_item.append(line[cont_indent:])
            if warnings:
                docstring_warning(
                    docstring,
                    new_offset,
                    f"Confusing indentation for continuation line {new_offset + 1} in docstring, "
                    f"should be 4 spaces, not {cont_indent}",
                )

        elif new_offset + 1 < len(lines) and _is_dash_line(lines[new_offset + 1]):
            # detect the start of a new section
            break

        else:
            items.append(current_item)
            current_item = [line]

        new_offset += 1

    if current_item:
        items.append(current_item)

    return items, new_offset - 1


def _read_block(docstring: Docstring, *, offset: int, **options: Any) -> tuple[str, int]:  # noqa: ARG001
    lines = docstring.lines
    if offset >= len(lines):
        return "", offset

    new_offset = offset
    block: list[str] = []

    # skip first empty lines
    while _is_empty_line(lines[new_offset]):
        new_offset += 1
    while new_offset < len(lines):
        is_empty = _is_empty_line(lines[new_offset])
        if is_empty and new_offset < len(lines) - 1 and _is_dash_line(lines[new_offset + 1]):
            break  # Break if a new unnamed section is reached.

        if is_empty and new_offset < len(lines) - 2 and _is_dash_line(lines[new_offset + 2]):
            break  # Break if a new named section is reached.

        block.append(lines[new_offset])
        new_offset += 1

    return "\n".join(block).rstrip("\n"), new_offset - 1


_RE_OB: str = r"\{"  # Opening bracket.
_RE_CB: str = r"\}"  # Closing bracket.
_RE_NAME: str = r"\*{0,2}[_a-z][_a-z0-9]*"
_RE_TYPE: str = r".+"
_RE_RETURNS: Pattern = re.compile(
    rf"""
    (?:
        (?P<nt_name>{_RE_NAME})\s*:\s*(?P<nt_type>{_RE_TYPE})  # name and type
        |  # or
        (?P<name>{_RE_NAME})\s*:\s*  # just name
        |  # or
        \s*:\s*$  # no name, no type
        |  # or
        (?::\s*)?(?P<type>{_RE_TYPE})\s*  # just type
    )
    """,
    re.IGNORECASE | re.VERBOSE,
)
_RE_YIELDS: Pattern = _RE_RETURNS
_RE_RECEIVES: Pattern = _RE_RETURNS
_RE_PARAMETER: Pattern = re.compile(
    rf"""
    (?P<names>{_RE_NAME}(?:,\s{_RE_NAME})*)
    (?:
        \s:\s
        (?:
            (?:{_RE_OB}(?P<choices>.+){_RE_CB})|
            (?P<type>{_RE_TYPE})
        )?
    )?
    """,
    re.IGNORECASE | re.VERBOSE,
)
_RE_DOCTEST_BLANKLINE: Pattern = re.compile(r"^\s*<BLANKLINE>\s*$")
_RE_DOCTEST_FLAGS: Pattern = re.compile(r"(\s*#\s*doctest:.+)$")


def _read_parameters(
    docstring: Docstring,
    *,
    offset: int,
    warn_unknown_params: bool = True,
    warn_missing_types: bool = True,
    warnings: bool = True,
    **options: Any,
) -> tuple[list[DocstringParameter], int]:
    parameters: list[DocstringParameter] = []
    annotation: str | Expr | None

    items, new_offset = _read_block_items(docstring, offset=offset, **options)

    for item in items:
        match = _RE_PARAMETER.match(item[0])
        if not match:
            if warnings:
                docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
            continue

        names = match.group("names").split(", ")
        annotation = match.group("type") or None
        choices = match.group("choices")
        default = None
        if choices:
            annotation = choices
            default = choices.split(", ", 1)[0]
        elif annotation:
            match = re.match(r"^(?P<annotation>.+),\s+default(?: |: |=)(?P<default>.+)$", annotation)
            if match:
                default = match.group("default")
                annotation = match.group("annotation")
        if annotation and annotation.endswith(", optional"):
            annotation = annotation[:-10]
        description = "\n".join(item[1:]).rstrip() if len(item) > 1 else ""

        if annotation is None:
            # try to use the annotation from the signature
            for name in names:
                with suppress(AttributeError, KeyError):
                    annotation = docstring.parent.parameters[name].annotation  # ty:ignore[unresolved-attribute]
                    break
            else:
                if warnings and warn_missing_types:
                    docstring_warning(docstring, new_offset, f"No types or annotations for parameters {names}")
        else:
            annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)

        if default is None:
            for name in names:
                with suppress(AttributeError, KeyError):
                    default = docstring.parent.parameters[name].default  # ty:ignore[unresolved-attribute]
                    break

        if warnings and warn_unknown_params:
            with suppress(AttributeError):  # For Parameters sections in objects without parameters.
                params = docstring.parent.parameters  # ty:ignore[unresolved-attribute]
                for name in names:
                    if name not in params:
                        message = f"Parameter '{name}' does not appear in the function signature"
                        for starred_name in (f"*{name}", f"**{name}"):
                            if starred_name in params:
                                message += f". Did you mean '{starred_name}'?"
                                break
                        docstring_warning(docstring, new_offset, message)

        parameters.extend(
            DocstringParameter(name, value=default, annotation=annotation, description=description) for name in names
        )

    return parameters, new_offset


def _read_parameters_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionParameters | None, int]:
    parameters, new_offset = _read_parameters(docstring, offset=offset, warnings=warnings, **options)
    if parameters:
        return DocstringSectionParameters(parameters), new_offset
    if warnings:
        docstring_warning(docstring, new_offset, f"Empty parameters section at line {offset}")
    return None, new_offset


def _read_other_parameters_section(
    docstring: Docstring,
    *,
    offset: int,
    warn_unknown_params: bool = True,  # noqa: ARG001
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionOtherParameters | None, int]:
    parameters, new_offset = _read_parameters(
        docstring,
        offset=offset,
        warn_unknown_params=False,
        warnings=warnings,
        **options,
    )
    if parameters:
        return DocstringSectionOtherParameters(parameters), new_offset
    if warnings:
        docstring_warning(docstring, new_offset, f"Empty other parameters section at line {offset}")
    return None, new_offset


def _read_type_parameters_section(
    docstring: Docstring,
    *,
    offset: int,
    warn_unknown_params: bool = True,
    **options: Any,
) -> tuple[DocstringSectionTypeParameters | None, int]:
    type_parameters: list[DocstringTypeParameter] = []
    bound: str | Expr | None

    items, new_offset = _read_block_items(docstring, offset=offset, **options)

    for item in items:
        match = _RE_PARAMETER.match(item[0])
        if not match:
            docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
            continue

        names = match.group("names").split(", ")
        bound = match.group("type") or None
        choices = match.group("choices")
        default = None
        if choices:
            bound = choices
            default = choices.split(", ", 1)[0]
        elif bound:
            match = re.match(r"^(?P<annotation>.+),\s+default(?: |: |=)(?P<default>.+)$", bound)
            if match:
                default = match.group("default")
                bound = match.group("annotation")
        description = "\n".join(item[1:]).rstrip() if len(item) > 1 else ""

        if bound is None:
            # try to use the bound from the signature
            for name in names:
                with suppress(AttributeError, KeyError):
                    bound = docstring.parent.type_parameters[name].annotation  # ty:ignore[unresolved-attribute]
                    break
        else:
            bound = parse_docstring_annotation(bound, docstring, log_level=LogLevel.debug)

        if default is None:
            for name in names:
                with suppress(AttributeError, KeyError):
                    default = docstring.parent.type_parameters[name].default  # ty:ignore[unresolved-attribute]
                    break

        if warn_unknown_params:
            with suppress(AttributeError):  # for parameters sections in objects without parameters
                type_params = docstring.parent.type_parameters  # ty:ignore[unresolved-attribute]
                for name in names:
                    if name not in type_params:
                        message = f"Type parameter '{name}' does not appear in the {docstring.parent.kind} signature"  # ty:ignore[unresolved-attribute]
                        for starred_name in (f"*{name}", f"**{name}"):
                            if starred_name in type_params:
                                message += f". Did you mean '{starred_name}'?"
                                break
                        docstring_warning(docstring, new_offset, message)

        type_parameters.extend(
            DocstringTypeParameter(name, value=default, annotation=bound, description=description) for name in names
        )

    if type_parameters:
        return DocstringSectionTypeParameters(type_parameters), new_offset

    docstring_warning(docstring, new_offset, f"Empty type parameters section at line {offset}")
    return None, new_offset


def _read_deprecated_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionDeprecated | None, int]:
    # deprecated
    # SINCE_VERSION
    #     TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty deprecated section at line {offset}")
        return None, new_offset

    if warnings and len(items) > 1:
        docstring_warning(docstring, new_offset, f"Too many deprecated items at {offset}")

    item = items[0]
    version = item[0]
    text = dedent("\n".join(item[1:]))
    return DocstringSectionDeprecated(version=version, text=text), new_offset


def _read_returns_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionReturns | None, int]:
    # (NAME : )?TYPE
    #     TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty returns section at line {offset}")
        return None, new_offset

    returns = []
    for index, item in enumerate(items):
        match = _RE_RETURNS.match(item[0])
        if not match:
            if warnings:
                docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
            continue

        groups = match.groupdict()
        name = groups["nt_name"] or groups["name"]
        annotation = groups["nt_type"] or groups["type"]
        text = dedent("\n".join(item[1:]))
        if annotation is None:
            # try to retrieve the annotation from the docstring parent
            with suppress(AttributeError, KeyError, ValueError):
                if docstring.parent.is_function:  # ty:ignore[unresolved-attribute]
                    annotation = docstring.parent.returns  # ty:ignore[unresolved-attribute]
                elif docstring.parent.is_attribute:  # ty:ignore[unresolved-attribute]
                    annotation = docstring.parent.annotation  # ty:ignore[unresolved-attribute]
                else:
                    raise ValueError
                if len(items) > 1:
                    if annotation.is_tuple:
                        annotation = annotation.slice.elements[index]
                    else:
                        if annotation.is_iterator:
                            return_item = annotation.slice
                        elif annotation.is_generator:
                            return_item = annotation.slice.elements[2]
                        else:
                            raise ValueError
                        if isinstance(return_item, ExprName):
                            annotation = return_item
                        elif return_item.is_tuple:
                            annotation = return_item.slice.elements[index]
                        else:
                            annotation = return_item
        else:
            annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)
        returns.append(DocstringReturn(name=name or "", annotation=annotation, description=text))
    return DocstringSectionReturns(returns), new_offset


def _read_yields_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionYields | None, int]:
    # yields
    # (NAME : )?TYPE
    #     TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty yields section at line {offset}")
        return None, new_offset

    yields = []
    for index, item in enumerate(items):
        match = _RE_YIELDS.match(item[0])
        if not match:
            if warnings:
                docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
            continue

        groups = match.groupdict()
        name = groups["nt_name"] or groups["name"]
        annotation = groups["nt_type"] or groups["type"]
        text = dedent("\n".join(item[1:]))
        if annotation is None:
            # try to retrieve the annotation from the docstring parent
            with suppress(AttributeError, IndexError, KeyError, ValueError):
                annotation = docstring.parent.annotation  # ty:ignore[unresolved-attribute]
                if annotation.is_iterator:
                    yield_item = annotation.slice
                elif annotation.is_generator:
                    yield_item = annotation.slice.elements[0]
                else:
                    raise ValueError
                if isinstance(yield_item, ExprName):
                    annotation = yield_item
                elif yield_item.is_tuple:
                    annotation = yield_item.slice.elements[index]
                else:
                    annotation = yield_item
        else:
            annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)
        yields.append(DocstringYield(name=name or "", annotation=annotation, description=text))
    return DocstringSectionYields(yields), new_offset


def _read_receives_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionReceives | None, int]:
    # receives
    # (NAME : )?TYPE
    #     TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty receives section at line {offset}")
        return None, new_offset

    receives = []
    for index, item in enumerate(items):
        match = _RE_RECEIVES.match(item[0])
        if not match:
            if warnings:
                docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
            continue

        groups = match.groupdict()
        name = groups["nt_name"] or groups["name"]
        annotation = groups["nt_type"] or groups["type"]
        text = dedent("\n".join(item[1:]))
        if annotation is None:
            # try to retrieve the annotation from the docstring parent
            with suppress(AttributeError, KeyError):
                annotation = docstring.parent.returns  # ty:ignore[unresolved-attribute]
                if annotation.is_generator:
                    receives_item = annotation.slice.elements[1]
                    if isinstance(receives_item, ExprName):
                        annotation = receives_item
                    elif receives_item.is_tuple:
                        annotation = receives_item.slice.elements[index]
                    else:
                        annotation = receives_item
        else:
            annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)
        receives.append(DocstringReceive(name=name or "", annotation=annotation, description=text))
    return DocstringSectionReceives(receives), new_offset


def _read_raises_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionRaises | None, int]:
    # raises
    # EXCEPTION
    #     TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty raises section at line {offset}")
        return None, new_offset

    raises = []
    for item in items:
        annotation = parse_docstring_annotation(item[0], docstring)
        text = dedent("\n".join(item[1:]))
        raises.append(DocstringRaise(annotation=annotation, description=text))
    return DocstringSectionRaises(raises), new_offset


def _read_warns_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionWarns | None, int]:
    # warns
    # WARNING
    #     TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty warns section at line {offset}")
        return None, new_offset

    warns = []
    for item in items:
        annotation = parse_docstring_annotation(item[0], docstring)
        text = dedent("\n".join(item[1:]))
        warns.append(DocstringWarn(annotation=annotation, description=text))
    return DocstringSectionWarns(warns), new_offset


def _read_attributes_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionAttributes | None, int]:
    # attributes (for classes)
    # NAME( : TYPE)?
    #    TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty attributes section at line {offset}")
        return None, new_offset

    annotation: str | Expr | None
    attributes = []
    for item in items:
        name_type = item[0]
        if ":" in name_type:
            name, annotation = name_type.split(":", 1)
            name = name.strip()
            annotation = annotation.strip() or None
        else:
            name = name_type
            annotation = None
        if annotation is None:
            with suppress(AttributeError, KeyError, TypeError):
                # Use subscript syntax to fetch annotation from inherited members too.
                annotation = docstring.parent[name].annotation  # ty:ignore[not-subscriptable]
        else:
            annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)
        text = dedent("\n".join(item[1:]))
        attributes.append(DocstringAttribute(name=name, annotation=annotation, description=text))
    return DocstringSectionAttributes(attributes), new_offset


def _read_functions_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionFunctions | None, int]:
    # SIGNATURE
    #    TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty functions/methods section at line {offset}")
        return None, new_offset

    functions = []
    signature: str | Expr | None
    for item in items:
        name_signature = item[0]
        if "(" in name_signature:
            name = name_signature.split("(", 1)[0]
            name = name.strip()
            signature = name_signature.strip()
        else:
            name = name_signature
            signature = None
        text = dedent("\n".join(item[1:])).strip()
        functions.append(DocstringFunction(name=name, annotation=signature, description=text))
    return DocstringSectionFunctions(functions), new_offset


def _read_classes_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionClasses | None, int]:
    # SIGNATURE
    #    TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty classes section at line {offset}")
        return None, new_offset

    classes = []
    signature: str | Expr | None
    for item in items:
        name_signature = item[0]
        if "(" in name_signature:
            name = name_signature.split("(", 1)[0]
            name = name.strip()
            signature = name_signature.strip()
        else:
            name = name_signature
            signature = None
        text = dedent("\n".join(item[1:])).strip()
        classes.append(DocstringClass(name=name, annotation=signature, description=text))
    return DocstringSectionClasses(classes), new_offset


def _read_type_aliases_section(
    docstring: Docstring,
    *,
    offset: int,
    **options: Any,
) -> tuple[DocstringSectionTypeAliases | None, int]:
    items, new_offset = _read_block_items(docstring, offset=offset, **options)

    if not items:
        docstring_warning(docstring, new_offset, f"Empty type aliases section at line {offset}")
        return None, new_offset

    type_aliases = []
    for item in items:
        name = item[0]
        text = dedent("\n".join(item[1:])).strip()
        type_aliases.append(DocstringTypeAlias(name=name, description=text))
    return DocstringSectionTypeAliases(type_aliases), new_offset


def _read_modules_section(
    docstring: Docstring,
    *,
    offset: int,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionModules | None, int]:
    # NAME
    #    TEXT?
    items, new_offset = _read_block_items(docstring, offset=offset, warnings=warnings, **options)

    if not items:
        if warnings:
            docstring_warning(docstring, new_offset, f"Empty modules section at line {offset}")
        return None, new_offset

    modules = []
    signature: str | Expr | None
    for item in items:
        name_signature = item[0]
        if "(" in name_signature:
            name = name_signature.split("(", 1)[0]
            name = name.strip()
            signature = name_signature.strip()
        else:
            name = name_signature
            signature = None
        text = dedent("\n".join(item[1:])).strip()
        modules.append(DocstringModule(name=name, annotation=signature, description=text))
    return DocstringSectionModules(modules), new_offset


def _read_examples_section(
    docstring: Docstring,
    *,
    offset: int,
    trim_doctest_flags: bool = True,
    warnings: bool = True,
    **options: Any,
) -> tuple[DocstringSectionExamples | None, int]:
    text, new_offset = _read_block(docstring, offset=offset, warnings=warnings, **options)

    sub_sections: list[tuple[Literal[DocstringSectionKind.text, DocstringSectionKind.examples], str]] = []
    in_code_example = False
    in_code_block = False
    current_text: list[str] = []
    current_example: list[str] = []

    for line in text.split("\n"):
        if _is_empty_line(line):
            if in_code_example:
                if current_example:
                    sub_sections.append((DocstringSectionKind.examples, "\n".join(current_example)))
                    current_example = []
                in_code_example = False
            else:
                current_text.append(line)

        elif in_code_example:
            if trim_doctest_flags:
                line = _RE_DOCTEST_FLAGS.sub("", line)  # noqa: PLW2901
                line = _RE_DOCTEST_BLANKLINE.sub("", line)  # noqa: PLW2901
            current_example.append(line)

        elif line.startswith("```"):
            in_code_block = not in_code_block
            current_text.append(line)

        elif in_code_block:
            current_text.append(line)

        elif line.startswith(">>>"):
            if current_text:
                sub_sections.append((DocstringSectionKind.text, "\n".join(current_text).rstrip("\n")))
                current_text = []
            in_code_example = True

            if trim_doctest_flags:
                line = _RE_DOCTEST_FLAGS.sub("", line)  # noqa: PLW2901
            current_example.append(line)

        else:
            current_text.append(line)

    if current_text:
        sub_sections.append((DocstringSectionKind.text, "\n".join(current_text).rstrip("\n")))
    elif current_example:
        sub_sections.append((DocstringSectionKind.examples, "\n".join(current_example)))

    if sub_sections:
        return DocstringSectionExamples(sub_sections), new_offset

    if warnings:
        docstring_warning(docstring, new_offset, f"Empty examples section at line {offset}")
    return None, new_offset


def _append_section(sections: list, current: list[str], admonition_title: str) -> None:
    if admonition_title:
        kind = admonition_title.lower().replace(" ", "-")
        if kind in ("warnings", "notes"):
            # NumpyDoc sections are pluralised but admonitions aren't.
            # We can special-case these explicitly so that it renders
            # as one would expect.
            kind = kind[:-1]
        sections.append(
            DocstringSectionAdmonition(
                kind=kind,
                text="\n".join(current).rstrip("\n"),
                title=admonition_title,
            ),
        )
    elif current and any(current):
        sections.append(DocstringSectionText("\n".join(current).rstrip("\n")))


_section_reader = {
    DocstringSectionKind.parameters: _read_parameters_section,
    DocstringSectionKind.other_parameters: _read_other_parameters_section,
    DocstringSectionKind.type_parameters: _read_type_parameters_section,
    DocstringSectionKind.deprecated: _read_deprecated_section,
    DocstringSectionKind.raises: _read_raises_section,
    DocstringSectionKind.warns: _read_warns_section,
    DocstringSectionKind.examples: _read_examples_section,
    DocstringSectionKind.attributes: _read_attributes_section,
    DocstringSectionKind.functions: _read_functions_section,
    DocstringSectionKind.classes: _read_classes_section,
    DocstringSectionKind.type_aliases: _read_type_aliases_section,
    DocstringSectionKind.modules: _read_modules_section,
    DocstringSectionKind.returns: _read_returns_section,
    DocstringSectionKind.yields: _read_yields_section,
    DocstringSectionKind.receives: _read_receives_section,
}


class NumpyOptions(TypedDict, total=False):
    """Options for parsing Numpydoc-style docstrings."""

    ignore_init_summary: bool
    """Whether to ignore the summary in `__init__` methods' docstrings."""
    trim_doctest_flags: bool
    """Whether to remove doctest flags from Python example blocks."""
    warn_unknown_params: bool
    """Whether to warn about unknown parameters."""
    warn_missing_types: bool
    """Whether to warn about missing types/annotations for parameters, return values, etc."""
    warnings: bool
    """Whether to issue warnings for parsing issues."""


def parse_numpy(
    docstring: Docstring,
    *,
    ignore_init_summary: bool = False,
    trim_doctest_flags: bool = True,
    warn_unknown_params: bool = True,
    warn_missing_types: bool = True,
    warnings: bool = True,
) -> list[DocstringSection]:
    """Parse a Numpydoc-style docstring.

    This function iterates on lines of a docstring to build sections.
    It then returns this list of sections.

    Parameters:
        docstring: The docstring to parse.
        ignore_init_summary: Whether to ignore the summary in `__init__` methods' docstrings.
        trim_doctest_flags: Whether to remove doctest flags from Python example blocks.
        warn_unknown_params: Warn about documented parameters not appearing in the signature.
        warn_missing_types: Warn about missing types/annotations for parameters, return values, etc.
        warnings: Whether to log warnings at all.

    Returns:
        A list of docstring sections.
    """
    sections: list[DocstringSection] = []
    current_section = []
    admonition_title = ""

    in_code_block = False
    lines = docstring.lines

    options = {
        "trim_doctest_flags": trim_doctest_flags,
        "ignore_init_summary": ignore_init_summary,
        "warn_unknown_params": warn_unknown_params,
        "warn_missing_types": warn_missing_types,
        "warnings": warnings,
    }

    ignore_summary = (
        options["ignore_init_summary"]
        and docstring.parent is not None
        and docstring.parent.name == "__init__"
        and docstring.parent.is_function
        and docstring.parent.parent is not None
        and docstring.parent.parent.is_class
    )

    offset = 2 if ignore_summary else 0

    while offset < len(lines):
        line_lower = lines[offset].lower()

        # Code blocks can contain dash lines that we must not interpret.
        if in_code_block:
            # End of code block.
            if line_lower.lstrip(" ").startswith("```"):
                in_code_block = False
            # Lines in code block must not be interpreted in any way.
            current_section.append(lines[offset])

        # Start of code block.
        elif line_lower.lstrip(" ").startswith("```"):
            in_code_block = True
            current_section.append(lines[offset])

        # Dash lines after empty lines lose their meaning.
        elif _is_empty_line(lines[offset]):
            current_section.append("")

        # End of the docstring, wrap up.
        elif offset == len(lines) - 1:
            current_section.append(lines[offset])
            _append_section(sections, current_section, admonition_title)
            admonition_title = ""
            current_section = []

        # Dash line after regular, non-empty line.
        elif _is_dash_line(lines[offset + 1]):
            # Finish reading current section.
            _append_section(sections, current_section, admonition_title)
            current_section = []

            # Start parsing new (known) section.
            if line_lower in _section_kind:
                admonition_title = ""
                reader = _section_reader[_section_kind[line_lower]]
                section, offset = reader(docstring, offset=offset + 2, **options)
                if section:
                    sections.append(section)

            # Start parsing admonition.
            else:
                admonition_title = lines[offset]
                offset += 1  # Skip next dash line.

        # Regular line.
        else:
            current_section.append(lines[offset])

        offset += 1

    # Finish current section.
    _append_section(sections, current_section, admonition_title)

    return sections
