import dataclasses
import inspect
import json
import logging
import math
import re
import socket
import time
import urllib.parse
import urllib.request
from enum import Enum

logger = logging.getLogger(__name__)

if "agora_added_arg" not in inspect.signature(socket.getaddrinfo).parameters:
    orig_getaddrinfo = socket.getaddrinfo

    def getaddrinfo_ipv4(*args, agora_added_arg=None, **kwargs):
        return [info for info in orig_getaddrinfo(*args, **kwargs) if info[0] == socket.AF_INET]

    socket.getaddrinfo = getaddrinfo_ipv4


# Constant keys used in the forum JSON response
FORUM_CREATED_DATE = "created_at"
FORUM_ID = "id"
FORUM_POSTS = "posts"
FORUM_POST_ACCEPTED = "accepted_answer"
FORUM_POST_BLURB = "blurb"
FORUM_POST_CONTENT = "cooked"
FORUM_POST_LIKES = "like_count"
FORUM_POST_NUMBER = "post_number"
FORUM_POST_STREAM = "post_stream"
FORUM_POST_TOPIC_ID = "topic_id"
FORUM_POST_UPDATED_DATE = "updated_at"
FORUM_POST_USER = "username"
FORUM_POST_USER_TITLE = "user_title"
FORUM_TOPICS = "topics"
FORUM_TOPIC_ARCHIVED = "archived"
FORUM_TOPIC_CLOSED = "closed"
FORUM_TOPIC_FANCY_TITLE = "fancy_title"
FORUM_TOPIC_LAST_MODIFIED_DATE = "last_posted_at"
FORUM_TOPIC_NUM_POSTS = "posts_count"
FORUM_TOPIC_PINNED = "pinned"
FORUM_TOPIC_SOLVED = "has_accepted_answer"
FORUM_TOPIC_TAGS = "tags"
FORUM_TOPIC_TITLE = "title"

# Host link of the forum
FORUM_HOST_LINK = "forums.developer.nvidia.com"

# Links to search the forum
ForumSearchLink = f"https://{FORUM_HOST_LINK}/search?q={{query}}"
ForumSearchJsonLink = f"https://{FORUM_HOST_LINK}/search.json?q={{query}}"
ForumTopicJsonLink = f"https://{FORUM_HOST_LINK}/t/{{topicId}}.json"
DiscourseAiSemanticSearchLink = f"https://{FORUM_HOST_LINK}/discourse-ai/embeddings/semantic-search?hyde=false&q={{query}}"
DiscourseAiSemanticSearchJsonLink = f"https://{FORUM_HOST_LINK}/discourse-ai/embeddings/semantic-search.json?hyde=false&q={{query}}"


class LogException(Exception):

    """
    Exception class to log an error message

    :param message: The error message to log
    :param log: If True, log the error message to the console. Default: True
    """

    def __init__(self, message, log=True):
        if log:
            logger.error(message)
        super().__init__(message)


class TopicStatus(str, Enum):

    """
    Enum for the status of a topic

    Choices:
    - Open: The topic is open
    - Closed: The topic is closed
    - Public: The topic is public
    - Archived: The topic is archived
    - NoReplies: The topic has no replies
    - SingleUser: The topic involves a single user
    - Solved: The topic is solved
    - Unsolved: The topic is unsolved
    """

    Open = 'open'
    Closed = 'closed'
    Public = 'public'
    Archived = 'archived'
    NoReplies = 'noreplies'
    SingleUser = 'single_user'
    Solved = 'solved'
    Unsolved = 'unsolved'

class SortBy(str, Enum):

    """
    Enum for the sorting order of topics

    Choices:
    - Relevance: Sort by relevance of the topic
    - LatestPost: Sort by the latest post in the topic
    - MostLiked: Sort by the number of likes the topic has
    - MostViewed: Sort by the number of views the topic has
    - LatestTopic: Sort by the latest topic created
    - MostVotes: Sort by the number of votes the topic has
    """

    Relevance = 'relevance'
    LatestPost = 'latest'
    MostLiked = 'likes'
    MostViewed = 'views'
    LatestTopic = 'latest_topic'
    MostVotes = 'votes'

@dataclasses.dataclass
class ForumQuery:

    """
    Data class to hold the query and filter options for searching topics in the forum

    :param query: The query string to search for on the forum (optional)
    :param user: The username of the user who posted the topic (optional)
    :param category: The category to search in (optional)
    :param tags: The tags to search for (optional)
    :param afterDate: The date to search after. Format: "YYYY-MM-DD" (optional)
    :param beforeDate: The date to search before. Format: "YYYY-MM-DD" (optional)
    :param status: The status of the topics to search for (optional)
    :param useAllTags: If True, all tags from the list should be present in the topic. If False, any tag from the list should be present. Default: False
    :param sortBy: The sorting order of the topics (optional)

    Note: At least one non-default parameter is required to create a non-empty query string
    """

    query: str = None
    user: str = None
    category: str = None
    tags: list[str] = None
    afterDate: str = None
    beforeDate: str = None
    status: TopicStatus = None
    useAllTags: bool = False
    sortBy: SortBy = SortBy.Relevance

    def __GetQuery(self):
        return f"{self.query} " if self.query else ""

    def __GetUser(self):
        return f"@{self.user} " if self.user else ""

    def __GetCategory(self):
        return f"#{self.category} " if self.category else ""

    def __GetTags(self):
        return f"tags:{'+'.join(self.tags) if self.useAllTags else ','.join(self.tags)} " if self.tags else ""

    def __GetAfterDate(self):
        return f"after:{self.afterDate} " if self.afterDate else ""

    def __GetBeforeDate(self):
        return f"before:{self.beforeDate} " if self.beforeDate else ""

    def __GetStatus(self):
        return f"status:{''.join(self.status)} " if self.status else ""

    def __GetSortBy(self):
        return f"order:{''.join(self.sortBy)} " if self.sortBy and self.sortBy != SortBy.Relevance else ""

    # create query string to be used in the URL
    def __str__(self):
        return (
            f"{self.__GetQuery()}"
            f"{self.__GetUser()}"
            f"{self.__GetCategory()}"
            f"{self.__GetTags()}"
            f"{self.__GetAfterDate()}"
            f"{self.__GetBeforeDate()}"
            f"{self.__GetStatus()}"
            f"{self.__GetSortBy()}"
        ).strip()


def _normalize_tags(raw_tags):
    """Convert Discourse API tags (list of dicts or strings) to list[str]."""
    result = []
    for t in raw_tags:
        if isinstance(t, dict):
            result.append(t.get("name") or t.get("slug") or "")
        else:
            result.append(t)
    return result


@dataclasses.dataclass
class ForumTopic:

    """
    Data class to hold the properties of a topic in the forum

    Attributes:
    - id: The topic id
    - title: The title of the topic. If not available, the fancy title is used
    - link: The link to the topic
    - solved: If the topic is solved
    - closed: If the topic is closed
    - pinned: If the topic is pinned
    - archived: If the topic is archived
    - createdDate: The date the topic was created. Format: "YYYY-MM-DDTHH:MM:SS.ZZZZ" (ISO 8601)
    - lastModifiedDate: The date the topic was last updated or when the last post was made in the topic. Format: "YYYY-MM-DDTHH:MM:SS.ZZZZ" (ISO 8601)
    - tags: The tags of the topic
    - numPosts: The number of posts in the topic
    - postIndex: The index of the top post in the topic
    - user: The user who posted the top post in the topic
    - blurb: The blurb (short elided description) of the top post in the topic
    - likes: The number of likes the top post in the topic has
    - isValid: If the topic is valid

    Note: If any property is not found in the forum response topic data while creating the object,
    the topic is marked as invalid and an exception is raised. If the topic is invalid,
    accessing some properties may cause an exception
    """

    id: int
    title: str
    link: str
    solved: bool
    closed: bool
    pinned: bool
    archived: bool
    createdDate: str
    lastModifiedDate: str
    tags: list[str]
    numPosts: int

    # Properties from the top post of the topic
    postIndex: int
    user: str
    blurb: str
    likes: int
    isValid: bool = False

    # Link to the topic
    ForumTopicLink = f"https://{FORUM_HOST_LINK}/t/{{topicId}}"

    def __RaisePropException(self, prop):
        raise LogException(f"No '{prop}' found in" + (f" post '#{self.postIndex}' of" if self.postIndex else "") + f" topic '{self.id}'")

    def __init__(self, topic: dict, topTopicPost: dict):

        """Create a ForumTopic object from the topic data and the top post data dictionary

        :param topic: The topic data dictionary from the forum response
            A topic data dictionary should contain the following properties:
            - "id": The topic id
            - "title" or "fancy_title": The title of the topic
            - "has_accepted_answer": If the topic is solved
            - "closed": If the topic is closed
            - "pinned": If the topic is pinned
            - "archived": If the topic is archived
            - "created_at": The date the topic was created
            - "last_posted_at": The date the topic was last modified
            - "tags": The tags of the topic
            - "posts_count": The number of posts in the topic

        :param topTopicPost: The dictionary of top posts of each topic from the forum response
            A top post data dictionary should contain the following properties:
            - "post_number": The index of the post in the topic
            - "username": The username of the user who posted the top post
            - "blurb": The short elided description of the top post
            - "like_count": The number of likes the top post has

        :raises LogException: If the topic data is invalid or any property is not found
        """

        if not topic:
            raise LogException("Invalid topic")

        self.id = topic[FORUM_ID] if FORUM_ID in topic else self.__RaisePropException(FORUM_ID)

        # Get all properties from the topPost of this topic
        topPost = topTopicPost[self.id] if self.id in topTopicPost else None
        if topPost:
            self.postIndex = topPost[FORUM_POST_NUMBER] if FORUM_POST_NUMBER in topPost else self.__RaisePropException(FORUM_POST_NUMBER)
            self.user = topPost[FORUM_POST_USER] if FORUM_POST_USER in topPost else self.__RaisePropException(FORUM_POST_USER)
            self.blurb = topPost[FORUM_POST_BLURB] if FORUM_POST_BLURB in topPost else self.__RaisePropException(FORUM_POST_BLURB)
            self.likes = topPost[FORUM_POST_LIKES] if FORUM_POST_LIKES in topPost else self.__RaisePropException(FORUM_POST_LIKES)
        else:
            raise LogException(f"No top post found to fetch the user, blurb and likes for topic {self.id}")

        # Now get the rest of the properties from the topic
        if FORUM_TOPIC_TITLE in topic:
            self.title = topic[FORUM_TOPIC_TITLE]
        elif FORUM_TOPIC_FANCY_TITLE in topic:
            self.title = topic[FORUM_TOPIC_FANCY_TITLE]
        else:
            self.__RaisePropException(FORUM_TOPIC_TITLE)

        self.link = self.ForumTopicLink.format(topicId=self.id)
        self.solved = topic[FORUM_TOPIC_SOLVED] if FORUM_TOPIC_SOLVED in topic else self.__RaisePropException(FORUM_TOPIC_SOLVED)
        self.closed = topic[FORUM_TOPIC_CLOSED] if FORUM_TOPIC_CLOSED in topic else self.__RaisePropException(FORUM_TOPIC_CLOSED)
        self.pinned = topic[FORUM_TOPIC_PINNED] if FORUM_TOPIC_PINNED in topic else self.__RaisePropException(FORUM_TOPIC_PINNED)
        self.archived = topic[FORUM_TOPIC_ARCHIVED] if FORUM_TOPIC_ARCHIVED in topic else self.__RaisePropException(FORUM_TOPIC_ARCHIVED)
        self.createdDate = topic[FORUM_CREATED_DATE] if FORUM_CREATED_DATE in topic else self.__RaisePropException(FORUM_CREATED_DATE)
        self.lastModifiedDate = topic[FORUM_TOPIC_LAST_MODIFIED_DATE] if FORUM_TOPIC_LAST_MODIFIED_DATE in topic else self.__RaisePropException(FORUM_TOPIC_LAST_MODIFIED_DATE)
        raw_tags = topic[FORUM_TOPIC_TAGS] if FORUM_TOPIC_TAGS in topic else self.__RaisePropException(FORUM_TOPIC_TAGS)
        self.tags = _normalize_tags(raw_tags)
        self.numPosts = topic[FORUM_TOPIC_NUM_POSTS] if FORUM_TOPIC_NUM_POSTS in topic else self.__RaisePropException(FORUM_TOPIC_NUM_POSTS)
        self.isValid = True

@dataclasses.dataclass
class ForumPost:

    """
    Data class to hold the properties of a post in the forum

    Attributes:
    - id: The post id
    - user: Username of the user who posted
    - postIndex: The index of the post in the topic
    - userTitle: The title of the user (moderator, admin, etc.)
    - link: The link to the post
    - content: The content of the post
    - isHTML: If true, the content is raw HTML. Otherwise, the content is parsed text
    - accepted: If the post is the solution
    - createdDate: The date the post was created. Format: "YYYY-MM-DDTHH:MM:SS.ZZZZ" (ISO 8601)
    - updatedDate: The date the post was last modified. Format: "YYYY-MM-DDTHH:MM:SS.ZZZZ" (ISO 8601)
    - topicId: Id of the topic this post belongs to
    - isValid: If the post is valid (see note below)

    Note: If any property is not found in the forum response post data while creating the object,
    the post is marked as invalid and an exception is raised. If the post is invalid,
    accessing some properties may cause an exception
    """

    id: int
    user: str
    postIndex: int
    userTitle: str
    link: str
    content: str
    isHTML: bool
    accepted: bool
    createdDate: str
    updatedDate: str
    topicId: int
    isValid: bool = False

    # Link to the post
    ForumPostLink = f"https://{FORUM_HOST_LINK}/t/{{topicId}}/{{postIndex}}"

    def __RaisePropException(self, prop):
        raise LogException(f"No '{prop}' found in post '#{self.postIndex}'" + (f" of topic '{self.topicId}'" if self.topicId else ""))

    def __init__(self, post: dict, isHTML: bool):

        """Create a ForumPost object from the post data dictionary

        :param post: The post data dictionary from the forum response
            A post data dictionary should contain the following properties:
            - "id": The post id
            - "username": The username of the user who posted
            - "post_number": The index of the post in the topic
            - "user_title": The title of the user (moderator, admin, etc.)
            - "cooked": The content of the post
            - "accepted_answer": If the post is the solution
            - "created_at": The date the post was created
            - "updated_at": The date the post was last modified
            - "topic_id": The id of the topic this post belongs to

        :param isHTML: If True, the content of the post is raw HTML. If False, the content is parsed text

        :raises LogException: If the post data is invalid or any property is not found
        """

        if not post:
            raise LogException("Invalid post")

        self.postIndex = post[FORUM_POST_NUMBER] if FORUM_POST_NUMBER in post else self.__RaisePropException(FORUM_POST_NUMBER)
        self.id = post[FORUM_ID] if FORUM_ID in post else self.__RaisePropException(FORUM_ID)
        self.topicId = post[FORUM_POST_TOPIC_ID] if FORUM_POST_TOPIC_ID in post else self.__RaisePropException(FORUM_POST_TOPIC_ID)
        self.user = post[FORUM_POST_USER] if FORUM_POST_USER in post else self.__RaisePropException(FORUM_POST_USER)
        self.userTitle = post[FORUM_POST_USER_TITLE] if FORUM_POST_USER_TITLE in post else self.__RaisePropException(FORUM_POST_USER_TITLE)
        self.link = self.ForumPostLink.format(topicId=self.topicId, postIndex=self.postIndex)
        self.isHTML = True
        self.content = post[FORUM_POST_CONTENT] if FORUM_POST_CONTENT in post else self.__RaisePropException(FORUM_POST_CONTENT)
        self.accepted = post[FORUM_POST_ACCEPTED] if FORUM_POST_ACCEPTED in post else self.__RaisePropException(FORUM_POST_ACCEPTED)
        self.createdDate = post[FORUM_CREATED_DATE] if FORUM_CREATED_DATE in post else self.__RaisePropException(FORUM_CREATED_DATE)
        self.updatedDate = post[FORUM_POST_UPDATED_DATE] if FORUM_POST_UPDATED_DATE in post else self.__RaisePropException(FORUM_POST_UPDATED_DATE)

        if not isHTML:
            # Parse the HTML content to get the text
            try:
                from bs4 import BeautifulSoup
                soup = BeautifulSoup(self.content, 'html.parser')
                self.content = soup.get_text()
                self.isHTML = False
            except ImportError as e:
                raise LogException(f"BeautifulSoup is required to parse HTML content. Please install it using 'pip install beautifulsoup4' or set isHTML=True.\nError: {e}")

        self.isValid = True


"""
Functions to search the NVIDIA developer forum (https://forums.developer.nvidia.com/) for topics, posts and solutions
"""

def GetJsonResponse(url: str, delay: int = 0):

    """
    Get the JSON response from the forum for the passed URL
    If the URL returns a 429 (too many requests) error, the request is retried using exponential backoff
    :param url: The URL to get the JSON response from
    :param delay: The delay in seconds before retrying the request in case of too many requests. Default: 0

    :return: The JSON response data

    :raises Exception: If an error occurs while fetching the JSON response
    """

    # Max wait time (in seconds) for the forum request in case of too many requests
    RATE_LIMIT_WAIT_TIME = 16
    RATE_LIMIT_RETRIES = 4

    retry = False
    try:
        if delay > 0:
            minDelay = min(delay, RATE_LIMIT_WAIT_TIME)
            logger.info(f"Retrying after {minDelay} seconds")
            time.sleep(minDelay)

        response = urllib.request.urlopen(url, timeout=5)
        if not response:
            raise LogException("Failed to get response from forum with URL: " + url)

        page = response.read()
        if not page:
            raise LogException("Error occurred while reading response from forum")

        data = json.loads(page)
        return data

    except urllib.error.HTTPError as e:
        if e.code == 429:
            # Too many requests - increase the delay exponentially and retry
            # Exponential backoff: 1, 2, 4, 8, 16, 16, ... till RATE_LIMIT_RETRIES
            delay = (2 * delay if delay > 0 else 1)
            rateLimitTries = math.log(delay / RATE_LIMIT_WAIT_TIME, 2)
            if rateLimitTries < RATE_LIMIT_RETRIES:
                retry = True

        if not retry:
            raise LogException(f"HTTP Error occurred while opening url {url}: {e}")

    except urllib.error.URLError as e:
        raise LogException(f"URL Error occurred while opening url {url}: {e}")

    except json.JSONDecodeError as e:
        raise LogException(f"Could not parse JSON response: {e}")

    if retry:
        logger.warning("Too many requests!")
        return GetJsonResponse(url, delay)

def Search(
        query: str = None,
        category: str = None,
        sortBy: SortBy = SortBy.Relevance,
        status: TopicStatus = None,
        beforeDate: str = None,
        afterDate: str = None,
        tags: list[str] = None,
        useAllTags: bool = False,
        user: str = None,
        limit: int = None,
        useAI: bool = False,
) -> list[ForumTopic]:

    """
    Get all topics from the forum that match the query and filter options passed.
    If no query is passed, all topics on the forum matching the other filter options are returned.
    At least one non-default parameter is required to successfully search for topics.

    :param query: The query to search for. Example: 'profiling error'. If empty, all topics matching the other parameters returned.
    :param category: The category to search in (optional). Example: 'developer-tools:nsight-compute'
    :param sortBy: The sorting order of the topics (optional). Default: relevance. Choices: "latest", "likes", "views", "latest_topic", "votes"
    :param status: The status of the topics to search for (optional). Default: any. Choices: "open", "closed", "public", "archived", "noreplies", "single_user", "solved", "unsolved"
    :param beforeDate: The date to search before (optional). Format: "YYYY-MM-DD"
    :param afterDate: The date to search after (optional). Format: "YYYY-MM-DD"
    :param tags: The tags to search for (optional). At least one tag from the list should be present in the topic. Example: ['cuda', 'nsight-compute']
    :param useAllTags: If True, all tags from the list should be present in the topic. If False, any tag from the list should be present. Default: False
    :param user: Username of the user who posted the topic (optional).
    :param limit: The maximum number of topics to return (optional). Default: None
    :param useAI: If True, use discourse AI semantic search. Default: False

    :return: A list of ForumTopic objects that match the search criteria or an empty list if no topics found

    :raises Exception: If an error occurs while fetching the topics

    Example:
    forumTopics = ForumSearcher().Search(
        query="profiling error",
        category="developer-tools:nsight-compute",
        sortBy=SortBy.LatestPost,
        status=TopicStatus.Solved,
        beforeDate="2021-12-31",
        afterDate="2021-01-01",
        tags=["cuda", "nsight-compute"],
        useAllTags=True,
        user="john_doe",
        limit=10,
        useAI=True
    )
    """

    # validate parameters
    if beforeDate and not re.match(r"\d{4}-\d{2}-\d{2}", beforeDate):
        raise LogException("Invalid date format for beforeDate. Expected format: 'YYYY-MM-DD'")

    if afterDate and not re.match(r"\d{4}-\d{2}-\d{2}", afterDate):
        raise LogException("Invalid date format for afterDate. Expected format: 'YYYY-MM-DD'")

    if limit and limit <= 0:
        raise LogException("Invalid limit. Limit should be greater than 0")

    if sortBy and sortBy not in list(SortBy):
        raise LogException("Invalid sortBy. Choices: " + ", ".join(list(SortBy)))

    if status and status not in list(TopicStatus):
        raise LogException("Invalid status. Choices: " + ", ".join(list(TopicStatus)))

    forumQuery = ForumQuery(
        query=query,
        user=user,
        category=category,
        tags=tags,
        afterDate=afterDate,
        beforeDate=beforeDate,
        status=status,
        useAllTags=useAllTags,
        sortBy=sortBy
    )

    queryStr = str(forumQuery)
    if not queryStr:
        raise LogException("At least one non-default parameter is required!")

    url = None

    # if useAI is True, use the discourse AI semantic search
    if useAI:
        url = DiscourseAiSemanticSearchJsonLink.format(query=urllib.parse.quote_plus(queryStr))
        logger.info(f"Query URL: {DiscourseAiSemanticSearchLink.format(query=urllib.parse.quote_plus(queryStr))}")
    else:
        url = ForumSearchJsonLink.format(query=urllib.parse.quote_plus(queryStr))
        logger.info(f"Query URL: {ForumSearchLink.format(query=urllib.parse.quote_plus(queryStr))}")

    data = GetJsonResponse(url)
    if FORUM_TOPICS not in data:
        # this is possible when no topics are found for the query - not an exception
        logger.warning("No topics found in response")
        return []

    # get top post of each topic
    topTopicPost = {}
    if FORUM_POSTS not in data:
        raise LogException("No top posts found in response")
    else:
        for post in data[FORUM_POSTS]:
            if FORUM_POST_TOPIC_ID in post and post[FORUM_POST_TOPIC_ID] not in topTopicPost:
                topTopicPost[post[FORUM_POST_TOPIC_ID]] = post

    forumTopics = []
    for topic in data[FORUM_TOPICS]:
        if limit and len(forumTopics) >= limit:
            break
        forumTopics.append(ForumTopic(topic, topTopicPost))

    return forumTopics


def GetPosts(
        topic: ForumTopic,
        isHTML: bool = False,
        limit: int = None
) -> list[ForumPost]:

    """
    Get all posts from the forum that are part of the passed topic
    :param topic: The topic to get posts for
    :param isHTML: If True, the content of the post would be raw HTML. If False, the content would be parsed text (optional). Default: False
    :param limit: The maximum number of posts to return (optional). Default: None

    :return: A list of ForumPost objects that are part of the topic or an empty list if no posts found

    :raises Exception: If an error occurs while fetching the posts
    """

    if not topic or not topic.isValid:
        raise LogException("Invalid topic")

    url = ForumTopicJsonLink.format(topicId=topic.id)
    data = GetJsonResponse(url)
    if FORUM_POST_STREAM not in data:
        raise LogException("No post stream found in response")

    postStream = data[FORUM_POST_STREAM]
    if FORUM_POSTS not in postStream:
        raise LogException("No posts found in response post stream")

    forumPosts = []
    for post in postStream[FORUM_POSTS]:
        if limit and len(forumPosts) >= limit:
            break
        forumPosts.append(ForumPost(post, isHTML))

    return forumPosts

def GetSolution(
        topic: ForumTopic,
        isHTML: bool = False
) -> ForumPost:

    """
    Get the post that is marked as the solution for the passed topic

    :param topic: The topic to get the solution post for
    :param isHTML: If True, the content of the post would be raw HTML. If False, the content would be parsed text (optional). Default: False

    :return: The ForumPost object that is marked as the solution for the topic or None if no solution found

    :raises Exception: If an error occurs while fetching the solution post
    """

    if not topic or not topic.isValid:
        raise LogException("Invalid topic")

    url = ForumTopicJsonLink.format(topicId=topic.id)
    data = GetJsonResponse(url)
    if FORUM_POST_STREAM not in data:
        raise LogException("No post stream found in response")

    postStream = data[FORUM_POST_STREAM]
    if FORUM_POSTS not in postStream:
        raise LogException("No posts found in response post stream")

    for post in postStream[FORUM_POSTS]:
        if FORUM_POST_ACCEPTED not in post:
            raise LogException(f"No {FORUM_POST_ACCEPTED} property found in post data")
        if post[FORUM_POST_ACCEPTED]:
            # found the solution post
            return ForumPost(post, isHTML)

    logger.warning(f"No solution found for topic {topic.title}")
