"""Article representation and storage on disk"""

from __future__ import annotations

import os
import json
from abc import ABC
from datetime import datetime, timedelta
from pathlib import Path
from hashlib import sha256
from tempfile import NamedTemporaryFile

from feather.config import Config


def sanitize_filename(
    config: Config, filename: str, insert_before_suffix: str = ""
) -> str:
    """Escape invalid characters and truncate the filename as per the configuration.
    This operates on a single filename, not a path.
    (insert_before_suffix will be inserted between the stem and suffix, and is assumed to not need escaping)."""
    filename = filename.translate(config.filename_translation)

    max_filename_length = config.max_filename_length
    filename_len = len(filename.encode("utf-8"))
    insert_before_suffix_len = len(insert_before_suffix.encode("utf-8"))
    if filename_len + insert_before_suffix_len <= max_filename_length:
        path = Path(filename)
        return f"{path.stem}{insert_before_suffix}{path.suffix}"
    else:
        suffix = Path(filename).suffix
        max_stem_length = (
            max_filename_length - insert_before_suffix_len - len(suffix.encode("utf-8"))
        )
        cutoff = len(
            filename.encode("utf-8")[:max_stem_length].decode("utf-8", errors="ignore")
        )
        return filename[:cutoff] + "…" + insert_before_suffix + suffix


def format_datetime(config: Config, timestamp: float) -> str:
    """Format a timestamp according to the configuration."""
    if timestamp < 0:
        date = datetime(1970, 1, 1, tzinfo=config.timezone) + timedelta(
            seconds=timestamp
        )
    else:
        date = datetime.fromtimestamp(timestamp, config.timezone)
    return date.strftime(config.time_format)


def atomic_write(path: Path, content: str):
    """Write data to a file atomically."""
    try:
        with NamedTemporaryFile(dir=path.parent, delete=False) as f:
            path_temp = Path(f.name)
            f.write(content.encode("utf-8"))
        path_temp.rename(path)
    except:
        path_temp.unlink(missing_ok=True)
        raise


type CategoryId = int | str


class Category:
    def fromdict(d) -> Category:
        parents = [Category.fromdict(parent) for parent in d["parents"]]
        return Category(d["id"], d["title"], parents, d["order"])

    def __init__(self, id, title, parents=[], order=0):
        self.id: CategoryId = id  # category unique id
        self.title: str = title  # category name
        self.parents: list[Category] = parents  # list of parent categories
        self.order: int = (
            order  # category display order, starting from 1 (0 if unknown)
        )

    def asdict(self):
        return {
            "id": self.id,
            "title": self.title,
            "parents": [dir.asdict() for dir in self.parents],
            "order": self.order,
        }


type ArticleId = int | str


class Article(ABC):
    config: Config
    json_path: Path  # JSON path

    # fields serialized into the JSON file #

    # no default value
    id: ArticleId  # article unique id
    category: Category  # feed category
    # no default value, computed on save_html
    html_path: str = None  # html path, relative to the html_root directory (None will force it to be recomputed on next save_html)
    # with default value
    unread: bool = True  # if the article is unread
    title: str = ""  # article title
    published: float = 0.0  # article publication time (timestamp)
    updated: float = 0.0  # article update time (timestamp)
    author: str = ""  # article author
    summary: str = ""  # article summary (HTML)
    content: str = ""  # article content (HTML)
    feed_title: str = ""  # feed title
    feed_url: str = ""  # feed URL
    feed_icon_url: str = ""  # feed icon URL
    feed_order: int = 0  # feed display order, starting from 1 (0 if unknown)
    article_url: str = ""  # article URL
    comments_url: str = ""  # article comments URL
    language: str = ""  # article language
    image_url: str = ""  # article main image
    last_write: float = 0.0  # last time this article file was written (timestamp)

    def _hash_id(self):
        return sha256(str(self.id).encode("utf-8")).hexdigest()

    def _get_html_path(self) -> Path:
        config = self.config

        # Category directory path
        category_directory = self.config.html_root
        for category in self.category.parents:
            category_directory /= sanitize_filename(
                config, config.article_category_template.render(category.asdict())
            )
        category_directory /= sanitize_filename(
            config, config.article_category_template.render(self.category.asdict())
        )

        # Filename
        html_name = sanitize_filename(
            config,
            config.article_filename_template.render(self._get_template_dict()),
        )
        html_path = category_directory / html_name
        # Add hash if filename already exists
        if html_path.exists():
            id_hash = self._hash_id()[:8]
            html_path = html_path.parent / sanitize_filename(
                config, html_path.name, insert_before_suffix=f".{id_hash}"
            )

        return html_path.relative_to(config.html_root)

    def _get_template_dict(self) -> dict:
        config = self.config
        template_fields = (
            "id",
            "unread",
            "title",
            "author",
            "summary",
            "content",
            "feed_title",
            "feed_url",
            "feed_icon_url",
            "feed_order",
            "article_url",
            "comments_url",
            "language",
            "image_url",
        )
        d = {field: getattr(self, field) for field in template_fields}
        d["published"] = format_datetime(config, self.published)
        d["updated"] = format_datetime(config, self.updated)
        d["category"] = self.category.asdict()
        return d

    def _get_json_path(self) -> Path:
        return self.config.json_root / f"{self._hash_id()}.json"

    def _write_json(self, recompute_path=False):
        """Write the JSON file associated with this article. Error if it already exists."""
        if recompute_path:
            self.json_path = self._get_json_path()
        stored_fields = (
            "id",
            "unread",
            "title",
            "published",
            "updated",
            "author",
            "summary",
            "content",
            "feed_title",
            "feed_url",
            "feed_icon_url",
            "feed_order",
            "article_url",
            "comments_url",
            "language",
            "image_url",
            "html_path",
            "last_write",
        )
        article_json = {field: getattr(self, field) for field in stored_fields}
        article_json["category"] = self.category.asdict()
        if self.json_path.exists():
            raise FileExistsError(
                f"Unexpectedly tried to overwrite article data for {self.json_path}: either the feed has duplicate entries, or another feather process tried to write the same file at the same time (in which case, try again later)"
            )
        atomic_write(self.json_path, json.dumps(article_json))

    def _delete_json(self, missing_ok=False):
        """Delete the JSON file associated with this article."""
        self.json_path.unlink(missing_ok=missing_ok)

    def _write_html(self, recompute_path=False):
        """Write the HTML file associated with this article. Error if it already exists.
        Note: this may recompute html_path, which is saved into the JSON - so make sure to save the JSON file _after_ the HTML file."""
        # Write HTML file for a JSON object
        config = self.config

        if self.html_path is None or recompute_path:
            self.html_path = str(self._get_html_path())

        html_path = config.html_root / self.html_path
        html_path.parent.mkdir(parents=True, exist_ok=True)
        if html_path.exists():
            raise FileExistsError(
                f"Unexpectedly tried to overwrite article file for {html_path}: either the feed has duplicate entries, or another feather process tried to write the same file at the same time (in which case, try again later)"
            )
        atomic_write(
            html_path, config.article_template.render(self._get_template_dict())
        )
        # set accessed date to update time, modified to publication time
        os.utime(html_path, (max(int(self.published), int(self.updated)), int(self.published)))

    def _delete_html(self, missing_ok=False):
        """Delete the HTML file associated with this article."""
        # Delete a HTML file for a JSON object
        if self.html_path is None:
            return
        else:
            html_path = self.config.html_root / self.html_path
            html_path.unlink(missing_ok=missing_ok)

    def has_html(self) -> bool:
        """Check if the HTML file associated with the article exists on disk."""
        if self.html_path is None:
            return False
        else:
            html_path = self.config.html_root / self.html_path
            return html_path.exists()

    def was_updated(self, old_article: Article) -> bool:
        """Returns true if the article is different from a previous version in a way that would require regeneration"""
        return old_article._get_template_dict() != self._get_template_dict()

    def write(self, recompute_paths=False):
        """Write all the files associated with this article to disk."""
        self.last_write = datetime.now().timestamp()
        if self.unread or self.config.write_read_articles:
            try:
                self._write_html(recompute_path=recompute_paths)
            except FileExistsError:
                raise
            except:
                self._delete_html(missing_ok=True)
                raise
        try:
            self._write_json(recompute_path=recompute_paths)
        except FileExistsError:
            raise
        except:
            self._delete_json(missing_ok=True)
            raise

    def delete(self):
        """Delete all the files associated with this article."""
        self._delete_html(missing_ok=True)
        self._delete_json()

    def regenerate(self):
        """Delete and rewrite all the files associated with this article using to the latest configuration."""
        self.delete()  # paths might change so we preemptively remove the old file
        self.write(recompute_paths=True)  # rewrite JSON & HTML


class FileArticle(Article):
    def __init__(self, config: Config, json_path: Path) -> Article:
        self.config = config
        self.json_path = json_path

        article_json = json.load(json_path.open("r"))
        for field in article_json:
            setattr(self, field, article_json[field])
        self.category = Category.fromdict(article_json["category"])
