import os
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from unittest import mock

import authoring_server as server


class AuthoringServerTestCase(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name)
        self.blogs = self.root / "blogs"
        self.posts = self.root / "posts"
        self.lima = self.root / "lima"
        self.home = self.root / "home"
        self.tags = self.root / "tags"
        self.hzone = self.root / "assets" / "images" / "hzone"
        self.blogs.mkdir()
        self.posts.mkdir()
        self.lima.mkdir()
        self.home.mkdir()
        self.tags.mkdir()

        patches = {
            "ROOT": self.root,
            "BLOGS_DIR": self.blogs,
            "POSTS_DIR": self.posts,
            "LIMA_DIR": self.lima,
            "IMAGE_ASSETS_DIR": self.root / "assets" / "images",
            "HZONE_ASSETS_DIR": self.hzone,
        }
        self.patchers = [mock.patch.object(server, name, value) for name, value in patches.items()]
        for patcher in self.patchers:
            patcher.start()

    def tearDown(self):
        for patcher in reversed(self.patchers):
            patcher.stop()
        self.tmp.cleanup()


class UtilityTests(AuthoringServerTestCase):
    def test_resolve_root_ignores_invalid_author_root(self):
        wrong_root = self.root / "empty"
        wrong_root.mkdir()
        (self.root / "authoring_server.py").write_text("", encoding="utf-8")

        with mock.patch.dict(os.environ, {"AUTHOR_ROOT": str(wrong_root), "GITHUB_WORKSPACE": ""}), mock.patch.object(server.Path, "cwd", return_value=self.root):
            self.assertEqual(server.resolve_root(), self.root)

    def test_slugify_normalises_text_and_keeps_fallback(self):
        self.assertEqual(server.slugify("Hello, Org Web!"), "hello-org-web")
        self.assertEqual(server.slugify(" "), "untitled")

    def test_normalise_tags_accepts_strings_and_deduplicates(self):
        self.assertEqual(
            server.normalise_tags("Life, review:Life  Emacs"),
            ["life", "review", "emacs"],
        )

    def test_parse_org_datetime_handles_date_and_optional_time(self):
        self.assertEqual(
            server.parse_org_datetime("<2026-05-07 Thu 14:35>"),
            datetime(2026, 5, 7, 14, 35),
        )
        self.assertEqual(
            server.parse_org_datetime("<2026-05-07 Thu>"),
            datetime(2026, 5, 7, 12, 0),
        )
        self.assertIsNone(server.parse_org_datetime("2026-05-07"))

    def test_safe_relative_path_allows_expected_content_roots(self):
        self.assertEqual(
            server.safe_relative_path("blogs/example.org"),
            self.blogs / "example.org",
        )
        self.assertEqual(
            server.safe_relative_path("posts/career/example.org"),
            self.posts / "career" / "example.org",
        )
        self.assertEqual(
            server.safe_relative_path("lima/index.md"),
            self.lima / "index.md",
        )
        self.assertEqual(
            server.safe_relative_path("home/notes.org"),
            self.home / "notes.org",
        )

    def test_safe_relative_path_rejects_escapes_and_wrong_locations(self):
        for path in ("../secret.org", "/tmp/secret.org", "sitemap.org", "assets/style.org", "tags/life.org"):
            with self.subTest(path=path):
                with self.assertRaises(ValueError):
                    server.safe_relative_path(path)

    def test_image_dimensions_detects_png_and_gif(self):
        png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 + (640).to_bytes(4, "big") + (480).to_bytes(4, "big")
        gif = b"GIF89a" + (320).to_bytes(2, "little") + (200).to_bytes(2, "little")
        self.assertEqual(server.image_dimensions(png, ".png"), (640, 480))
        self.assertEqual(server.image_dimensions(gif, ".gif"), (320, 200))
        self.assertIsNone(server.image_dimensions(b"not an image", ".png"))

    def test_parse_upload_form_reads_attachment_and_page_path(self):
        boundary = "----authoring-test"
        content_type = f"multipart/form-data; boundary={boundary}"
        body = (
            f"--{boundary}\r\n"
            'Content-Disposition: form-data; name="pagePath"\r\n\r\n'
            "lima/index.md\r\n"
            f"--{boundary}\r\n"
            'Content-Disposition: form-data; name="attachment"; filename="photo.png"\r\n'
            "Content-Type: image/png\r\n\r\n"
        ).encode("utf-8") + b"image bytes\r\n" + f"--{boundary}--\r\n".encode("utf-8")

        filename, payload, page_path = server.parse_upload_form(content_type, body)

        self.assertEqual(filename, "photo.png")
        self.assertEqual(payload, b"image bytes")
        self.assertEqual(page_path, "lima/index.md")


class PageRenderingTests(AuthoringServerTestCase):
    def test_render_org_writes_metadata_and_body(self):
        rendered = server.render_org(
            {
                "title": "A New Note",
                "slug": "Custom Slug",
                "tags": ["Life", "life", "Review"],
                "content": "Body text",
                "date": "<2026-05-07 Thu 10:30>",
                "comments": False,
                "wip": "draft",
            },
            previous=None,
        )

        self.assertIn("#+TITLE: A New Note", rendered)
        self.assertIn("#+DATE: <2026-05-07 Thu 10:30>", rendered)
        self.assertIn("#+filetags: :life:review:", rendered)
        self.assertIn("#+COMMENTS: ", rendered)
        self.assertIn("#+SLUG: custom-slug", rendered)
        self.assertIn("#+WIP: draft", rendered)
        self.assertTrue(rendered.endswith("Body text\n"))

    def test_render_markdown_adds_or_replaces_title_heading(self):
        self.assertEqual(
            server.render_markdown({"title": "Family Update", "content": "Body"}),
            "# Family Update\n\nBody\n",
        )
        self.assertEqual(
            server.render_markdown({"title": "New Title", "content": "## Old\n\nBody"}),
            "# New Title\n\nBody\n",
        )

    def test_save_page_creates_blog_and_round_trips_content(self):
        saved = server.save_page(
            {
                "pageType": "blog",
                "title": "Test Post",
                "slug": "test-post",
                "date": "<2026-05-07 Thu 09:00>",
                "tags": "test, blog",
                "content": "The body",
                "comments": True,
            }
        )

        self.assertEqual(saved["path"], "blogs/2026/05-may/test-post.org")
        self.assertEqual(saved["title"], "Test Post")
        self.assertEqual(saved["tags"], ["test", "blog"])
        self.assertEqual(saved["content"], "The body")

    def test_save_page_creates_lima_markdown(self):
        saved = server.save_page(
            {
                "pageType": "lima",
                "title": "Lima Entry",
                "slug": "lima-entry",
                "content": "Some markdown",
            }
        )

        path = self.lima / "lima-entry.md"
        self.assertEqual(saved["path"], "lima/lima-entry.md")
        self.assertEqual(path.read_text(encoding="utf-8"), "# Lima Entry\n\nSome markdown\n")

    def test_list_pages_excludes_generated_and_sync_conflict_files(self):
        (self.blogs / "keep.org").write_text("#+TITLE: Keep\n#+DATE: <2026-05-07 Thu 12:00>\n", encoding="utf-8")
        (self.posts / "posts-list.org").write_text("#+TITLE: Generated\n", encoding="utf-8")
        (self.blogs / "note.sync-conflict-1.org").write_text("#+TITLE: Conflict\n", encoding="utf-8")
        (self.lima / "index.md").write_text("# Lima Home\n", encoding="utf-8")
        (self.home / "notes.org").write_text("#+TITLE: Notes\n", encoding="utf-8")
        (self.tags / "life.org").write_text("#+TITLE: Tag\n", encoding="utf-8")

        paths = [page["path"] for page in server.list_pages()]

        self.assertEqual(set(paths), {"blogs/keep.org", "home/notes.org", "lima/index.md"})

    def test_list_pages_skips_files_that_fail_to_read(self):
        good = self.blogs / "keep.org"
        bad = self.blogs / "bad.org"
        good.write_text("#+TITLE: Keep\n#+DATE: <2026-05-07 Thu 12:00>\n", encoding="utf-8")
        bad.write_text("#+TITLE: Bad\n", encoding="utf-8")
        original_read_page = server.read_page

        def read_page(path):
            if path == bad:
                raise OSError("file disappeared")
            return original_read_page(path)

        with mock.patch.object(server, "read_page", side_effect=read_page):
            paths = [page["path"] for page in server.list_pages()]

        self.assertEqual(paths, ["blogs/keep.org"])

    def test_server_diagnostics_reports_root_and_page_count(self):
        (self.blogs / "keep.org").write_text("#+TITLE: Keep\n#+DATE: <2026-05-07 Thu 12:00>\n", encoding="utf-8")

        diagnostics = server.server_diagnostics()

        self.assertEqual(diagnostics["root"], self.root.as_posix())
        self.assertEqual(diagnostics["pageCount"], 1)
        self.assertEqual(diagnostics["firstPage"], "blogs/keep.org")

    def test_relative_asset_path_is_calculated_from_lima_page_directory(self):
        self.assertEqual(
            server.relative_asset_path("lima/family/update.md", "assets/images/hzone/pic.png"),
            "../../assets/images/hzone/pic.png",
        )
        self.assertEqual(
            server.relative_asset_path("blogs/example.org", "assets/images/hzone/pic.png"),
            "../assets/images/hzone/pic.png",
        )

    def test_save_upload_stores_post_images_by_section_and_returns_org_link(self):
        with mock.patch.object(server, "datetime") as datetime_mock:
            datetime_mock.now.return_value = datetime(2026, 5, 7, 9, 30)
            saved = server.save_upload(
                "Stress In Workplace.png",
                b"image bytes",
                "posts/career/management-of-self.org",
            )

        target = self.root / "assets" / "images" / "career" / "2026-05-07-stress-in-workplace.png"
        self.assertEqual(target.read_bytes(), b"image bytes")
        self.assertEqual(saved["path"], "assets/images/career/2026-05-07-stress-in-workplace.png")
        self.assertEqual(saved["relativeUrl"], "../../assets/images/career/2026-05-07-stress-in-workplace.png")
        self.assertEqual(saved["insertText"], "[[../../assets/images/career/2026-05-07-stress-in-workplace.png]]")

    def test_save_upload_keeps_blog_images_in_blog_folder(self):
        with mock.patch.object(server, "datetime") as datetime_mock:
            datetime_mock.now.return_value = datetime(2026, 5, 7, 9, 30)
            saved = server.save_upload(
                "Lunch.jpg",
                b"image bytes",
                "blogs/2026/05-may/lunch.org",
            )

        self.assertEqual(saved["path"], "assets/images/blogs/2026-05-07-lunch.jpg")
        self.assertEqual(saved["insertText"], "[[../../../assets/images/blogs/2026-05-07-lunch.jpg]]")

    def test_save_upload_inserts_absolute_public_url_for_markdown(self):
        with mock.patch.object(server, "datetime") as datetime_mock:
            datetime_mock.now.return_value = datetime(2026, 5, 9, 9, 30)
            saved = server.save_upload(
                "Screen Shot.png",
                b"image bytes",
                "lima/index.md",
            )

        self.assertEqual(saved["path"], "assets/images/hzone/2026-05-09-screen-shot.png")
        self.assertEqual(saved["relativeUrl"], "../assets/images/hzone/2026-05-09-screen-shot.png")
        self.assertEqual(
            saved["insertText"],
            "![2026-05-09-screen-shot.png](https://zainezq.com/assets/images/hzone/2026-05-09-screen-shot.png)",
        )

    def test_save_upload_rejects_disallowed_extensions(self):
        with self.assertRaises(ValueError):
            server.save_upload("shell.php", b"<?php")

        with self.assertRaises(ValueError):
            server.save_upload("movie.mp4", b"video")


class BuildQueueTests(unittest.TestCase):
    def test_snapshot_reports_recent_completed_job(self):
        queue = server.BuildQueue()
        job = server.BuildJob(1, "blogs/post.org", "Post")
        job.started_at = 1.0
        job.finished_at = 2.0
        job.ok = True
        job.message = "Done"
        job.log = "build log"
        queue._recent.append(job)

        snapshot = queue.snapshot()

        self.assertFalse(snapshot["running"])
        self.assertEqual(snapshot["message"], "Done")
        self.assertEqual(snapshot["recent"][0]["status"], "done")
        self.assertEqual(snapshot["log"], "build log")

    def test_queue_build_enqueues_from_page_data(self):
        queue = server.BuildQueue()
        with mock.patch.object(server, "BUILD_QUEUE", queue), mock.patch.object(queue, "_run_worker"):
            queued = server.queue_build({"path": "blogs/post.org", "title": "Post"})

        self.assertEqual(queued["id"], 1)
        self.assertEqual(queued["status"], "queued")
        self.assertEqual(queued["path"], "blogs/post.org")

    def test_run_build_commands_streams_output_to_callback(self):
        root = Path(tempfile.mkdtemp())
        try:
            venv_python = root / ".venv" / "bin" / "python"
            venv_python.parent.mkdir(parents=True)
            venv_python.write_text("", encoding="utf-8")
            seen = []

            class FakeProcess:
                def __init__(self, lines, return_code=0):
                    self.stdout = iter(lines)
                    self.return_code = return_code

                def wait(self):
                    return self.return_code

            processes = [
                FakeProcess(["emacs line 1\n", "emacs line 2\n"]),
                FakeProcess(["index line\n"]),
            ]

            with mock.patch.object(server, "ROOT", root), mock.patch.object(server.subprocess, "Popen", side_effect=processes):
                ok, message, log = server.run_build_commands(seen.append)

            self.assertTrue(ok)
            self.assertIn("Build complete", message)
            self.assertIn("emacs line 1\n", seen)
            self.assertIn("index line\n", seen)
            self.assertEqual(log, "".join(seen))
        finally:
            for path in sorted(root.rglob("*"), reverse=True):
                if path.is_file():
                    path.unlink()
                else:
                    path.rmdir()
            root.rmdir()


if __name__ == "__main__":
    unittest.main()
