import time
from typing import List

import requests
from flask import current_app as app
from werkzeug.http import parse_options_header

from feedrsub.database import db
from feedrsub.ingestion.utils import headers_to_dict
from feedrsub.models.feed import Feed
from feedrsub.models.feedstats import FeedStats, FetchType
from feedrsub.utils.date_parsing import unix_timestamp
from feedrsub.utils.hashing import DiffResult
from feedrsub.utils.signals import notification_received
from feedrsub.utils.strings import stringify_list, utf8len


class ContentLengthException(Exception):
    pass


class FeedFetcher:
    def __init__(self, max_feeds: int = 10) -> None:
        self.to_fetch: List[Feed] = []
        self.fetched: List[Feed] = []
        self.max_feeds: int = max_feeds

    @classmethod
    def fetch_feed(cls, feed: Feed, force: bool = False) -> bool:
        """
        Fetch a single Feed.

        Will attempt to fetch latest version of Feed. Statistics will be saved and next fetch
        scheduled regardless of fetch success. If fetch failed or there is no change in the feed, then
        no notification_received signal will be sent and the process will exit.

        :param feed: Feed to be fetched
        :param force: Always fetch regardless of last-modified values
        :return: True if fetch was successful with new content
        """
        fetched: bool = False

        content: str = ""
        content_length: int = 0
        response_url: str = ""
        headers: dict = {}
        status_code: int = 500
        encoding: str = ""

        app.logger.info("Fetching %s", feed)

        start = time.perf_counter()
        try:
            with requests.get(
                feed.topic,
                headers=feed.fetch_headers(force),
                timeout=(3.05, 10),
                stream=True,
            ) as response:

                # Capture response variables before raising any exceptions
                status_code = response.status_code
                headers = response.headers
                encoding = response.encoding or "utf-8"

                # Only set content if content length is acceptable, else raise ContentLengthException
                content_length = int(response.headers.get("Content-Length", 0))
                if content_length < app.config.get("MAX_FEED_LENGTH"):
                    content = response.text
                else:
                    raise ContentLengthException()

                response.raise_for_status()

        except requests.Timeout as t:
            app.logger.warning("Timeout fetching Feed %s: %s", feed, t)
        except requests.ConnectionError as c:
            app.logger.warning("ConnectionError fetching feed %s: %s", feed, c)
        except requests.RequestException as e:
            app.logger.warning("Error fetching Feed %s: %s", feed, e)
        except ContentLengthException:
            app.logger.warning("TOO BIG: feed=%s size=%s", feed, content_length)
        else:
            fetched = True

        request_time_ms = int((time.perf_counter() - start) * 1000)

        # Read content length from content only if available and required
        if content and content_length == 0:
            content_length = utf8len(content)

        app.logger.info(
            "FETCHED: topic=%s duration=%dms status=%s size=%sb",
            feed.topic,
            request_time_ms,
            status_code,
            content_length,
        )

        feed.last_status_code = status_code
        feed.set_last_fetch()
        feed.set_next_scheduled_update()

        stats = FeedStats.create_stats(
            feed.id,
            FetchType.PULL,
            status_code=status_code,
            latency=request_time_ms,
            content_length=content_length,
        )

        # Set feed to inactive if Feed is Gone
        if status_code == 410:
            feed.gone = True

        # Exit if Feed not successfully fetched
        if not fetched:
            return cls.no_change(feed, stats)

        # Set Feed ETag from response
        # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
        etag = headers.get("etag")
        if etag:
            feed.etag = etag

        # Set Feed Last-Modified from response
        # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
        last_modified = headers.get("last-modified")
        if last_modified:
            feed.last_modified = feed.set_last_modified(last_modified)

        # Save and return if not modified code received
        if status_code == 304:
            return cls.no_change(feed, stats)

        # Check if content hash is new
        new_content: DiffResult = feed.is_new_content(content, encoding or "UTF-8")
        app.logger.info(
            "HASH: feed=%s diff=%d threshold=%d original=%s new=%s",
            feed,
            new_content.diff,
            new_content.threshold,
            new_content.hash1,
            new_content.hash2,
        )

        # Save and return if no new content and not forcing update
        if not new_content and not force:
            return cls.no_change(feed, stats)

        db.session.commit()

        # Set content-location header to final url if not already set
        if "content-location" not in headers:
            headers["content-location"] = response_url

        notification_received.send(
            cls,
            feed=feed,
            content_type=parse_options_header(headers.get("content-type")),
            content=content,
            encoding=encoding,
            stats=stats,
            headers=headers_to_dict(headers),
        )
        return True

    def fetch_scheduled_feeds(self) -> None:
        """
        Fetch all scheduled Feeds

        :return: None
        """
        start = time.perf_counter()

        self.to_fetch = self.get_scheduled_feeds(self.max_feeds)

        if not self.to_fetch:
            app.logger.debug("No Feeds to fetch. Exiting.")
            return

        app.logger.info("Fetching Feeds %s", stringify_list(self.to_fetch))

        for feed in self.to_fetch:
            result = self.fetch_feed(feed)
            if result:
                self.fetched.append(feed)

        app.logger.info(
            "Feeds fetched in %dms", int((time.perf_counter() - start) * 1000)
        )

    @classmethod
    def get_scheduled_feeds(cls, max_feeds: int = 10) -> List[Feed]:
        """
        Get all Feeds that are past their next scheduled fetch time

        :param max_feeds: Maximum number of Feeds to fetch
        :return: List of Feeds to fetch
        """
        feeds = (
            Feed.query.filter(
                Feed.next_scheduled_update <= unix_timestamp(), Feed.active == True
            )
            .limit(max_feeds)
            .all()
        )  # noqa

        return feeds

    @classmethod
    def no_change(cls, feed: Feed, stats: dict) -> bool:
        """
        Save FeedStats, commit, and return False

        :param feed: Feed that was fetched
        :param stats: Statistics about Feed fetch
        """
        app.logger.info("No change in %s since fetch at %s", feed, feed.last_fetch)

        # Save Feed Stats and Feed to DB
        FeedStats.save_stats(stats)
        db.session.commit()
        return False
