"Tests for DocThread"

import sqlite3
import subprocess
import threading
import time

import pytest
from basethread import Request
from const import APPLICATION_ID, USER_VERSION
from docthread import DocThread, _calculate_crop_tuples
from gi.repository import GLib
from importthread import CancelledError
from page import Page
from PIL import Image


def test_do_tesseract_path_fallback(mocker):
    "test do_tesseract when path is ./"

    # Mock DocThread to avoid real DB connection if possible, but here we just
    # spoof _write_tid.
    # Using a memory database for simplicity in this test
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    # Mock get_languages to return path="./"
    mocker.patch("tesserocr.get_languages", return_value=("./", []))

    # Mock glob to simulate finding tessdata
    mock_glob = mocker.patch(
        "glob.glob", return_value=["/usr/share/tesseract-ocr/4.00/tessdata"]
    )

    # Mock PyTessBaseAPI
    mock_api = mocker.patch("tesserocr.PyTessBaseAPI")
    mock_api_instance = mock_api.return_value
    mock_api_instance.__enter__.return_value = mock_api_instance

    # Mock Page
    mock_page = mocker.Mock(spec=Page)
    mock_page.image_object = mocker.Mock()
    mock_page.id = 1

    # Mock get_page
    mocker.patch.object(thread, "get_page", return_value=mock_page)

    # Mock replace_page and other methods that hit DB
    mocker.patch.object(thread, "replace_page")

    # Mock cancel
    thread.cancel = False

    request = mocker.Mock()
    request.args = [{"page": 1, "language": "eng", "dir": "/tmp"}]

    thread.do_tesseract(request)

    # Check if glob was called
    mock_glob.assert_called_with("/usr/share/tesseract-ocr/*/tessdata")

    # Check if PyTessBaseAPI was initialized with the found path
    mock_api.assert_called_with(
        lang="eng", path="/usr/share/tesseract-ocr/4.00/tessdata"
    )


def test_do_tesseract_in_memory_pixels(mocker):
    "test do_tesseract feeds page pixels to tesseract in memory"
    thread = DocThread(db=":memory:")

    mocker.patch(
        "tesserocr.get_languages",
        return_value=("/usr/share/tesseract-ocr/4.00/tessdata", []),
    )
    mock_api = mocker.patch("tesserocr.PyTessBaseAPI")
    mock_api_instance = mock_api.return_value
    mock_api_instance.__enter__.return_value = mock_api_instance
    mock_api_instance.GetHOCRText.return_value = "hocr content"

    image = Image.new("RGB", (2, 3), color=(255, 0, 0))
    mock_page = mocker.Mock(spec=Page)
    mock_page.image_object = image
    mock_page.id = 1
    mocker.patch.object(thread, "get_page", return_value=mock_page)
    mocker.patch.object(thread, "replace_page")
    thread.cancel = False

    request = mocker.Mock()
    request.args = [{"page": 1, "language": "eng", "dir": "/tmp"}]

    thread.do_tesseract(request)

    expected = image.convert("L")
    mock_api_instance.SetImageBytes.assert_called_once_with(
        expected.tobytes(), expected.width, expected.height, 1, expected.width
    )
    mock_api_instance.Recognize.assert_called_once_with()
    mock_api_instance.GetHOCRText.assert_called_once_with(0)
    assert "hocr content" in mock_page.import_hocr.call_args.args[0]
    assert mock_page.ocr_flag is True


def test_do_tesseract_path_fallback_not_found(temp_db, mocker):
    "test do_tesseract when path is ./ and no system tessdata found"
    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()

    # Mock get_languages to return path="./"
    mocker.patch("tesserocr.get_languages", return_value=("./", []))

    # Mock glob to return empty list
    mocker.patch("glob.glob", return_value=[])
    mocker.patch("shutil.which", return_value=None)

    # Mock PyTessBaseAPI to prevent RuntimeError
    mocker.patch("tesserocr.PyTessBaseAPI")

    # Mock get_page
    mock_page = mocker.Mock(spec=Page)
    mock_page.image_object = mocker.Mock()
    mock_page.id = 1
    mock_page.image_id = 1
    mocker.patch.object(thread, "get_page", return_value=mock_page)

    # Mock finding page number
    # Mock DB operations
    mocker.patch.object(thread, "_execute")
    mocker.patch.object(thread, "_fetchone", return_value=[1])
    mocker.patch.object(thread, "_fetchall", return_value=[])
    mocker.patch.object(thread, "_take_snapshot")
    mocker.patch.object(thread, "_insert_image", return_value=(1, "thumb"))
    mocker.patch.object(thread, "_insert_page", return_value=1)
    mocker.patch.object(thread, "_bytes_to_pixbuf", return_value="thumb")

    # Mock _con for commit
    thread._con[threading.get_native_id()] = mocker.Mock()

    request = mocker.Mock()
    request.args = [{"page": 1, "language": "eng", "dir": "/tmp"}]

    thread.do_tesseract(request)

    # Check if error was reported
    request.error.assert_called()
    assert "tessdata directory not found" in str(request.error.call_args)


def test_do_tesseract_path_fallback_symlink(temp_db, mocker):
    "test do_tesseract when path is ./ and tessdata found via symlink"
    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()

    # Mock get_languages to return path="./"
    mocker.patch("tesserocr.get_languages", return_value=("./", []))

    # Mock glob to return empty list
    mocker.patch("glob.glob", return_value=[])

    # Mock shutil.which to return a path
    mocker.patch("shutil.which", return_value="/usr/local/bin/tesseract")

    # Mock Path
    mock_path = mocker.patch("docthread.Path", autospec=True)
    mock_tess_path = mock_path.return_value
    mock_tess_path.is_symlink.return_value = True

    # tess_path.resolve() / "../../share/tessdata"
    mock_resolved = mock_tess_path.resolve.return_value
    mock_tessdata = mock_resolved.__truediv__.return_value.resolve.return_value
    mock_tessdata.exists.return_value = True
    # In Python, str(mock) returns a string representation of the mock,
    # we need to mock the __str__ method to return our desired path.
    mock_tessdata.__str__.return_value = "/usr/local/share/tessdata"

    # Mock PyTessBaseAPI
    mock_api = mocker.patch("tesserocr.PyTessBaseAPI")
    mock_api_instance = mock_api.return_value
    mock_api_instance.__enter__.return_value = mock_api_instance

    # Mock Page
    mock_page = mocker.Mock(spec=Page)
    mock_page.image_object = mocker.Mock()
    mock_page.id = 1
    mock_page.image_id = 1
    mocker.patch.object(thread, "get_page", return_value=mock_page)

    # Mock DB operations
    mocker.patch.object(thread, "replace_page")

    request = mocker.Mock()
    request.args = [{"page": 1, "language": "eng", "dir": "/tmp"}]

    thread.do_tesseract(request)

    # Check if PyTessBaseAPI was initialized with the path from symlink
    mock_api.assert_called_with(lang="eng", path="/usr/local/share/tessdata")


def test_calculate_crop_tuples(mocker):
    "test _calculate_crop_tuples"

    mock_image = mocker.Mock()
    mock_image.width = 100
    mock_image.height = 200

    # Test vertical split
    options = {"direction": "v", "position": 40}
    tuples = _calculate_crop_tuples(options, mock_image)
    assert tuples == (
        (0, 0, 40, 200),
        (40, 0, 100, 200),
        (40, 0, 60, 200),
    )

    # Test horizontal split
    options = {"direction": "h", "position": 50}
    tuples = _calculate_crop_tuples(options, mock_image)
    assert tuples == (
        (0, 0, 100, 50),
        (0, 50, 100, 200),
        (0, 50, 100, 150),
    )


def test_get_thumb(mocker):
    "test get_thumb"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_execute = mocker.patch.object(thread, "_execute")
    mocker.patch.object(thread, "_fetchone", return_value=[b"dummy_thumb"])
    mocker.patch.object(thread, "_bytes_to_pixbuf", return_value="mock_pixbuf")

    result = thread.get_thumb(1)
    mock_execute.assert_called_with(
        """SELECT thumb FROM page, page_order
                WHERE page.id = page_id AND initial_page_id = ? AND action_id = ?""",
        (1, 0),
    )
    assert result == "mock_pixbuf"


def test_get_text(mocker):
    "test get_text"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_execute = mocker.patch.object(thread, "_execute")
    mocker.patch.object(thread, "_fetchone", return_value=["dummy_text"])

    result = thread.get_text(1)
    mock_execute.assert_called_with(
        """SELECT text FROM page, page_order
                WHERE page.id = page_id AND initial_page_id = ? AND action_id = ?""",
        (1, 0),
    )
    assert result == "dummy_text"


def test_open_session_file(mocker):
    "test open session file"

    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mocker.patch.object(thread, "_connect")
    mock_execute = mocker.patch.object(thread, "_execute")
    mocker.patch.object(
        thread,
        "_fetchone",
        side_effect=[
            (APPLICATION_ID,),  # application_id
            (USER_VERSION,),  # user_version
            (10,),  # action_id
        ],
    )
    mocker.patch.object(thread, "_fetchall", return_value=[])

    thread.open("test.db")

    assert thread._db == "test.db"
    assert thread._action_id == 10
    assert mock_execute.call_count == 4
    mock_execute.assert_any_call("PRAGMA application_id")
    mock_execute.assert_any_call("PRAGMA user_version")
    mock_execute.assert_any_call("PRAGMA table_info(page_order)")
    mock_execute.assert_any_call("SELECT MAX(action_id) FROM page_order")


def test_open_session_file_invalid_app_id(mocker):
    "test open session file with invalid application id"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mocker.patch.object(thread, "_connect")
    mocker.patch.object(thread, "_execute")
    mocker.patch.object(
        thread,
        "_fetchone",
        return_value=(12345,),  # Invalid application_id
    )

    with pytest.raises(TypeError, match="is not a scantpaper session file"):
        thread.open("test.db")


def test_open_session_file_invalid_app_id0(mocker):
    "test open session file with invalid application id"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mocker.patch.object(thread, "_connect")
    mocker.patch.object(thread, "_execute")
    mocker.patch.object(
        thread,
        "_fetchone",
        return_value=(0,),  # Invalid application_id
    )

    with pytest.raises(TypeError, match="is not a scantpaper session file"):
        thread.open("test.db")


def test_do_set_saved(mocker):
    "test do_set_saved"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_execute = mocker.patch.object(thread, "_execute")
    thread._con[threading.get_native_id()] = mocker.Mock()

    request = mocker.Mock()

    # Test single page_id, default saved=True
    request.args = [1]
    thread.do_set_saved(request)
    mock_execute.assert_called_with(
        """UPDATE page SET saved = ? WHERE id IN (
                SELECT page_id FROM page_order
                WHERE initial_page_id IN (?)
                AND action_id = ?
            )""",
        (True, 1, 0),
    )

    # Test single page_id, explicit saved=False
    request.args = [1, False]
    thread.do_set_saved(request)
    mock_execute.assert_called_with(
        """UPDATE page SET saved = ? WHERE id IN (
                SELECT page_id FROM page_order
                WHERE initial_page_id IN (?)
                AND action_id = ?
            )""",
        (False, 1, 0),
    )

    # Test multiple page_ids
    request.args = [[1, 2, 3], True]
    thread.do_set_saved(request)
    mock_execute.assert_called_with(
        """UPDATE page SET saved = ? WHERE id IN (
                SELECT page_id FROM page_order
                WHERE initial_page_id IN (?, ?, ?)
                AND action_id = ?
            )""",
        (True, 1, 2, 3, 0),
    )


def test_do_set_text(mocker):
    "test do_set_text"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mocker.patch.object(thread, "_take_snapshot")
    mock_execute = mocker.patch.object(thread, "_execute")
    thread._con[threading.get_native_id()] = mocker.Mock()

    request = mocker.Mock()
    request.args = [1, "new_text"]
    thread.do_set_text(request)
    mock_execute.assert_called_with(
        """UPDATE page SET text = ? WHERE id = (
                SELECT page_id FROM page_order
                WHERE initial_page_id = ? AND action_id = ?
            )""",
        ("new_text", 1, 0),
    )


def test_parse_bboxtree(mocker):
    "test parse_bboxtree"
    thread = DocThread(db=":memory:")
    mock_send = mocker.patch.object(thread, "send")
    thread.parse_bboxtree("json")
    mock_send.assert_called_with("parse_bboxtree", "json")


def test_do_parse_bboxtree():
    "test do_parse_bboxtree"
    thread = DocThread(db=":memory:")
    hocr = """
    [
        {"type": "page", "bbox": [0, 0, 100, 100], "depth": 0},
        {"type": "word", "bbox": [10, 10, 20, 20], "text": "high", "confidence": 90, "depth": 1},
        {"type": "word", "bbox": [30, 30, 40, 40], "text": "low", "confidence": 50, "depth": 1}
    ]
    """
    request = Request("parse_bboxtree", [hocr], None)
    result = thread.do_parse_bboxtree(request)
    assert len(result["bboxes"]) == 3
    assert result["sorted_word_indices"] == [2, 1]


def test_do_set_annotations(mocker):
    "test do_set_annotations"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_execute = mocker.patch.object(thread, "_execute")
    thread._con[threading.get_native_id()] = mocker.Mock()

    request = mocker.Mock()
    request.args = [1, "new_ann"]
    thread.do_set_annotations(request)
    mock_execute.assert_called_with(
        """UPDATE page SET annotations = ? WHERE id = (
                SELECT page_id FROM page_order
                WHERE initial_page_id = ? AND action_id = ?
            )""",
        ("new_ann", 1, 0),
    )


def test_do_set_resolution(mocker):
    "test do_set_resolution"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_execute = mocker.patch.object(thread, "_execute")
    thread._con[threading.get_native_id()] = mocker.Mock()

    request = mocker.Mock()
    request.args = [1, 300.0, 300.0]
    thread.do_set_resolution(request)
    mock_execute.assert_called_with(
        """UPDATE page SET x_res = ?, y_res = ? WHERE id = (
                SELECT page_id FROM page_order
                WHERE initial_page_id = ? AND action_id = ?
            )""",
        (300.0, 300.0, 1, 0),
    )


def test_do_set_mean_std_dev(mocker):
    "test do_set_mean_std_dev"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_execute = mocker.patch.object(thread, "_execute")
    thread._con[threading.get_native_id()] = mocker.Mock()

    request = mocker.Mock()
    request.args = [1, [128.0], [10.0]]
    thread.do_set_mean_std_dev(request)
    mock_execute.assert_called_with(
        """UPDATE page SET mean = ?, std_dev = ? WHERE id = (
                SELECT page_id FROM page_order
                WHERE initial_page_id = ? AND action_id = ?
            )""",
        ("[128.0]", "[10.0]", 1, 0),
    )


def test_do_delete_pages_ids(mocker):
    "test do_delete_pages with page_ids"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mocker.patch.object(thread, "_take_snapshot")
    mock_execute = mocker.patch.object(thread, "_execute")
    mocker.patch.object(thread, "_executemany")
    mocker.patch.object(thread, "_fetchall", return_value=[])
    tid = threading.get_native_id()
    thread._con[tid] = mocker.Mock()
    thread._cur[tid] = mocker.Mock()

    request = mocker.Mock()
    request.args = [{"page_ids": [1]}]

    thread.do_delete_pages(request)

    mock_execute.assert_any_call(
        """DELETE FROM page_order
                    WHERE initial_page_id IN (?) AND action_id = ?""",
        (1, 0),
    )


def test_run_unpaper_cmd_rtl(mocker):
    "test _run_unpaper_cmd with rtl direction"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_run = mocker.patch("subprocess.run")
    mock_run.return_value.stdout = "Processing sheet 1.pnm\n"
    mock_run.return_value.stderr = ""

    mocker.patch("os.path.getsize", return_value=100)
    mock_temp = mocker.patch("tempfile.NamedTemporaryFile")
    mock_temp.return_value.name = "temp_file"

    request = mocker.Mock()
    request.args = [
        {
            "dir": "/tmp",
            "options": {
                "command": ["unpaper", "--output-pages", "2", "out"],
                "direction": "rtl",
            },
        }
    ]

    out, out2 = thread._run_unpaper_cmd(request)

    # With RTL, out and out2 should be swapped
    # Normally out is the first temp file created (output-pages arg -2)
    # out2 is the second temp file created (output-pages arg -1)
    # The method swaps them if direction is rtl
    assert out != out2


def test_run_unpaper_cmd_rtl_error(mocker):
    "test _run_unpaper_cmd error handling"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    # Mock os.path.getsize to return 0 (empty file), triggering the error check
    mocker.patch("os.path.getsize", return_value=0)
    mock_temp = mocker.patch("tempfile.NamedTemporaryFile")
    mock_temp.return_value.name = "temp_file"

    request = mocker.Mock()
    request.args = [
        {
            "dir": "/tmp",
            "options": {
                "command": ["unpaper", "--output-pages", "2", "out"],
                "direction": "rtl",
            },
        }
    ]

    # Test stderr error
    mock_run = mocker.patch("subprocess.run")
    mock_run.return_value.stdout = ""
    mock_run.return_value.stderr = "some error"

    with pytest.raises(subprocess.CalledProcessError):
        thread._run_unpaper_cmd(request)
    request.data.assert_called_with("some error")

    # Reset command for the second call
    request.args[0]["options"]["command"] = ["unpaper", "--output-pages", "2", "out"]

    # Test stdout error (after processing replacement)
    mock_run.return_value.stdout = "Processing sheet 1.pnm\nError processing"
    mock_run.return_value.stderr = ""

    with pytest.raises(subprocess.CalledProcessError):
        thread._run_unpaper_cmd(request)
    request.data.assert_called_with("Error processing")


def test_set_text_calls_send(mocker):
    "Test that `set_text()` forwards to `send()` with correct args."
    thread = DocThread(db=":memory:")

    # Replace the send method with a mock so we can inspect the call.
    mock_send = mocker.Mock(return_value="sent")
    thread.send = mock_send

    result = thread.set_text(42, "hello world", finished_callback=lambda *_: None)

    mock_send.assert_called_once()
    # Ensure the first three positional args are domain, page_id, text
    assert mock_send.call_args[0][:3] == ("set_text", 42, "hello world")
    assert result == "sent"


def test_check_cancelled():
    "test check_cancelled"

    thread = DocThread(db=":memory:")
    thread.cancel = False
    # should not raise
    thread.check_cancelled()

    thread.cancel = True
    with pytest.raises(CancelledError):
        thread.check_cancelled()


def test_do_analyse_empty_image(mocker):
    "test do_analyse with an empty image"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_page = mocker.Mock(spec=Page)
    mock_page.image_object = mocker.Mock()
    mock_page.id = 1
    mocker.patch.object(thread, "get_page", return_value=mock_page)
    mocker.patch.object(thread, "replace_page")

    # Mock ImageStat.Stat to return count=[0]
    mock_stat = mocker.patch("PIL.ImageStat.Stat")
    mock_stat.return_value.count = [0]

    request = mocker.Mock()
    request.args = [{"list_of_pages": [1]}]

    thread.do_analyse(request)

    assert mock_page.mean == [0.0]
    assert mock_page.std_dev == [0.0]


def test_do_threshold_colour(mocker):
    "test do_threshold preserves colour content on white"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    page = mocker.Mock(spec=Page)
    page.id = 1
    page.dirty_time = None
    page.saved = True
    img = Image.new("RGB", (4, 3))
    pixels = img.load()
    rows = [
        [(255, 255, 255), (250, 250, 250), (240, 240, 240), (255, 255, 255)],
        [(255, 0, 0), (255, 255, 0), (0, 0, 255), (0, 180, 0)],
        [(255, 180, 180), (150, 200, 255), (0, 0, 0), (128, 128, 128)],
    ]
    for y in range(3):
        for x in range(4):
            pixels[x, y] = rows[y][x]
    page.image_object = img
    mocker.patch.object(thread, "get_page", return_value=page)
    mocker.patch.object(thread, "replace_page")

    request = mocker.Mock()
    request.args = [{"page": 1, "threshold": 20}]
    thread.do_threshold(request)

    out = page.image_object
    assert out.mode == "1", "output is 1-bit"
    pix = out.load()
    assert [pix[x, 0] for x in range(4)] == [255, 255, 255, 255], "white stays white"
    assert [pix[x, 1] for x in range(4)] == [0, 0, 0, 0], "saturated colours kept"
    assert [pix[x, 2] for x in range(4)] == [0, 0, 0, 0], "light colours kept"


def test_do_threshold_percent(mocker):
    "test do_threshold value is a percentage of the range"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    page = mocker.Mock(spec=Page)
    page.id = 1
    page.dirty_time = None
    page.saved = True
    values = [0, 50, 51, 100, 203, 204, 240, 255]
    img = Image.new("L", (len(values), 1))
    img.putdata(values)
    page.image_object = img
    mocker.patch.object(thread, "get_page", return_value=page)
    mocker.patch.object(thread, "replace_page")

    request = mocker.Mock()
    request.args = [{"page": 1, "threshold": 20}]
    thread.do_threshold(request)
    assert page.image_object.mode == "1"
    pix = page.image_object.load()
    assert [pix[x, 0] for x in range(len(values))] == [0, 0, 0, 0, 0, 255, 255, 255]

    img = Image.new("L", (len(values), 1))
    img.putdata(values)
    page.image_object = img
    request.args = [{"page": 1, "threshold": 80}]
    thread.do_threshold(request)
    assert page.image_object.mode == "1"
    pix = page.image_object.load()
    assert [pix[x, 0] for x in range(len(values))] == [
        0,
        0,
        255,
        255,
        255,
        255,
        255,
        255,
    ]


def test_executemany_no_params(mocker):
    "test _executemany when params is None to cover line 111"
    thread = DocThread(db=":memory:")
    tid = threading.get_native_id()

    # Pre-populate _con and _cur to bypass _connect's real DB connection
    # and provide a mock cursor.
    mock_cur = mocker.Mock()
    thread._con[tid] = mocker.Mock()
    thread._cur[tid] = mock_cur

    # This should trigger line 111: self._cur[tid].executemany(query)
    thread._executemany("dummy query")

    mock_cur.executemany.assert_called_once_with("dummy query")


def test_open_newer_version(mocker):
    "test open session file with newer version"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mocker.patch.object(thread, "_connect")
    mocker.patch.object(thread, "_execute")
    mocker.patch.object(
        thread,
        "_fetchone",
        side_effect=[
            (APPLICATION_ID,),
            (USER_VERSION + 1,),
            (1,),
        ],
    )
    mocker.patch.object(thread, "_fetchall", return_value=[])
    mock_logger = mocker.patch("docthread.logger")

    thread.open("test.db")

    mock_logger.warning.assert_called()
    assert "%s was created by a newer version of scantpaper." in str(
        mock_logger.warning.call_args
    )


def test_insert_image_not_found(mocker):
    "test _insert_image with non-existent if_different_from"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_page = mocker.Mock(spec=Page)
    mock_page.to_stored_bytes.return_value = b"image"

    mocker.patch.object(thread, "_execute")
    mocker.patch.object(thread, "_fetchone", return_value=None)

    with pytest.raises(ValueError, match="Image id 1 not found"):
        thread._insert_image(mock_page, if_different_from=1)


def test_add_page_insert_after_not_found(mocker):
    "test add_page with a non-existent insert_after page"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mocker.patch.object(thread, "_take_snapshot")
    mocker.patch.object(thread, "_execute")
    mocker.patch.object(thread, "_fetchone", return_value=None)
    mocker.patch.object(thread, "_insert_image", return_value=(1, "thumb"))
    mocker.patch.object(thread, "_insert_page", return_value=1)

    mock_page = mocker.Mock(spec=Page)

    with pytest.raises(ValueError, match="Page 999 does not exist"):
        thread.add_page(mock_page, insert_after=999)


def test_do_delete_pages_row_ids(mocker):
    "test do_delete_pages with row_ids"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mocker.patch.object(thread, "_take_snapshot")
    mocker.patch.object(thread, "_execute")
    mocker.patch.object(thread, "_executemany")
    mocker.patch.object(thread, "_fetchall", return_value=[])
    tid = threading.get_native_id()
    thread._con[tid] = mocker.Mock()
    thread._cur[tid] = mocker.Mock()

    request = mocker.Mock()
    request.args = [{"row_ids": [1, 2]}]

    thread.do_delete_pages(request)

    # We can inspect the calls if needed, or rely on execution
    assert thread._execute.call_count >= 1


def test_do_delete_pages_no_args(mocker):
    "test do_delete_pages with no args"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mocker.patch.object(thread, "_take_snapshot")

    request = mocker.Mock()
    request.args = [{}]

    with pytest.raises(ValueError, match="Specify either row_id or page_id"):
        thread.do_delete_pages(request)


def test_get_page_errors(mocker):
    "test get_page error conditions"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    # Test no args
    with pytest.raises(ValueError, match="Please specify the page id"):
        thread.get_page()

    mocker.patch.object(thread, "_execute")
    mocker.patch.object(thread, "_fetchone", return_value=None)

    # Test id not found
    with pytest.raises(ValueError, match="Page id 1 not found"):
        thread.get_page(id=1)


def test_do_tesseract_no_lang(mocker):
    "test do_tesseract with no language"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_page = mocker.Mock(spec=Page)
    mocker.patch.object(thread, "get_page", return_value=mock_page)

    request = mocker.Mock()
    request.args = [{"page": 1, "language": None}]

    with pytest.raises(ValueError, match="No tesseract language specified"):
        thread.do_tesseract(request)


def test_do_unpaper_ioerror(mocker):
    "test do_unpaper handling IOError"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()

    mock_page = mocker.Mock(spec=Page)
    mock_page.id = 1
    mock_page.get_depth.return_value = 1
    # Raising IOError from image.save to trigger the except block
    mock_page.image_object = mocker.Mock()
    mock_page.image_object.save.side_effect = IOError("Mocked IOError")

    mocker.patch.object(thread, "get_page", return_value=mock_page)

    request = mocker.Mock()
    request.args = [{"page": 1, "dir": "/tmp", "options": {"command": []}}]

    thread.do_unpaper(request)

    request.error.assert_called()
    assert "Error creating file in /tmp: Mocked IOError" in str(request.error.call_args)


def test_pages_saved_after_replace(temp_db, mocker):
    "test pages_saved after replace_page and do_set_saved with initial_page_id"
    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()

    # 1. Add a page
    img = Image.new("RGB", (10, 10), color="red")
    page = Page(image_object=img)
    _, _, page_id = thread.add_page(page)

    # 2. Check if pages are saved (should be False)
    assert not thread.pages_saved()

    # 3. Replace the page (simulating an edit)
    img2 = Image.new("RGB", (10, 10), color="blue")
    page2 = Page(image_object=img2)

    _, _, initial_page_id = thread.replace_page(page2, initial_page_id=page_id)
    assert initial_page_id == page_id

    # 4. Mark as saved using initial_page_id
    request = mocker.Mock()
    request.args = [[initial_page_id], True]
    thread.do_set_saved(request)

    # 5. Check if pages are saved
    assert thread.pages_saved()


def test_replace_page_reuse_image(temp_db, mocker):
    "test replace_page with reuse_image=True keeps the stored image"
    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()

    img = Image.new("RGB", (10, 10), color="red")
    page = Page(image_object=img)
    _, _, page_id = thread.add_page(page)

    stored_page = thread.get_page(id=page_id)
    image_id = stored_page.image_id
    assert image_id is not None

    insert_image_spy = mocker.spy(thread, "_insert_image")
    reuse_thumb_spy = mocker.spy(thread, "_reuse_image_thumb")

    position, thumb, returned_id = thread.replace_page(
        stored_page, page_id, reuse_image=True
    )

    insert_image_spy.assert_not_called()
    reuse_thumb_spy.assert_called_once_with(image_id)
    assert returned_id == page_id
    assert position == 0
    assert thumb is not None

    assert thread.get_page(id=page_id).image_id == image_id


def test_replace_page_default_inserts_image(temp_db, mocker):
    "test replace_page default reuse_image=False calls _insert_image"
    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()

    img = Image.new("RGB", (10, 10), color="red")
    page = Page(image_object=img)
    _, _, page_id = thread.add_page(page)

    insert_image_spy = mocker.spy(thread, "_insert_image")
    img2 = Image.new("RGB", (10, 10), color="blue")
    page2 = Page(image_object=img2)

    thread.replace_page(page2, page_id)

    insert_image_spy.assert_called_once()


def test_ocr_undo_redo(temp_db, mocker):
    "test OCR text layer changes are undoable and redoable, image preserved"
    hocr = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
 <head>
  <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
 </head>
 <body>
  <div class='ocr_page' id='page_1' title='bbox 0 0 10 10'>
   <div class='ocr_carea' id='block_1_1' title='bbox 0 0 10 10'>
    <span class='ocr_line' id='line_1_1' title='bbox 0 0 10 10'>
     <span class='ocrx_word' id='word_1_1' title='bbox 0 0 10 10; x_wconf 95'>hello</span>
    </span>
   </div>
  </div>
 </body>
</html>
"""
    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()

    img = Image.new("RGB", (10, 10), color="red")
    page = Page(image_object=img)
    _, _, page_id = thread.add_page(page)

    image_id_before = thread.get_page(id=page_id).image_id

    mocker.patch(
        "tesserocr.get_languages",
        return_value=("/usr/share/tesseract-ocr/4.00/tessdata", []),
    )
    mock_api = mocker.patch("tesserocr.PyTessBaseAPI")
    mock_api_instance = mock_api.return_value
    mock_api_instance.__enter__.return_value = mock_api_instance
    mock_api_instance.GetHOCRText.return_value = hocr

    request = mocker.Mock()
    request.args = [{"page": page_id, "language": "eng", "dir": "/tmp"}]

    thread.do_tesseract(request)

    ocr_page = thread.get_page(id=page_id)
    assert '"text": "hello"' in ocr_page.text_layer
    assert (
        thread.get_page(id=page_id).image_id == image_id_before
    ), "no new image inserted by OCR"

    thread.do_undo(Request("undo", (), thread.responses))
    undone_page = thread.get_page(id=page_id)
    assert undone_page.text_layer is None, "undo removes OCR text layer"
    assert thread.get_page(id=page_id).image_id == image_id_before, "undo keeps image"

    thread.do_redo(Request("redo", (), thread.responses))
    redone_page = thread.get_page(id=page_id)
    assert '"text": "hello"' in redone_page.text_layer, "redo restores OCR text layer"
    assert thread.get_page(id=page_id).image_id == image_id_before, "redo keeps image"


def test_issue_74_ghost_page_mechanism(temp_db):
    "issue #74: a clear that bypasses the thread leaves ghosts in the undo chain"

    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()

    page_a = Page(image_object=Image.new("RGB", (10, 10), color="red"))
    _, _, id_a = thread.add_page(page_a)

    # Pre-fix "New File" cleared only the frontend list without informing the
    # thread, so page A remains in the snapshot chain. Simulate that here by
    # sending nothing to the thread before scanning the next page.

    page_b = Page(image_object=Image.new("RGB", (10, 10), color="blue"))
    _, _, id_b = thread.add_page(page_b)

    # edit page B
    edited = Page(image_object=Image.new("RGB", (10, 10), color="green"))
    thread.replace_page(edited, id_b)

    result = thread.do_undo(Request("undo", (), thread.responses))
    ids = [row[2] for row in result["snapshot"]]
    assert ids == [
        id_a,
        id_b,
    ], "a frontend-only clear leaves page A in the undo chain (issue #74)"


def test_undo_after_delete_all_has_no_ghost(temp_db):
    "issue #74 regression: clearing all pages via the thread keeps undo coherent"

    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()

    page_a = Page(image_object=Image.new("RGB", (10, 10), color="red"))
    _, _, id_a = thread.add_page(page_a)

    # fixed "New File": the clear is routed through the thread like any other
    # deletion
    request = Request("delete_pages", ({"page_ids": [id_a]},), thread.responses)
    thread.do_delete_pages(request)

    page_b = Page(image_object=Image.new("RGB", (10, 10), color="blue"))
    _, _, id_b = thread.add_page(page_b)

    # edit page B
    edited = Page(image_object=Image.new("RGB", (10, 10), color="green"))
    thread.replace_page(edited, id_b)

    result = thread.do_undo(Request("undo", (), thread.responses))
    ids = [row[2] for row in result["snapshot"]]
    assert ids == [id_b], "undo after New File restores only unedited page B"


def test_open_migration_v1_to_v2(temp_db):
    "test migration from version 1 to 2"

    db_path = temp_db.name
    conn = sqlite3.connect(db_path)
    cur = conn.cursor()
    cur.execute(f"PRAGMA application_id = {APPLICATION_ID}")
    cur.execute("PRAGMA user_version = 1")
    cur.execute("CREATE TABLE image(id INTEGER PRIMARY KEY, image BLOB, thumb BLOB)")
    cur.execute("""CREATE TABLE page(
                id INTEGER PRIMARY KEY,
                image_id INTEGER NOT NULL,
                x_res FLOAT,
                y_res FLOAT,
                std_dev TEXT,
                mean TEXT,
                saved BOOL,
                text TEXT,
                annotations TEXT,
                FOREIGN KEY (image_id) REFERENCES image(id))""")
    cur.execute("""CREATE TABLE page_order(
                action_id INTEGER NOT NULL,
                row_id INTEGER NOT NULL,
                page_number INTEGER NOT NULL,
                page_id INTEGER NOT NULL,
                PRIMARY KEY (action_id, row_id))""")
    cur.execute("INSERT INTO image VALUES (1, NULL, NULL)")
    cur.execute("INSERT INTO page (id, image_id) VALUES (1, 1)")
    cur.execute("INSERT INTO page_order VALUES (0, 0, 1, 1)")
    conn.commit()
    conn.close()

    thread = DocThread(db=db_path)

    # Check if migration was successful
    thread._execute("PRAGMA user_version")
    assert thread._fetchone()[0] == USER_VERSION

    thread._execute("SELECT initial_page_id FROM page_order WHERE page_id = 1")
    assert thread._fetchone()[0] == 1


def test_pixbuf_to_bytes(mocker):
    "test _pixbuf_to_bytes"
    thread = DocThread(db=":memory:")
    assert (
        thread._pixbuf_to_bytes(None) == b""
    ), "Expected None pixbuf to return empty bytes"


def test_init_race_condition(tmp_path, monkeypatch):
    """
    Test that DocThread.__init__ correctly waits for the 'create' request
    to finish, even if it takes longer than the old 2-second timeout.
    """
    db_path = tmp_path / "test_race.db"

    original_do_create = DocThread.do_create

    def delayed_do_create(self, request):
        time.sleep(3)  # Longer than the old 2s timeout
        original_do_create(self, request)

    monkeypatch.setattr(DocThread, "do_create", delayed_do_create)

    # This should now wait up to 10s and succeed
    thread = DocThread(db=db_path)

    # Verify that the tables were created by calling a method that uses them
    # If the race condition exists, this would raise sqlite3.OperationalError
    assert thread.can_undo() is False

    thread.quit()


def test_init_timeout_logging(tmp_path, mocker, caplog):
    "Test that DocThread.__init__ logs an error if initialization times out."
    db_path = tmp_path / "test_timeout.db"

    # Mock GLib.timeout_add to use a very short timeout instead of 10s
    original_timeout_add = GLib.timeout_add

    def mock_timeout_add(ms, callback, *args):
        return original_timeout_add(1, callback, *args)

    mocker.patch("gi.repository.GLib.timeout_add", side_effect=mock_timeout_add)
    # Mock send to do nothing so it times out
    mocker.patch.object(DocThread, "send")

    with caplog.at_level("ERROR"):
        thread = DocThread(db=db_path)

    assert f"Failed to initialize DocThread for {db_path}" in caplog.text
    thread.quit()


def test_close(mocker):
    "Test closing the db"
    thread = DocThread(db=":memory:")
    tid = threading.get_native_id()
    con = mocker.Mock()
    thread._con[tid] = con
    thread.close()
    con.close.assert_called()
    assert tid not in thread._con, "removed connection from pool on close"


def test_save_as(temp_db, mocker):
    "Test saving the db"
    thread = DocThread(db=":memory:")
    execute = mocker.patch.object(thread, "_execute")
    thread.save_as(temp_db.name)
    execute.assert_called_with(f"VACUUM INTO '{temp_db.name}'")


def test_init_no_dir_db():
    "Test __init__ falls back to temp dir when no dir or db given"
    thread = DocThread()
    assert thread._dir is not None
    assert thread._db is not None
    thread.quit()


def test_do_open(mocker):
    "Test do_open calls open with the given path"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()
    mock_open = mocker.patch.object(thread, "open")
    request = mocker.Mock()
    request.args = ["/tmp/test.db"]
    thread.do_open(request)
    mock_open.assert_called_with("/tmp/test.db")


def test_add_page_insert_at_start(mocker):
    "Test add_page with INSERT_AT_START position"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()
    thread._con[threading.get_native_id()] = mocker.Mock()
    mocker.patch.object(thread, "_take_snapshot")
    mocker.patch.object(thread, "_insert_image", return_value=(1, None))
    mocker.patch.object(thread, "_insert_page", return_value=99)
    mock_shift = mocker.patch.object(thread, "_shift_row_ids")
    mock_execute = mocker.patch.object(thread, "_execute")

    page = mocker.Mock(spec=Page)
    page.resolution = (300, 300, 3)

    from docthread import INSERT_AT_START

    result = thread.add_page(page, insert_after=INSERT_AT_START)
    mock_shift.assert_called_with(1, 1)
    mock_execute.assert_called()


def test_page_number_table_error(mocker):
    "Test page_number_table error callback"
    thread = DocThread(db=":memory:")
    mock_send = mocker.patch.object(thread, "send")

    def send_side_effect(*args, **kwargs):
        error_callback = kwargs["error_callback"]
        response = mocker.Mock()
        response.info = None
        GLib.idle_add(lambda: error_callback(response))

    mock_send.side_effect = send_side_effect
    result = thread.page_number_table()
    assert result is None


def test_do_import_page_insert_after(mocker):
    "Test do_import_page with insert_after set"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()
    mock_page_cls = mocker.patch("docthread.Page")
    mock_page_cls.return_value.get_resolution.return_value = (300, 300, 3)
    mock_add_page = mocker.patch.object(thread, "add_page", return_value=(1, None, 99))
    request = mocker.Mock()
    request.args = [{"filename": "/tmp/test.png", "insert_after": 5}]
    thread.do_import_page(request)
    mock_add_page.assert_called_with(mocker.ANY, insert_after=5)
    request.data.assert_called_once_with(
        {"type": "page", "row": (1, None, 99), "insert-after": 5}
    )


def test_close_tid_not_in_con():
    "Test close when tid is not in _con"
    thread = DocThread(db=":memory:")
    thread.close()


def test_insert_page_no_resolution(mocker):
    "Test _insert_page when page.resolution is falsy"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()
    mock_execute = mocker.patch.object(thread, "_execute")
    tid = threading.get_native_id()
    thread._con[tid] = mocker.Mock()
    thread._cur[tid] = mocker.Mock()
    thread._cur[tid].lastrowid = 1
    mocker.patch.object(thread, "_fetchone", return_value=[1])

    page = mocker.Mock(spec=Page)
    page.resolution = None
    page.mean = None
    page.std_dev = None

    thread._insert_page(page, 1)
    execute_call = mock_execute.call_args_list[0]
    assert None in execute_call[0][1][2:4]


def test_do_rotate_not_90(mocker):
    "Test do_rotate with angle not in (-90, 90)"
    thread = DocThread(db=":memory:")
    thread._write_tid = threading.get_native_id()
    mocker.patch.object(thread, "check_cancelled")
    mock_replace = mocker.patch.object(
        thread, "replace_page", return_value=(1, None, 99)
    )

    page = mocker.Mock(spec=Page)
    page.id = 1
    page.resolution = (200, 300, 3)
    page.width = 100
    page.height = 200
    page.dirty_time = None
    page.saved = False
    img = mocker.Mock()
    img.width = 100
    img.height = 200
    img.rotate.return_value = img
    page.image_object = img
    mocker.patch.object(thread, "get_page", return_value=page)

    request = mocker.Mock()
    request.args = [{"angle": 45, "dir": "/tmp", "page": 1}]
    thread.do_rotate(request)

    assert page.width == 100
    assert page.height == 200
    mock_replace.assert_called_once()
