"""Tests for app/tasks/watch_folder_tasks.py module."""

import os
import stat
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch

import pytest


@pytest.mark.unit
class TestCacheHelpers:
    """Tests for the cache helper functions in watch_folder_tasks."""

    def test_evict_old_entries_removes_old(self):
        """Entries older than CACHE_RETENTION_DAYS should be removed."""
        from app.tasks.watch_folder_tasks import _evict_old_entries

        old_dt = (datetime.now(timezone.utc) - timedelta(days=40)).isoformat()
        recent_dt = datetime.now(timezone.utc).isoformat()
        data = {"old_key": old_dt, "new_key": recent_dt}
        result = _evict_old_entries(data)
        assert "old_key" not in result
        assert "new_key" in result

    def test_evict_old_entries_empty(self):
        """Empty cache should return empty dict."""
        from app.tasks.watch_folder_tasks import _evict_old_entries

        assert _evict_old_entries({}) == {}

    def test_evict_old_entries_skips_malformed_dates(self):
        """Entries with malformed timestamps should be silently dropped."""
        from app.tasks.watch_folder_tasks import _evict_old_entries

        data = {"bad_key": "not-a-date", "good_key": datetime.now(timezone.utc).isoformat()}
        result = _evict_old_entries(data)
        assert "bad_key" not in result
        assert "good_key" in result

    def test_mark_processed_adds_entry(self):
        """_mark_processed should add an ISO-formatted timestamp for the key."""
        from app.tasks.watch_folder_tasks import _mark_processed

        cache: dict = {}
        _mark_processed(cache, "/some/file.pdf")
        assert "/some/file.pdf" in cache
        # Timestamp should be parseable
        datetime.fromisoformat(cache["/some/file.pdf"])

    def test_load_cache_returns_empty_when_no_file(self):
        """_load_cache should return {} when the cache file does not exist."""
        from app.tasks.watch_folder_tasks import _load_cache

        result = _load_cache("/tmp/does_not_exist_xyz.json")
        assert result == {}

    def test_save_and_load_roundtrip(self, tmp_path):
        """Saving and loading cache should preserve entries."""
        from app.tasks.watch_folder_tasks import _load_cache, _save_cache

        cache_file = str(tmp_path / "cache.json")
        data = {"key1": datetime.now(timezone.utc).isoformat()}
        _save_cache(cache_file, data)
        loaded = _load_cache(cache_file)
        assert "key1" in loaded

    def test_load_cache_handles_invalid_json(self, tmp_path):
        """_load_cache should return {} for corrupted JSON files."""
        from app.tasks.watch_folder_tasks import _load_cache

        cache_file = str(tmp_path / "bad.json")
        with open(cache_file, "w") as f:
            f.write("not valid json{{{{")
        result = _load_cache(cache_file)
        assert result == {}


@pytest.mark.unit
class TestIsAllowedFile:
    """Tests for the _is_allowed_file helper."""

    def test_pdf_is_allowed(self):
        from app.tasks.watch_folder_tasks import _is_allowed_file

        assert _is_allowed_file("document.pdf") is True
        assert _is_allowed_file("DOCUMENT.PDF") is True

    def test_docx_is_allowed(self):
        from app.tasks.watch_folder_tasks import _is_allowed_file

        assert _is_allowed_file("report.docx") is True

    def test_exe_is_not_allowed(self):
        from app.tasks.watch_folder_tasks import _is_allowed_file

        assert _is_allowed_file("malware.exe") is False

    def test_zip_is_not_allowed(self):
        from app.tasks.watch_folder_tasks import _is_allowed_file

        assert _is_allowed_file("archive.zip") is False


@pytest.mark.unit
class TestScanLocalFolder:
    """Tests for _scan_local_folder."""

    def test_nonexistent_folder_returns_zero(self):
        from app.tasks.watch_folder_tasks import _scan_local_folder

        count = _scan_local_folder("/tmp/does_not_exist_xyz_abc", {}, False)
        assert count == 0

    def test_empty_folder_returns_zero(self, tmp_path):
        from app.tasks.watch_folder_tasks import _scan_local_folder

        count = _scan_local_folder(str(tmp_path), {}, False)
        assert count == 0

    def test_new_pdf_is_enqueued(self, tmp_path):
        """A new PDF in the watch folder should be enqueued for processing."""
        from app.tasks.watch_folder_tasks import _scan_local_folder

        pdf_file = tmp_path / "test.pdf"
        pdf_file.write_bytes(b"%PDF-1.4 test")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.process_document") as mock_proc,
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path / "workdir")
            os.makedirs(mock_settings.workdir, exist_ok=True)
            count = _scan_local_folder(str(tmp_path), cache, False)

        assert count == 1
        assert str(pdf_file) in cache

    def test_already_cached_file_is_skipped(self, tmp_path):
        """Files already in cache should not be re-processed."""
        from app.tasks.watch_folder_tasks import _scan_local_folder

        pdf_file = tmp_path / "already.pdf"
        pdf_file.write_bytes(b"%PDF-1.4 test")

        cache = {str(pdf_file): datetime.now(timezone.utc).isoformat()}
        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path / "workdir")
            os.makedirs(mock_settings.workdir, exist_ok=True)
            count = _scan_local_folder(str(tmp_path), cache, False)

        assert count == 0

    def test_unsupported_file_is_skipped(self, tmp_path):
        """Unsupported file types should not be enqueued."""
        from app.tasks.watch_folder_tasks import _scan_local_folder

        exe_file = tmp_path / "bad.exe"
        exe_file.write_bytes(b"MZ malware")

        cache: dict = {}
        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path / "workdir")
            count = _scan_local_folder(str(tmp_path), cache, False)

        assert count == 0
        assert str(exe_file) not in cache

    def test_delete_after_process_removes_source(self, tmp_path):
        """When delete_after_process=True, source files should be deleted."""
        from app.tasks.watch_folder_tasks import _scan_local_folder

        pdf_file = tmp_path / "invoice.pdf"
        pdf_file.write_bytes(b"%PDF-1.4")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.process_document"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path / "workdir")
            os.makedirs(mock_settings.workdir, exist_ok=True)
            _scan_local_folder(str(tmp_path), cache, delete_after=True)

        assert not pdf_file.exists()


@pytest.mark.unit
class TestAcquireReleaseLock:
    """Tests for the Redis-based locking helpers."""

    def test_acquire_lock_succeeds(self):
        from app.tasks.watch_folder_tasks import _acquire_lock

        mock_redis = MagicMock()
        mock_redis.setnx.return_value = True
        with patch("app.tasks.watch_folder_tasks.redis_client", mock_redis):
            result = _acquire_lock("test_lock")
        assert result is True

    def test_acquire_lock_fails_when_held(self):
        from app.tasks.watch_folder_tasks import _acquire_lock

        mock_redis = MagicMock()
        mock_redis.setnx.return_value = False
        with patch("app.tasks.watch_folder_tasks.redis_client", mock_redis):
            result = _acquire_lock("test_lock")
        assert result is False

    def test_release_lock_deletes_key(self):
        from app.tasks.watch_folder_tasks import _release_lock

        mock_redis = MagicMock()
        with patch("app.tasks.watch_folder_tasks.redis_client", mock_redis):
            _release_lock("test_lock")
        mock_redis.delete.assert_called_once_with("test_lock")


@pytest.mark.unit
class TestScanLocalWatchFoldersTask:
    """Tests for the scan_local_watch_folders Celery task."""

    def test_returns_skipped_when_no_folders_configured(self):
        from app.tasks.watch_folder_tasks import scan_local_watch_folders

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.watch_folders = None
            result = scan_local_watch_folders()
        assert result["status"] == "skipped"

    def test_returns_skipped_for_empty_string(self):
        from app.tasks.watch_folder_tasks import scan_local_watch_folders

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.watch_folders = ""
            result = scan_local_watch_folders()
        assert result["status"] == "skipped"

    def test_scans_configured_folder(self, tmp_path):
        """With a valid folder configured, the task should scan it."""
        from app.tasks.watch_folder_tasks import scan_local_watch_folders

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_local_folder", return_value=0) as mock_scan,
        ):
            mock_settings.watch_folders = str(tmp_path)
            mock_settings.watch_folder_delete_after_process = False
            result = scan_local_watch_folders()

        mock_scan.assert_called_once()
        assert result["status"] == "ok"
        assert result["files_enqueued"] == 0


@pytest.mark.unit
class TestScanFtpWatchFolderTask:
    """Tests for the scan_ftp_watch_folder Celery task."""

    def test_returns_skipped_when_disabled(self):
        from app.tasks.watch_folder_tasks import scan_ftp_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.ftp_ingest_enabled = False
            result = scan_ftp_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_skipped_when_no_folder_configured(self):
        from app.tasks.watch_folder_tasks import scan_ftp_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.ftp_ingest_enabled = True
            mock_settings.ftp_ingest_folder = None
            result = scan_ftp_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_error_when_connection_fails(self):
        from app.tasks.watch_folder_tasks import scan_ftp_watch_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._connect_ftp", return_value=None),
        ):
            mock_settings.ftp_ingest_enabled = True
            mock_settings.ftp_ingest_folder = "/inbox"
            mock_settings.ftp_ingest_delete_after_process = False
            result = scan_ftp_watch_folder()
        assert result["status"] == "error"

    def test_successful_scan(self):
        from app.tasks.watch_folder_tasks import scan_ftp_watch_folder

        mock_ftp = MagicMock()
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._connect_ftp", return_value=mock_ftp),
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_ftp_folder", return_value=2),
        ):
            mock_settings.ftp_ingest_enabled = True
            mock_settings.ftp_ingest_folder = "/inbox"
            mock_settings.ftp_ingest_delete_after_process = False
            result = scan_ftp_watch_folder()

        assert result["status"] == "ok"
        assert result["files_enqueued"] == 2


@pytest.mark.unit
class TestScanSftpWatchFolderTask:
    """Tests for the scan_sftp_watch_folder Celery task."""

    def test_returns_skipped_when_disabled(self):
        from app.tasks.watch_folder_tasks import scan_sftp_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.sftp_ingest_enabled = False
            result = scan_sftp_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_skipped_when_no_folder_configured(self):
        from app.tasks.watch_folder_tasks import scan_sftp_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.sftp_ingest_enabled = True
            mock_settings.sftp_ingest_folder = None
            result = scan_sftp_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_error_when_connection_fails(self):
        from app.tasks.watch_folder_tasks import scan_sftp_watch_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._get_sftp_connection", return_value=(None, None)),
        ):
            mock_settings.sftp_ingest_enabled = True
            mock_settings.sftp_ingest_folder = "/upload"
            mock_settings.sftp_ingest_delete_after_process = False
            result = scan_sftp_watch_folder()
        assert result["status"] == "error"

    def test_successful_scan(self):
        from app.tasks.watch_folder_tasks import scan_sftp_watch_folder

        mock_ssh = MagicMock()
        mock_sftp = MagicMock()
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._get_sftp_connection", return_value=(mock_ssh, mock_sftp)),
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_sftp_folder", return_value=3),
        ):
            mock_settings.sftp_ingest_enabled = True
            mock_settings.sftp_ingest_folder = "/upload"
            mock_settings.sftp_ingest_delete_after_process = False
            result = scan_sftp_watch_folder()

        assert result["status"] == "ok"
        assert result["files_enqueued"] == 3


@pytest.mark.unit
class TestScanAllWatchFolders:
    """Tests for the scan_all_watch_folders orchestrator task."""

    def test_skips_when_lock_held(self):
        from app.tasks.watch_folder_tasks import scan_all_watch_folders

        with patch("app.tasks.watch_folder_tasks._acquire_lock", return_value=False):
            result = scan_all_watch_folders()
        assert result["status"] == "skipped"

    def test_runs_all_scans_and_releases_lock(self):
        from app.tasks.watch_folder_tasks import scan_all_watch_folders

        with (
            patch("app.tasks.watch_folder_tasks._acquire_lock", return_value=True),
            patch("app.tasks.watch_folder_tasks._release_lock") as mock_release,
            patch("app.tasks.watch_folder_tasks.scan_local_watch_folders", return_value={"status": "ok"}),
            patch("app.tasks.watch_folder_tasks.scan_ftp_watch_folder", return_value={"status": "skipped"}),
            patch("app.tasks.watch_folder_tasks.scan_sftp_watch_folder", return_value={"status": "skipped"}),
        ):
            result = scan_all_watch_folders()

        assert result["status"] == "ok"
        assert "results" in result
        mock_release.assert_called_once()

    def test_lock_released_even_on_exception(self):
        """Lock must be released even if a sub-scan raises an exception."""
        from app.tasks.watch_folder_tasks import scan_all_watch_folders

        with (
            patch("app.tasks.watch_folder_tasks._acquire_lock", return_value=True),
            patch("app.tasks.watch_folder_tasks._release_lock") as mock_release,
            patch("app.tasks.watch_folder_tasks.scan_local_watch_folders", side_effect=RuntimeError("boom")),
        ):
            with pytest.raises(RuntimeError):
                scan_all_watch_folders()

        mock_release.assert_called_once()


@pytest.mark.unit
class TestConnectFtp:
    """Tests for the _connect_ftp helper."""

    def test_returns_none_when_settings_incomplete(self):
        from app.tasks.watch_folder_tasks import _connect_ftp

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.ftp_host = None
            mock_settings.ftp_username = "user"
            mock_settings.ftp_password = "pass"  # noqa: S105
            result = _connect_ftp()
        assert result is None

    def test_returns_none_when_connection_fails(self):
        from app.tasks.watch_folder_tasks import _connect_ftp

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("ftplib.FTP_TLS") as mock_ftps_cls,
            patch("ftplib.FTP") as mock_ftp_cls,
        ):
            mock_settings.ftp_host = "ftp.example.com"
            mock_settings.ftp_port = 21
            mock_settings.ftp_username = "user"
            mock_settings.ftp_password = "pass"  # noqa: S105
            mock_settings.ftp_use_tls = False
            mock_settings.ftp_allow_plaintext = True

            mock_ftp_cls.return_value.connect.side_effect = ConnectionRefusedError("refused")
            result = _connect_ftp()
        assert result is None


@pytest.mark.unit
class TestScanFtpFolder:
    """Tests for the _scan_ftp_folder helper."""

    def test_cwd_failure_returns_zero(self):
        import ftplib  # noqa: S402

        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.side_effect = ftplib.error_perm("550 no such directory")  # noqa: S321
        count = _scan_ftp_folder(mock_ftp, "/missing", {}, False)
        assert count == 0

    def test_skips_disallowed_files(self):
        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.return_value = None
        mock_ftp.nlst.return_value = ["photo.exe", "virus.bat"]
        count = _scan_ftp_folder(mock_ftp, "/inbox", {}, False)
        assert count == 0

    def test_downloads_new_allowed_file(self, tmp_path):
        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.return_value = None
        mock_ftp.nlst.return_value = ["invoice.pdf"]

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)

            # Simulate retrbinary writing bytes
            def fake_retrbinary(cmd, callback):
                callback(b"%PDF-1.4")

            mock_ftp.retrbinary.side_effect = fake_retrbinary
            count = _scan_ftp_folder(mock_ftp, "/inbox", cache, False)

        assert count == 1
        assert "ftp:/inbox/invoice.pdf" in cache

    def test_already_cached_file_is_skipped(self, tmp_path):
        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.return_value = None
        mock_ftp.nlst.return_value = ["invoice.pdf"]

        cache = {"ftp:/inbox/invoice.pdf": datetime.now(timezone.utc).isoformat()}
        count = _scan_ftp_folder(mock_ftp, "/inbox", cache, False)
        assert count == 0


@pytest.mark.unit
class TestDropboxWatchFolderTask:
    """Tests for the scan_dropbox_watch_folder Celery task."""

    def test_returns_skipped_when_disabled(self):
        from app.tasks.watch_folder_tasks import scan_dropbox_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.dropbox_ingest_enabled = False
            result = scan_dropbox_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_skipped_when_no_folder_configured(self):
        from app.tasks.watch_folder_tasks import scan_dropbox_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.dropbox_ingest_enabled = True
            mock_settings.dropbox_ingest_folder = None
            result = scan_dropbox_watch_folder()
        assert result["status"] == "skipped"

    def test_successful_scan(self):
        from app.tasks.watch_folder_tasks import scan_dropbox_watch_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_dropbox_folder", return_value=1),
        ):
            mock_settings.dropbox_ingest_enabled = True
            mock_settings.dropbox_ingest_folder = "/Inbox"
            mock_settings.dropbox_ingest_delete_after_process = False
            result = scan_dropbox_watch_folder()

        assert result["status"] == "ok"
        assert result["files_enqueued"] == 1


@pytest.mark.unit
class TestGoogleDriveWatchFolderTask:
    """Tests for the scan_google_drive_watch_folder Celery task."""

    def test_returns_skipped_when_disabled(self):
        from app.tasks.watch_folder_tasks import scan_google_drive_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.google_drive_ingest_enabled = False
            result = scan_google_drive_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_skipped_when_no_folder_id(self):
        from app.tasks.watch_folder_tasks import scan_google_drive_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.google_drive_ingest_enabled = True
            mock_settings.google_drive_ingest_folder_id = None
            result = scan_google_drive_watch_folder()
        assert result["status"] == "skipped"

    def test_successful_scan(self):
        from app.tasks.watch_folder_tasks import scan_google_drive_watch_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_google_drive_folder", return_value=2),
        ):
            mock_settings.google_drive_ingest_enabled = True
            mock_settings.google_drive_ingest_folder_id = "abc123"
            mock_settings.google_drive_ingest_delete_after_process = False
            result = scan_google_drive_watch_folder()

        assert result["status"] == "ok"
        assert result["files_enqueued"] == 2


@pytest.mark.unit
class TestOnedriveWatchFolderTask:
    """Tests for the scan_onedrive_watch_folder Celery task."""

    def test_returns_skipped_when_disabled(self):
        from app.tasks.watch_folder_tasks import scan_onedrive_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.onedrive_ingest_enabled = False
            result = scan_onedrive_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_skipped_when_no_folder_path(self):
        from app.tasks.watch_folder_tasks import scan_onedrive_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.onedrive_ingest_enabled = True
            mock_settings.onedrive_ingest_folder_path = None
            result = scan_onedrive_watch_folder()
        assert result["status"] == "skipped"

    def test_successful_scan(self):
        from app.tasks.watch_folder_tasks import scan_onedrive_watch_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_onedrive_folder", return_value=3),
        ):
            mock_settings.onedrive_ingest_enabled = True
            mock_settings.onedrive_ingest_folder_path = "/Inbox/Scanner"
            mock_settings.onedrive_ingest_delete_after_process = False
            result = scan_onedrive_watch_folder()

        assert result["status"] == "ok"
        assert result["files_enqueued"] == 3


@pytest.mark.unit
class TestNextcloudWatchFolderTask:
    """Tests for the scan_nextcloud_watch_folder Celery task."""

    def test_returns_skipped_when_disabled(self):
        from app.tasks.watch_folder_tasks import scan_nextcloud_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.nextcloud_ingest_enabled = False
            result = scan_nextcloud_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_skipped_when_no_folder(self):
        from app.tasks.watch_folder_tasks import scan_nextcloud_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.nextcloud_ingest_enabled = True
            mock_settings.nextcloud_ingest_folder = None
            result = scan_nextcloud_watch_folder()
        assert result["status"] == "skipped"

    def test_successful_scan(self):
        from app.tasks.watch_folder_tasks import scan_nextcloud_watch_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_nextcloud_folder", return_value=1),
        ):
            mock_settings.nextcloud_ingest_enabled = True
            mock_settings.nextcloud_ingest_folder = "/Scans/Inbox"
            mock_settings.nextcloud_ingest_delete_after_process = False
            result = scan_nextcloud_watch_folder()

        assert result["status"] == "ok"
        assert result["files_enqueued"] == 1


@pytest.mark.unit
class TestS3WatchFolderTask:
    """Tests for the scan_s3_watch_folder Celery task."""

    def test_returns_skipped_when_disabled(self):
        from app.tasks.watch_folder_tasks import scan_s3_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.s3_ingest_enabled = False
            result = scan_s3_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_skipped_when_no_prefix(self):
        from app.tasks.watch_folder_tasks import scan_s3_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.s3_ingest_enabled = True
            mock_settings.s3_ingest_prefix = None
            result = scan_s3_watch_folder()
        assert result["status"] == "skipped"

    def test_successful_scan(self):
        from app.tasks.watch_folder_tasks import scan_s3_watch_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_s3_prefix", return_value=4),
        ):
            mock_settings.s3_ingest_enabled = True
            mock_settings.s3_ingest_prefix = "inbox/scanner/"
            mock_settings.s3_ingest_delete_after_process = False
            result = scan_s3_watch_folder()

        assert result["status"] == "ok"
        assert result["files_enqueued"] == 4


@pytest.mark.unit
class TestWebdavWatchFolderTask:
    """Tests for the scan_webdav_watch_folder Celery task."""

    def test_returns_skipped_when_disabled(self):
        from app.tasks.watch_folder_tasks import scan_webdav_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.webdav_ingest_enabled = False
            result = scan_webdav_watch_folder()
        assert result["status"] == "skipped"

    def test_returns_skipped_when_no_folder(self):
        from app.tasks.watch_folder_tasks import scan_webdav_watch_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.webdav_ingest_enabled = True
            mock_settings.webdav_ingest_folder = None
            result = scan_webdav_watch_folder()
        assert result["status"] == "skipped"

    def test_successful_scan(self):
        from app.tasks.watch_folder_tasks import scan_webdav_watch_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_webdav_folder", return_value=2),
        ):
            mock_settings.webdav_ingest_enabled = True
            mock_settings.webdav_ingest_folder = "/remote.php/webdav/Inbox"
            mock_settings.webdav_ingest_delete_after_process = False
            result = scan_webdav_watch_folder()

        assert result["status"] == "ok"
        assert result["files_enqueued"] == 2


@pytest.mark.unit
class TestScanAllWatchFoldersCloud:
    """Tests for the extended scan_all_watch_folders with cloud providers."""

    def test_all_cloud_providers_called(self):
        """scan_all_watch_folders should call all provider-specific tasks."""
        from app.tasks.watch_folder_tasks import scan_all_watch_folders

        provider_tasks = [
            "scan_local_watch_folders",
            "scan_ftp_watch_folder",
            "scan_sftp_watch_folder",
            "scan_dropbox_watch_folder",
            "scan_google_drive_watch_folder",
            "scan_onedrive_watch_folder",
            "scan_nextcloud_watch_folder",
            "scan_s3_watch_folder",
            "scan_webdav_watch_folder",
        ]

        with (
            patch("app.tasks.watch_folder_tasks._acquire_lock", return_value=True),
            patch("app.tasks.watch_folder_tasks._release_lock"),
        ):
            mocks = {}
            patches = []
            for name in provider_tasks:
                m = MagicMock(return_value={"status": "skipped"})
                p = patch(f"app.tasks.watch_folder_tasks.{name}", m)
                patches.append(p)
                mocks[name] = m

            # Apply all patches
            for p in patches:
                p.start()
            try:
                result = scan_all_watch_folders()
            finally:
                for p in patches:
                    p.stop()

        assert result["status"] == "ok"
        for name in provider_tasks:
            mocks[name].assert_called_once()


# ===========================================================================
# Additional coverage tests
# ===========================================================================


@pytest.mark.unit
class TestSaveCacheErrors:
    """Tests for _save_cache error handling."""

    def test_save_cache_logs_error_on_os_error(self, tmp_path):
        """_save_cache should log an error when it cannot write the file."""
        from app.tasks.watch_folder_tasks import _save_cache

        # Use a path where the parent directory does not exist
        bad_path = str(tmp_path / "nonexistent_dir" / "cache.json")
        # Should not raise — just log
        _save_cache(bad_path, {"key": "val"})


@pytest.mark.unit
class TestEvictOldEntriesTimezoneNaive:
    """Additional eviction edge cases."""

    def test_evict_keeps_timezone_naive_recent_entry(self):
        """Timezone-naive timestamps should be treated as UTC and kept if recent."""
        from app.tasks.watch_folder_tasks import _evict_old_entries

        # Naive datetime string (no +00:00), recent
        naive_recent = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
        result = _evict_old_entries({"recent_naive": naive_recent})
        assert "recent_naive" in result

    def test_evict_removes_timezone_naive_old_entry(self):
        """Old timezone-naive entries should be evicted."""
        from app.tasks.watch_folder_tasks import _evict_old_entries

        old_naive = (datetime.now(timezone.utc) - timedelta(days=60)).replace(tzinfo=None).isoformat()
        result = _evict_old_entries({"old_naive": old_naive})
        assert "old_naive" not in result


@pytest.mark.unit
class TestEnqueueFileNonPdf:
    """Tests for _enqueue_file with non-PDF files."""

    def test_non_pdf_triggers_convert_to_pdf(self):
        """Non-PDF files should be dispatched to convert_to_pdf."""
        from app.tasks.watch_folder_tasks import _enqueue_file

        with (
            patch("app.tasks.watch_folder_tasks.convert_to_pdf") as mock_conv,
            patch("app.tasks.watch_folder_tasks.process_document") as mock_proc,
        ):
            _enqueue_file("/tmp/doc.docx")
        mock_conv.delay.assert_called_once_with("/tmp/doc.docx", owner_id=None)
        mock_proc.delay.assert_not_called()

    def test_pdf_triggers_process_document(self):
        """PDF files should be dispatched to process_document."""
        from app.tasks.watch_folder_tasks import _enqueue_file

        with (
            patch("app.tasks.watch_folder_tasks.convert_to_pdf") as mock_conv,
            patch("app.tasks.watch_folder_tasks.process_document") as mock_proc,
        ):
            _enqueue_file("/tmp/report.pdf")
        mock_proc.delay.assert_called_once_with("/tmp/report.pdf", owner_id=None)
        mock_conv.delay.assert_not_called()

    def test_pdf_detected_via_filename_param(self):
        """When filename kwarg ends with .pdf, process_document should be called."""
        from app.tasks.watch_folder_tasks import _enqueue_file

        with (
            patch("app.tasks.watch_folder_tasks.convert_to_pdf") as mock_conv,
            patch("app.tasks.watch_folder_tasks.process_document") as mock_proc,
        ):
            _enqueue_file("/tmp/somefile", filename="renamed.pdf")
        mock_proc.delay.assert_called_once_with("/tmp/somefile", owner_id=None)
        mock_conv.delay.assert_not_called()


@pytest.mark.unit
class TestScanLocalFolderEdgeCases:
    """Additional edge cases for _scan_local_folder."""

    def test_permission_error_returns_zero(self, tmp_path):
        """PermissionError during scandir should return 0."""
        from app.tasks.watch_folder_tasks import _scan_local_folder

        with patch("os.scandir", side_effect=PermissionError("denied")):
            count = _scan_local_folder(str(tmp_path), {}, False)
        assert count == 0

    def test_copy_oserror_skips_file(self, tmp_path):
        """If shutil.copy2 raises OSError, the file should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_local_folder

        pdf_file = tmp_path / "fail.pdf"
        pdf_file.write_bytes(b"%PDF-1.4")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("shutil.copy2", side_effect=OSError("disk full")),
        ):
            mock_settings.workdir = str(tmp_path / "workdir")
            os.makedirs(mock_settings.workdir, exist_ok=True)
            count = _scan_local_folder(str(tmp_path), cache, False)

        assert count == 0
        assert str(pdf_file) not in cache

    def test_file_collision_generates_unique_name(self, tmp_path):
        """When dest file already exists, a timestamped name should be used."""
        from app.tasks.watch_folder_tasks import _scan_local_folder

        pdf_file = tmp_path / "doc.pdf"
        pdf_file.write_bytes(b"%PDF-1.4")

        workdir = tmp_path / "workdir"
        workdir.mkdir()
        # Pre-create the destination to force the collision path
        (workdir / "wf_doc.pdf").write_bytes(b"old")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.process_document"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(workdir)
            count = _scan_local_folder(str(tmp_path), cache, False)

        assert count == 1

    def test_delete_after_oserror_does_not_crash(self, tmp_path):
        """If os.remove raises OSError during delete_after, it should be logged but not crash."""
        from app.tasks.watch_folder_tasks import _scan_local_folder

        pdf_file = tmp_path / "todelete.pdf"
        pdf_file.write_bytes(b"%PDF-1.4")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.process_document"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("os.remove", side_effect=OSError("busy")),
        ):
            mock_settings.workdir = str(tmp_path / "workdir")
            os.makedirs(mock_settings.workdir, exist_ok=True)
            # Bypass the shutil.copy2 to avoid hitting the real copy
            with patch("shutil.copy2"):
                count = _scan_local_folder(str(tmp_path), cache, delete_after=True)

        assert count == 1


@pytest.mark.unit
class TestConnectFtpTlsFallback:
    """Tests for the FTP TLS/plaintext fallback logic in _connect_ftp."""

    def test_tls_success(self):
        """When TLS succeeds, the FTP_TLS object should be returned."""
        from app.tasks.watch_folder_tasks import _connect_ftp

        mock_ftp_tls = MagicMock()
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("ftplib.FTP_TLS", return_value=mock_ftp_tls),
        ):
            mock_settings.ftp_host = "ftp.example.com"
            mock_settings.ftp_port = 21
            mock_settings.ftp_username = "user"
            mock_settings.ftp_password = "pass"  # noqa: S105
            mock_settings.ftp_use_tls = True
            mock_settings.ftp_allow_plaintext = False
            result = _connect_ftp()
        assert result is mock_ftp_tls

    def test_tls_fails_fallback_to_plaintext(self):
        """When TLS fails and allow_plaintext=True, should fall back to plain FTP."""
        from app.tasks.watch_folder_tasks import _connect_ftp

        mock_ftp_plain = MagicMock()
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("ftplib.FTP_TLS") as mock_ftps_cls,
            patch("ftplib.FTP", return_value=mock_ftp_plain),
        ):
            mock_settings.ftp_host = "ftp.example.com"
            mock_settings.ftp_port = 21
            mock_settings.ftp_username = "user"
            mock_settings.ftp_password = "pass"  # noqa: S105
            mock_settings.ftp_use_tls = True
            mock_settings.ftp_allow_plaintext = True
            mock_ftps_cls.return_value.connect.side_effect = ConnectionRefusedError("no tls")
            result = _connect_ftp()
        assert result is mock_ftp_plain

    def test_tls_fails_no_plaintext_returns_none(self):
        """When TLS fails and allow_plaintext=False, returns None."""
        from app.tasks.watch_folder_tasks import _connect_ftp

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("ftplib.FTP_TLS") as mock_ftps_cls,
        ):
            mock_settings.ftp_host = "ftp.example.com"
            mock_settings.ftp_port = 21
            mock_settings.ftp_username = "user"
            mock_settings.ftp_password = "pass"  # noqa: S105
            mock_settings.ftp_use_tls = True
            mock_settings.ftp_allow_plaintext = False
            mock_ftps_cls.return_value.connect.side_effect = ConnectionRefusedError("no tls")
            result = _connect_ftp()
        assert result is None

    def test_plain_ftp_success(self):
        """When use_tls=False, plain FTP is used directly."""
        from app.tasks.watch_folder_tasks import _connect_ftp

        mock_ftp = MagicMock()
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("ftplib.FTP", return_value=mock_ftp),
        ):
            mock_settings.ftp_host = "ftp.example.com"
            mock_settings.ftp_port = 21
            mock_settings.ftp_username = "user"
            mock_settings.ftp_password = "pass"  # noqa: S105
            mock_settings.ftp_use_tls = False
            mock_settings.ftp_allow_plaintext = True
            result = _connect_ftp()
        assert result is mock_ftp


@pytest.mark.unit
class TestScanFtpFolderEdgeCases:
    """Additional edge cases for _scan_ftp_folder."""

    def test_nlst_failure_returns_zero(self):
        """When NLST raises an exception, return 0."""
        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.return_value = None
        mock_ftp.nlst.side_effect = Exception("NLST failed")
        count = _scan_ftp_folder(mock_ftp, "/inbox", {}, False)
        assert count == 0

    def test_file_collision_generates_unique_name(self, tmp_path):
        """When dest path already exists, a timestamped path should be created."""
        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.return_value = None
        mock_ftp.nlst.return_value = ["doc.pdf"]
        # Pre-create the dest file
        (tmp_path / "ftp_doc.pdf").write_bytes(b"old")

        def fake_retrbinary(cmd, callback):
            callback(b"%PDF-1.4")

        mock_ftp.retrbinary.side_effect = fake_retrbinary

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_ftp_folder(mock_ftp, "/inbox", cache, False)
        assert count == 1

    def test_download_failure_skips_file(self, tmp_path):
        """When retrbinary raises, the file should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.return_value = None
        mock_ftp.nlst.return_value = ["broken.pdf"]
        mock_ftp.retrbinary.side_effect = Exception("transfer failed")

        cache: dict = {}
        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path)
            count = _scan_ftp_folder(mock_ftp, "/inbox", cache, False)
        assert count == 0
        assert "ftp:/inbox/broken.pdf" not in cache

    def test_delete_after_ftp_success(self, tmp_path):
        """When delete_after=True and delete succeeds, no exception should occur."""
        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.return_value = None
        mock_ftp.nlst.return_value = ["file.pdf"]

        def fake_retrbinary(cmd, callback):
            callback(b"%PDF-1.4")

        mock_ftp.retrbinary.side_effect = fake_retrbinary

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_ftp_folder(mock_ftp, "/inbox", cache, delete_after=True)

        assert count == 1
        mock_ftp.delete.assert_called_once_with("file.pdf")

    def test_delete_after_ftp_failure_does_not_crash(self, tmp_path):
        """When delete raises, it should log a warning but not crash."""
        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.return_value = None
        mock_ftp.nlst.return_value = ["file.pdf"]

        def fake_retrbinary(cmd, callback):
            callback(b"%PDF-1.4")

        mock_ftp.retrbinary.side_effect = fake_retrbinary
        mock_ftp.delete.side_effect = Exception("permission denied")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_ftp_folder(mock_ftp, "/inbox", cache, delete_after=True)
        assert count == 1


@pytest.mark.unit
class TestGetSftpConnection:
    """Tests for _get_sftp_connection."""

    def test_returns_none_when_host_missing(self):
        """Returns (None, None) when host is not configured."""
        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.sftp_host = None
            mock_settings.sftp_username = "user"
            from app.tasks.watch_folder_tasks import _get_sftp_connection

            ssh, sftp = _get_sftp_connection()
        assert ssh is None
        assert sftp is None

    def test_returns_none_when_no_auth(self):
        """Returns (None, None) when neither password nor key is set."""
        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.sftp_host = "sftp.example.com"
            mock_settings.sftp_port = 22
            mock_settings.sftp_username = "user"
            mock_settings.sftp_disable_host_key_verification = False
            mock_settings.sftp_private_key = None
            mock_settings.sftp_password = None

            import paramiko

            mock_ssh = MagicMock(spec=paramiko.SSHClient)

            with patch("paramiko.SSHClient", return_value=mock_ssh):
                from app.tasks.watch_folder_tasks import _get_sftp_connection

                ssh, sftp = _get_sftp_connection()
        assert ssh is None
        assert sftp is None

    def test_connects_with_password(self):
        """Returns (ssh, sftp) when password auth succeeds."""
        import paramiko

        mock_ssh = MagicMock(spec=paramiko.SSHClient)
        mock_sftp = MagicMock()
        mock_ssh.open_sftp.return_value = mock_sftp

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("paramiko.SSHClient", return_value=mock_ssh),
        ):
            mock_settings.sftp_host = "sftp.example.com"
            mock_settings.sftp_port = 22
            mock_settings.sftp_username = "user"
            mock_settings.sftp_disable_host_key_verification = False
            mock_settings.sftp_private_key = None
            mock_settings.sftp_password = "secret"  # noqa: S105
            mock_settings.sftp_private_key_passphrase = None

            from app.tasks.watch_folder_tasks import _get_sftp_connection

            ssh, sftp = _get_sftp_connection()

        assert ssh is mock_ssh
        assert sftp is mock_sftp

    def test_connects_with_key_file(self, tmp_path):
        """Returns (ssh, sftp) when key-based auth succeeds."""
        import paramiko

        key_file = tmp_path / "id_rsa"
        key_file.write_text("fake key")

        mock_ssh = MagicMock(spec=paramiko.SSHClient)
        mock_sftp = MagicMock()
        mock_ssh.open_sftp.return_value = mock_sftp

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("paramiko.SSHClient", return_value=mock_ssh),
        ):
            mock_settings.sftp_host = "sftp.example.com"
            mock_settings.sftp_port = 22
            mock_settings.sftp_username = "user"
            mock_settings.sftp_disable_host_key_verification = False
            mock_settings.sftp_private_key = str(key_file)
            mock_settings.sftp_private_key_passphrase = "mypassphrase"
            mock_settings.sftp_password = None

            from app.tasks.watch_folder_tasks import _get_sftp_connection

            ssh, sftp = _get_sftp_connection()

        assert ssh is mock_ssh
        assert sftp is mock_sftp

    def test_connection_failure_returns_none(self):
        """Returns (None, None) when connect() raises."""
        import paramiko

        mock_ssh = MagicMock(spec=paramiko.SSHClient)
        mock_ssh.connect.side_effect = Exception("refused")

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("paramiko.SSHClient", return_value=mock_ssh),
        ):
            mock_settings.sftp_host = "sftp.example.com"
            mock_settings.sftp_port = 22
            mock_settings.sftp_username = "user"
            mock_settings.sftp_disable_host_key_verification = False
            mock_settings.sftp_private_key = None
            mock_settings.sftp_password = "secret"  # noqa: S105
            mock_settings.sftp_private_key_passphrase = None

            from app.tasks.watch_folder_tasks import _get_sftp_connection

            ssh, sftp = _get_sftp_connection()

        assert ssh is None
        assert sftp is None

    def test_disable_host_key_verification_path(self):
        """When sftp_disable_host_key_verification=True, AutoAddPolicy is set."""
        import paramiko

        mock_ssh = MagicMock(spec=paramiko.SSHClient)
        mock_sftp = MagicMock()
        mock_ssh.open_sftp.return_value = mock_sftp

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("paramiko.SSHClient", return_value=mock_ssh),
            patch("paramiko.AutoAddPolicy"),
        ):
            mock_settings.sftp_host = "sftp.example.com"
            mock_settings.sftp_port = 22
            mock_settings.sftp_username = "user"
            mock_settings.sftp_disable_host_key_verification = True
            mock_settings.sftp_private_key = None
            mock_settings.sftp_password = "secret"  # noqa: S105
            mock_settings.sftp_private_key_passphrase = None

            from app.tasks.watch_folder_tasks import _get_sftp_connection

            _get_sftp_connection()

        mock_ssh.set_missing_host_key_policy.assert_called()

    def test_connection_failure_close_raises(self):
        """When connect() fails and ssh.close() also raises, returns (None, None)."""
        import paramiko

        mock_ssh = MagicMock(spec=paramiko.SSHClient)
        mock_ssh.connect.side_effect = Exception("refused")
        mock_ssh.close.side_effect = Exception("close also failed")

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("paramiko.SSHClient", return_value=mock_ssh),
        ):
            mock_settings.sftp_host = "sftp.example.com"
            mock_settings.sftp_port = 22
            mock_settings.sftp_username = "user"
            mock_settings.sftp_disable_host_key_verification = False
            mock_settings.sftp_private_key = None
            mock_settings.sftp_password = "secret"  # noqa: S105
            mock_settings.sftp_private_key_passphrase = None

            from app.tasks.watch_folder_tasks import _get_sftp_connection

            ssh, sftp = _get_sftp_connection()

        assert ssh is None
        assert sftp is None


@pytest.mark.unit
class TestScanSftpFolderEdgeCases:
    """Additional edge cases for _scan_sftp_folder."""

    def test_listdir_failure_returns_zero(self):
        """When listdir_attr raises, return 0."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        mock_sftp.listdir_attr.side_effect = Exception("permission denied")
        count = _scan_sftp_folder(mock_sftp, "/upload", {}, False)
        assert count == 0

    def test_directory_entry_is_skipped(self, tmp_path):
        """Directory entries should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        dir_attr = MagicMock()
        dir_attr.st_mode = stat.S_IFDIR | 0o755
        dir_attr.filename = "subdir"
        mock_sftp.listdir_attr.return_value = [dir_attr]

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path)
            count = _scan_sftp_folder(mock_sftp, "/upload", {}, False)
        assert count == 0

    def test_download_new_file(self, tmp_path):
        """A new allowed file should be downloaded and enqueued."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        file_attr = MagicMock()
        file_attr.st_mode = stat.S_IFREG | 0o644
        file_attr.filename = "scan.pdf"
        mock_sftp.listdir_attr.return_value = [file_attr]

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)

        assert count == 1
        assert "sftp:/upload/scan.pdf" in cache

    def test_file_collision_generates_unique_name(self, tmp_path):
        """When dest file already exists, a unique timestamped name is used."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        file_attr = MagicMock()
        file_attr.st_mode = stat.S_IFREG | 0o644
        file_attr.filename = "doc.pdf"
        mock_sftp.listdir_attr.return_value = [file_attr]
        # Pre-create the collision file
        (tmp_path / "sftp_doc.pdf").write_bytes(b"old")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)
        assert count == 1

    def test_download_failure_skips_file(self, tmp_path):
        """When sftp.get raises, the file is skipped."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        file_attr = MagicMock()
        file_attr.st_mode = stat.S_IFREG | 0o644
        file_attr.filename = "broken.pdf"
        mock_sftp.listdir_attr.return_value = [file_attr]
        mock_sftp.get.side_effect = Exception("IO error")

        cache: dict = {}
        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path)
            count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)
        assert count == 0
        assert "sftp:/upload/broken.pdf" not in cache

    def test_delete_after_success(self, tmp_path):
        """delete_after=True should call sftp.remove."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        file_attr = MagicMock()
        file_attr.st_mode = stat.S_IFREG | 0o644
        file_attr.filename = "file.pdf"
        mock_sftp.listdir_attr.return_value = [file_attr]

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            _scan_sftp_folder(mock_sftp, "/upload", cache, delete_after=True)

        mock_sftp.remove.assert_called_once_with("/upload/file.pdf")

    def test_delete_after_failure_does_not_crash(self, tmp_path):
        """delete_after failure should be logged but not crash."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        file_attr = MagicMock()
        file_attr.st_mode = stat.S_IFREG | 0o644
        file_attr.filename = "file.pdf"
        mock_sftp.listdir_attr.return_value = [file_attr]
        mock_sftp.remove.side_effect = Exception("permission denied")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_sftp_folder(mock_sftp, "/upload", cache, delete_after=True)
        assert count == 1

    def test_already_cached_sftp_file_skipped(self, tmp_path):
        """Files already in cache should not be re-downloaded."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        file_attr = MagicMock()
        file_attr.st_mode = stat.S_IFREG | 0o644
        file_attr.filename = "cached.pdf"
        mock_sftp.listdir_attr.return_value = [file_attr]

        cache = {"sftp:/upload/cached.pdf": datetime.now(timezone.utc).isoformat()}
        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path)
            count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)
        assert count == 0

    def test_unsupported_type_sftp_skipped(self, tmp_path):
        """Non-allowed file types should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        file_attr = MagicMock()
        file_attr.st_mode = stat.S_IFREG | 0o644
        file_attr.filename = "script.sh"
        mock_sftp.listdir_attr.return_value = [file_attr]

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path)
            count = _scan_sftp_folder(mock_sftp, "/upload", {}, False)
        assert count == 0


@pytest.mark.unit
class TestScanDropboxFolder:
    """Tests for _scan_dropbox_folder."""

    def test_auth_failure_returns_zero(self):
        """If get_dropbox_client raises, return 0."""
        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        with patch("app.tasks.upload_to_dropbox.get_dropbox_client", side_effect=Exception("auth failed")):
            result = _scan_dropbox_folder("/Inbox", {}, False)
        assert result == 0

    def test_list_folder_failure_returns_zero(self):
        """If files_list_folder raises, return 0."""
        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        mock_dbx.files_list_folder.side_effect = Exception("network error")

        with patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx):
            result = _scan_dropbox_folder("/Inbox", {}, False)
        assert result == 0

    def test_downloads_new_file(self, tmp_path):
        """New allowed files should be downloaded and enqueued."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
        file_entry.name = "receipt.pdf"
        file_entry.id = "id:abc123"
        file_entry.path_lower = "/inbox/receipt.pdf"

        mock_result = MagicMock()
        mock_result.entries = [file_entry]
        mock_result.has_more = False
        mock_dbx.files_list_folder.return_value = mock_result

        mock_response = MagicMock()
        mock_response.content = b"%PDF-1.4"
        mock_dbx.files_download.return_value = (MagicMock(), mock_response)

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_dropbox_folder("/Inbox", cache, False)

        assert count == 1
        assert "dropbox:id:abc123" in cache

    def test_skips_non_file_metadata(self, tmp_path):
        """Non-FileMetadata entries (folders) should be skipped."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        folder_entry = MagicMock(spec=dropbox_module.files.FolderMetadata)
        folder_entry.name = "subfolder"

        mock_result = MagicMock()
        mock_result.entries = [folder_entry]
        mock_result.has_more = False
        mock_dbx.files_list_folder.return_value = mock_result

        cache: dict = {}
        with patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx):
            count = _scan_dropbox_folder("/Inbox", cache, False)
        assert count == 0

    def test_skips_already_cached_file(self, tmp_path):
        """Files already in cache should not be re-downloaded."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
        file_entry.name = "cached.pdf"
        file_entry.id = "id:cached"
        file_entry.path_lower = "/inbox/cached.pdf"

        mock_result = MagicMock()
        mock_result.entries = [file_entry]
        mock_result.has_more = False
        mock_dbx.files_list_folder.return_value = mock_result

        cache = {"dropbox:id:cached": datetime.now(timezone.utc).isoformat()}
        with patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx):
            count = _scan_dropbox_folder("/Inbox", cache, False)
        assert count == 0

    def test_skips_unsupported_file_type(self, tmp_path):
        """Non-allowed file types should be skipped."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
        file_entry.name = "script.exe"
        file_entry.id = "id:exe1"
        file_entry.path_lower = "/inbox/script.exe"

        mock_result = MagicMock()
        mock_result.entries = [file_entry]
        mock_result.has_more = False
        mock_dbx.files_list_folder.return_value = mock_result

        with patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx):
            count = _scan_dropbox_folder("/Inbox", {}, False)
        assert count == 0

    def test_download_failure_skips_file(self, tmp_path):
        """If files_download raises, the file should be skipped."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
        file_entry.name = "fail.pdf"
        file_entry.id = "id:fail1"
        file_entry.path_lower = "/inbox/fail.pdf"

        mock_result = MagicMock()
        mock_result.entries = [file_entry]
        mock_result.has_more = False
        mock_dbx.files_list_folder.return_value = mock_result
        mock_dbx.files_download.side_effect = Exception("download failed")

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_dropbox_folder("/Inbox", cache, False)
        assert count == 0
        assert "dropbox:id:fail1" not in cache

    def test_delete_after_success(self, tmp_path):
        """delete_after=True should call files_delete_v2."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
        file_entry.name = "invoice.pdf"
        file_entry.id = "id:inv1"
        file_entry.path_lower = "/inbox/invoice.pdf"

        mock_result = MagicMock()
        mock_result.entries = [file_entry]
        mock_result.has_more = False
        mock_dbx.files_list_folder.return_value = mock_result

        mock_response = MagicMock()
        mock_response.content = b"%PDF-1.4"
        mock_dbx.files_download.return_value = (MagicMock(), mock_response)

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            _scan_dropbox_folder("/Inbox", cache, delete_after=True)

        mock_dbx.files_delete_v2.assert_called_once_with("/inbox/invoice.pdf")

    def test_delete_after_failure_does_not_crash(self, tmp_path):
        """delete_after failure should log warning but not crash."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
        file_entry.name = "doc.pdf"
        file_entry.id = "id:doc1"
        file_entry.path_lower = "/inbox/doc.pdf"

        mock_result = MagicMock()
        mock_result.entries = [file_entry]
        mock_result.has_more = False
        mock_dbx.files_list_folder.return_value = mock_result
        mock_dbx.files_delete_v2.side_effect = Exception("permission denied")

        mock_response = MagicMock()
        mock_response.content = b"%PDF-1.4"
        mock_dbx.files_download.return_value = (MagicMock(), mock_response)

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_dropbox_folder("/Inbox", cache, delete_after=True)
        assert count == 1

    def test_pagination(self, tmp_path):
        """When has_more=True, should paginate to get all files."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()

        file1 = MagicMock(spec=dropbox_module.files.FileMetadata)
        file1.name = "page1.pdf"
        file1.id = "id:page1"
        file1.path_lower = "/inbox/page1.pdf"

        file2 = MagicMock(spec=dropbox_module.files.FileMetadata)
        file2.name = "page2.pdf"
        file2.id = "id:page2"
        file2.path_lower = "/inbox/page2.pdf"

        result1 = MagicMock()
        result1.entries = [file1]
        result1.has_more = True
        result1.cursor = "cursor1"

        result2 = MagicMock()
        result2.entries = [file2]
        result2.has_more = False

        mock_dbx.files_list_folder.return_value = result1
        mock_dbx.files_list_folder_continue.return_value = result2

        mock_response = MagicMock()
        mock_response.content = b"%PDF-1.4"
        mock_dbx.files_download.return_value = (MagicMock(), mock_response)

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_dropbox_folder("/Inbox", cache, False)

        assert count == 2

    def test_file_collision_generates_unique_name(self, tmp_path):
        """When dest file already exists, a timestamped path is used."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
        file_entry.name = "collision.pdf"
        file_entry.id = "id:coll1"
        file_entry.path_lower = "/inbox/collision.pdf"

        mock_result = MagicMock()
        mock_result.entries = [file_entry]
        mock_result.has_more = False
        mock_dbx.files_list_folder.return_value = mock_result

        mock_response = MagicMock()
        mock_response.content = b"%PDF-1.4"
        mock_dbx.files_download.return_value = (MagicMock(), mock_response)

        (tmp_path / "dropbox_collision.pdf").write_bytes(b"old")

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_dropbox_folder("/Inbox", cache, False)
        assert count == 1


@pytest.mark.unit
class TestScanGoogleDriveFolder:
    """Tests for _scan_google_drive_folder."""

    def test_service_none_returns_zero(self):
        """If get_google_drive_service returns None, return 0."""
        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        with patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=None):
            result = _scan_google_drive_folder("folder123", {}, False)
        assert result == 0

    def test_listing_failure_breaks_loop(self):
        """If the API listing call raises, break and return current count (0)."""
        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.side_effect = Exception("API error")

        with patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service):
            result = _scan_google_drive_folder("folder123", {}, False)
        assert result == 0

    def test_downloads_new_file(self, tmp_path):
        """New allowed files should be downloaded and enqueued."""
        import io

        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "gid1", "name": "invoice.pdf", "mimeType": "application/pdf"}],
        }

        mock_downloader = MagicMock()
        mock_downloader.next_chunk.return_value = (None, True)

        with (
            patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
            patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
            patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_google_drive_folder("folder123", {}, False)

        assert count == 1

    def test_skips_unsupported_type(self, tmp_path):
        """Non-allowed file types should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "gid2", "name": "script.exe", "mimeType": "application/octet-stream"}],
        }

        with (
            patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_google_drive_folder("folder123", {}, False)
        assert count == 0

    def test_skips_already_cached(self, tmp_path):
        """Already cached files should not be re-downloaded."""
        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "gid3", "name": "cached.pdf", "mimeType": "application/pdf"}],
        }

        cache = {"gdrive:gid3": datetime.now(timezone.utc).isoformat()}
        with (
            patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_google_drive_folder("folder123", cache, False)
        assert count == 0

    def test_download_failure_skips_file(self, tmp_path):
        """Download failure should skip the file."""
        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "gid4", "name": "fail.pdf", "mimeType": "application/pdf"}],
        }
        mock_service.files.return_value.get_media.side_effect = Exception("download error")

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_google_drive_folder("folder123", cache, False)
        assert count == 0

    def test_delete_after_success(self, tmp_path):
        """delete_after=True should call service.files().delete()."""
        import io

        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "gid5", "name": "todelete.pdf", "mimeType": "application/pdf"}],
        }

        mock_downloader = MagicMock()
        mock_downloader.next_chunk.return_value = (None, True)

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
            patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
            patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
        ):
            mock_settings.workdir = str(tmp_path)
            _scan_google_drive_folder("folder123", cache, delete_after=True)

        mock_service.files.return_value.delete.assert_called()

    def test_pagination(self, tmp_path):
        """Should follow nextPageToken for pagination."""
        import io

        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.side_effect = [
            {
                "files": [{"id": "gid10", "name": "page1.pdf", "mimeType": "application/pdf"}],
                "nextPageToken": "tok1",
            },
            {
                "files": [{"id": "gid11", "name": "page2.pdf", "mimeType": "application/pdf"}],
            },
        ]

        mock_downloader = MagicMock()
        mock_downloader.next_chunk.return_value = (None, True)

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
            patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
            patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_google_drive_folder("folder123", cache, False)

        assert count == 2

    def test_delete_after_failure_does_not_crash(self, tmp_path):
        """delete_after failure is logged but does not crash."""
        import io

        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "gid6", "name": "del.pdf", "mimeType": "application/pdf"}],
        }
        mock_service.files.return_value.delete.return_value.execute.side_effect = Exception("forbidden")

        mock_downloader = MagicMock()
        mock_downloader.next_chunk.return_value = (None, True)

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
            patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
            patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_google_drive_folder("folder123", cache, delete_after=True)
        assert count == 1

    def test_file_collision_generates_unique_name(self, tmp_path):
        """When dest file already exists, a timestamped path is used."""
        import io

        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "gid7", "name": "coll.pdf", "mimeType": "application/pdf"}],
        }

        (tmp_path / "gdrive_coll.pdf").write_bytes(b"old")

        mock_downloader = MagicMock()
        mock_downloader.next_chunk.return_value = (None, True)

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
            patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
            patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_google_drive_folder("folder123", cache, False)
        assert count == 1


@pytest.mark.unit
class TestScanOneDriveFolder:
    """Tests for _scan_onedrive_folder."""

    def test_auth_failure_returns_zero(self):
        """If get_onedrive_token raises, return 0."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        with patch("app.tasks.upload_to_onedrive.get_onedrive_token", side_effect=Exception("auth failed")):
            result = _scan_onedrive_folder("/Inbox", {}, False)
        assert result == 0

    def test_listing_failure_breaks_loop(self, tmp_path):
        """When the listing request raises, break and return 0."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", side_effect=Exception("network error")),
        ):
            mock_settings.http_request_timeout = 30
            result = _scan_onedrive_folder("/Inbox", {}, False)
        assert result == 0

    def test_downloads_new_file(self, tmp_path):
        """New allowed files should be downloaded and enqueued."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {
            "value": [
                {
                    "name": "report.pdf",
                    "id": "item1",
                    "@microsoft.graph.downloadUrl": "https://example.com/dl/report.pdf",
                }
            ]
        }
        list_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", side_effect=[list_resp, dl_resp]),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, False)

        assert count == 1
        assert "onedrive:item1" in cache

    def test_skips_folder_items(self, tmp_path):
        """Items with 'folder' key should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {
            "value": [
                {"name": "Subfolder", "id": "folder1", "folder": {"childCount": 2}},
            ]
        }
        list_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", return_value=list_resp),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, False)
        assert count == 0

    def test_skips_unsupported_type(self, tmp_path):
        """Non-allowed types are skipped."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {
            "value": [{"name": "script.exe", "id": "exe1", "@microsoft.graph.downloadUrl": "https://x.com/exe"}]
        }
        list_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", return_value=list_resp),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, False)
        assert count == 0

    def test_skips_already_cached(self, tmp_path):
        """Already cached files are not re-downloaded."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {
            "value": [
                {
                    "name": "cached.pdf",
                    "id": "cacheditem",
                    "@microsoft.graph.downloadUrl": "https://x.com/cached",
                }
            ]
        }
        list_resp.raise_for_status.return_value = None

        cache = {"onedrive:cacheditem": datetime.now(timezone.utc).isoformat()}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", return_value=list_resp),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, False)
        assert count == 0

    def test_skips_no_download_url(self, tmp_path):
        """Items without @microsoft.graph.downloadUrl are skipped."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {"value": [{"name": "nurl.pdf", "id": "nurl1"}]}
        list_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", return_value=list_resp),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, False)
        assert count == 0

    def test_download_failure_skips_file(self, tmp_path):
        """Download failure skips the file."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {
            "value": [
                {
                    "name": "fail.pdf",
                    "id": "fail1",
                    "@microsoft.graph.downloadUrl": "https://x.com/fail",
                }
            ]
        }
        list_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.raise_for_status.side_effect = Exception("dl failed")

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", side_effect=[list_resp, dl_resp]),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, False)
        assert count == 0
        assert "onedrive:fail1" not in cache

    def test_delete_after_success(self, tmp_path):
        """delete_after=True should call DELETE on the item."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {
            "value": [
                {
                    "name": "del.pdf",
                    "id": "del1",
                    "@microsoft.graph.downloadUrl": "https://x.com/del",
                }
            ]
        }
        list_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        del_resp = MagicMock()
        del_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", side_effect=[list_resp, dl_resp]),
            patch("requests.delete", return_value=del_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, delete_after=True)

        assert count == 1

    def test_delete_after_failure_does_not_crash(self, tmp_path):
        """delete_after failure is logged but doesn't crash."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {
            "value": [
                {
                    "name": "del2.pdf",
                    "id": "del2",
                    "@microsoft.graph.downloadUrl": "https://x.com/del2",
                }
            ]
        }
        list_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        del_resp = MagicMock()
        del_resp.raise_for_status.side_effect = Exception("forbidden")

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", side_effect=[list_resp, dl_resp]),
            patch("requests.delete", return_value=del_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, delete_after=True)
        assert count == 1

    def test_pagination(self, tmp_path):
        """Should follow @odata.nextLink for pagination."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        page1_resp = MagicMock()
        page1_resp.json.return_value = {
            "value": [
                {
                    "name": "p1.pdf",
                    "id": "p1",
                    "@microsoft.graph.downloadUrl": "https://x.com/p1",
                }
            ],
            "@odata.nextLink": "https://graph.microsoft.com/v1.0/next",
        }
        page1_resp.raise_for_status.return_value = None

        page2_resp = MagicMock()
        page2_resp.json.return_value = {
            "value": [
                {
                    "name": "p2.pdf",
                    "id": "p2",
                    "@microsoft.graph.downloadUrl": "https://x.com/p2",
                }
            ]
        }
        page2_resp.raise_for_status.return_value = None

        dl1 = MagicMock()
        dl1.content = b"%PDF-1.4"
        dl1.raise_for_status.return_value = None

        dl2 = MagicMock()
        dl2.content = b"%PDF-1.4"
        dl2.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", side_effect=[page1_resp, dl1, page2_resp, dl2]),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, False)

        assert count == 2

    def test_file_collision_generates_unique_name(self, tmp_path):
        """When dest file already exists, a timestamped path is used."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {
            "value": [
                {
                    "name": "coll.pdf",
                    "id": "coll1",
                    "@microsoft.graph.downloadUrl": "https://x.com/coll",
                }
            ]
        }
        list_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        (tmp_path / "onedrive_coll.pdf").write_bytes(b"old")

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", side_effect=[list_resp, dl_resp]),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, False)
        assert count == 1


@pytest.mark.unit
class TestScanNextcloudFolder:
    """Tests for _scan_nextcloud_folder."""

    def test_incomplete_settings_returns_zero(self):
        """Returns 0 if Nextcloud connection settings are missing."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.nextcloud_upload_url = None
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            result = _scan_nextcloud_folder("/Scans", {}, False)
        assert result == 0

    def test_propfind_failure_returns_zero(self):
        """Returns 0 if PROPFIND request fails."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", side_effect=Exception("connection refused")),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            result = _scan_nextcloud_folder("/Scans", {}, False)
        assert result == 0

    def test_xml_parse_failure_returns_zero(self):
        """Returns 0 if XML parsing fails."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        mock_resp = MagicMock()
        mock_resp.text = "invalid xml <<<"
        mock_resp.raise_for_status.return_value = None

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=mock_resp),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            result = _scan_nextcloud_folder("/Scans", {}, False)
        assert result == 0

    def _build_propfind_xml(self, hrefs):
        responses = "".join(
            f"<d:response><d:href>{href}</d:href><d:propstat></d:propstat></d:response>" for href in hrefs
        )
        return f'<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">{responses}</d:multistatus>'

    def test_downloads_new_file(self, tmp_path):
        """New allowed files should be downloaded and enqueued."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = self._build_propfind_xml(["/remote.php/webdav/Scans/", "/remote.php/webdav/Scans/invoice.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", cache, False)

        assert count == 1

    def test_skips_already_cached(self, tmp_path):
        """Cached files should not be re-downloaded."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = self._build_propfind_xml(["/remote.php/webdav/Scans/cached.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        cache = {"nextcloud:/remote.php/webdav/Scans/cached.pdf": datetime.now(timezone.utc).isoformat()}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", cache, False)
        assert count == 0

    def test_skips_unsupported_type(self, tmp_path):
        """Non-allowed types should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = self._build_propfind_xml(["/remote.php/webdav/Scans/script.exe"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", {}, False)
        assert count == 0

    def test_download_failure_skips_file(self, tmp_path):
        """Download failure skips the file."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = self._build_propfind_xml(["/remote.php/webdav/Scans/fail.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.raise_for_status.side_effect = Exception("download failed")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", cache, False)
        assert count == 0

    def test_delete_after_success(self, tmp_path):
        """delete_after=True should call DELETE on the file URL."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = self._build_propfind_xml(["/remote.php/webdav/Scans/del.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        del_resp = MagicMock()
        del_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", side_effect=[propfind_resp, del_resp]),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", cache, delete_after=True)
        assert count == 1

    def test_delete_after_failure_does_not_crash(self, tmp_path):
        """delete_after failure is logged but doesn't crash."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = self._build_propfind_xml(["/remote.php/webdav/Scans/del2.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        del_resp = MagicMock()
        del_resp.raise_for_status.side_effect = Exception("forbidden")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", side_effect=[propfind_resp, del_resp]),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", cache, delete_after=True)
        assert count == 1

    def test_file_collision_generates_unique_name(self, tmp_path):
        """When dest file already exists, a timestamped path is used."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = self._build_propfind_xml(["/remote.php/webdav/Scans/coll.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        (tmp_path / "nc_coll.pdf").write_bytes(b"old")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", cache, False)
        assert count == 1

    def test_absolute_href_used_directly(self, tmp_path):
        """When href is already an absolute URL, it should be used as-is."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = self._build_propfind_xml(["https://cloud.example.com/remote.php/webdav/Scans/abs.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
            mock_settings.nextcloud_username = "user"
            mock_settings.nextcloud_password = "pass"  # noqa: S105
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", cache, False)
        assert count == 1


@pytest.mark.unit
class TestScanS3Prefix:
    """Tests for _scan_s3_prefix."""

    def test_missing_bucket_returns_zero(self):
        """Returns 0 when S3_BUCKET_NAME is not configured."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.s3_bucket_name = None
            result = _scan_s3_prefix("inbox/", {}, False)
        assert result == 0

    def test_downloads_new_object(self, tmp_path):
        """New allowed S3 objects should be downloaded and enqueued."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/invoice.pdf"}]}]

        cache: dict = {}
        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            count = _scan_s3_prefix("inbox/", cache, False)

        assert count == 1
        assert "s3:mybucket/inbox/invoice.pdf" in cache

    def test_skips_empty_key(self, tmp_path):
        """Objects with empty filename (folder markers) should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/"}]}]

        cache: dict = {}
        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            count = _scan_s3_prefix("inbox/", cache, False)
        assert count == 0

    def test_skips_unsupported_type(self, tmp_path):
        """Non-allowed types should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/script.exe"}]}]

        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            count = _scan_s3_prefix("inbox/", {}, False)
        assert count == 0

    def test_skips_already_cached(self, tmp_path):
        """Already cached objects should not be re-downloaded."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/cached.pdf"}]}]

        cache = {"s3:mybucket/inbox/cached.pdf": datetime.now(timezone.utc).isoformat()}
        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            count = _scan_s3_prefix("inbox/", cache, False)
        assert count == 0

    def test_download_failure_skips_file(self, tmp_path):
        """ClientError during download should skip the file."""
        from botocore.exceptions import ClientError

        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/fail.pdf"}]}]
        mock_s3.download_file.side_effect = ClientError(
            {"Error": {"Code": "NoSuchKey", "Message": "not found"}}, "GetObject"
        )

        cache: dict = {}
        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            count = _scan_s3_prefix("inbox/", cache, False)
        assert count == 0
        assert "s3:mybucket/inbox/fail.pdf" not in cache

    def test_delete_after_success(self, tmp_path):
        """delete_after=True should call s3.delete_object."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/del.pdf"}]}]

        cache: dict = {}
        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            count = _scan_s3_prefix("inbox/", cache, delete_after=True)

        assert count == 1
        mock_s3.delete_object.assert_called_once_with(Bucket="mybucket", Key="inbox/del.pdf")

    def test_delete_after_failure_does_not_crash(self, tmp_path):
        """delete_after failure is logged but doesn't crash."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/del2.pdf"}]}]
        mock_s3.delete_object.side_effect = Exception("access denied")

        cache: dict = {}
        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            count = _scan_s3_prefix("inbox/", cache, delete_after=True)
        assert count == 1

    def test_file_collision_generates_unique_name(self, tmp_path):
        """When dest file already exists, a timestamped path is used."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/coll.pdf"}]}]

        (tmp_path / "s3_coll.pdf").write_bytes(b"old")

        cache: dict = {}
        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            count = _scan_s3_prefix("inbox/", cache, False)
        assert count == 1


@pytest.mark.unit
class TestScanWebdavFolder:
    """Tests for _scan_webdav_folder."""

    def test_missing_url_returns_zero(self):
        """Returns 0 when WEBDAV_URL is not configured."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.webdav_url = None
            mock_settings.webdav_username = None
            mock_settings.webdav_password = None  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            result = _scan_webdav_folder("/Inbox", {}, False)
        assert result == 0

    def test_propfind_failure_returns_zero(self):
        """Returns 0 if PROPFIND fails."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", side_effect=Exception("connection refused")),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            result = _scan_webdav_folder("/Inbox", {}, False)
        assert result == 0

    def test_xml_parse_failure_returns_zero(self):
        """Returns 0 if XML parsing fails."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        mock_resp = MagicMock()
        mock_resp.text = "invalid <<< xml"
        mock_resp.raise_for_status.return_value = None

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=mock_resp),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            result = _scan_webdav_folder("/Inbox", {}, False)
        assert result == 0

    def _build_webdav_xml(self, hrefs):
        responses = "".join(f"<d:response><d:href>{href}</d:href></d:response>" for href in hrefs)
        return f'<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">{responses}</d:multistatus>'

    def test_skips_directory_entries(self, tmp_path):
        """Entries ending with '/' should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["/dav/Inbox/"])
        mock_resp = MagicMock()
        mock_resp.text = xml
        mock_resp.raise_for_status.return_value = None

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=mock_resp),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", {}, False)
        assert count == 0

    def test_downloads_new_file(self, tmp_path):
        """New allowed files should be downloaded and enqueued."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["/dav/Inbox/invoice.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", cache, False)

        assert count == 1
        assert "webdav:/dav/Inbox/invoice.pdf" in cache

    def test_skips_already_cached(self, tmp_path):
        """Cached files should not be re-downloaded."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["/dav/Inbox/cached.pdf"])
        mock_resp = MagicMock()
        mock_resp.text = xml
        mock_resp.raise_for_status.return_value = None

        cache = {"webdav:/dav/Inbox/cached.pdf": datetime.now(timezone.utc).isoformat()}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=mock_resp),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", cache, False)
        assert count == 0

    def test_skips_unsupported_type(self, tmp_path):
        """Non-allowed types should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["/dav/Inbox/script.exe"])
        mock_resp = MagicMock()
        mock_resp.text = xml
        mock_resp.raise_for_status.return_value = None

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=mock_resp),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", {}, False)
        assert count == 0

    def test_download_failure_skips_file(self, tmp_path):
        """Download failure skips the file."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["/dav/Inbox/fail.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.raise_for_status.side_effect = Exception("dl failed")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", cache, False)
        assert count == 0

    def test_delete_after_success(self, tmp_path):
        """delete_after=True should issue a DELETE request."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["/dav/Inbox/del.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        del_resp = MagicMock()
        del_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", side_effect=[propfind_resp, del_resp]),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", cache, delete_after=True)
        assert count == 1

    def test_delete_after_failure_does_not_crash(self, tmp_path):
        """delete_after failure is logged but doesn't crash."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["/dav/Inbox/del2.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        del_resp = MagicMock()
        del_resp.raise_for_status.side_effect = Exception("forbidden")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", side_effect=[propfind_resp, del_resp]),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", cache, delete_after=True)
        assert count == 1

    def test_file_collision_generates_unique_name(self, tmp_path):
        """When dest file already exists, a timestamped path is used."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["/dav/Inbox/coll.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        (tmp_path / "webdav_coll.pdf").write_bytes(b"old")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", cache, False)
        assert count == 1

    def test_absolute_href_used_directly(self, tmp_path):
        """When href is already an absolute URL, it should be used as-is."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["https://dav.example.com/dav/Inbox/abs.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = "user"
            mock_settings.webdav_password = "pass"  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", cache, False)
        assert count == 1

    def test_no_auth_uses_none(self, tmp_path):
        """When webdav_username is None, auth=None should be used."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = self._build_webdav_xml(["/dav/Inbox/noauth.pdf"])
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.content = b"%PDF-1.4"
        dl_resp.raise_for_status.return_value = None

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
            patch("app.tasks.watch_folder_tasks.process_document"),
        ):
            mock_settings.webdav_url = "https://dav.example.com"
            mock_settings.webdav_username = None
            mock_settings.webdav_password = None  # noqa: S105
            mock_settings.webdav_verify_ssl = True
            mock_settings.http_request_timeout = 30
            mock_settings.workdir = str(tmp_path)
            count = _scan_webdav_folder("/Inbox", cache, False)
        assert count == 1


@pytest.mark.unit
class TestScanFtpWatchFolderQuitError:
    """Test that FTP quit errors during scan are silently handled."""

    def test_ftp_quit_error_does_not_crash(self):
        """If ftp.quit() raises, it should be logged but not crash."""
        from app.tasks.watch_folder_tasks import scan_ftp_watch_folder

        mock_ftp = MagicMock()
        mock_ftp.quit.side_effect = Exception("quit error")

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._connect_ftp", return_value=mock_ftp),
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_ftp_folder", return_value=0),
        ):
            mock_settings.ftp_ingest_enabled = True
            mock_settings.ftp_ingest_folder = "/inbox"
            mock_settings.ftp_ingest_delete_after_process = False
            result = scan_ftp_watch_folder()

        assert result["status"] == "ok"


@pytest.mark.unit
class TestScanSftpWatchFolderCloseErrors:
    """Test that SFTP close errors during scan are silently handled."""

    def test_sftp_close_error_does_not_crash(self):
        """If sftp.close() raises, it should be logged but not crash."""
        from app.tasks.watch_folder_tasks import scan_sftp_watch_folder

        mock_ssh = MagicMock()
        mock_sftp = MagicMock()
        mock_sftp.close.side_effect = Exception("sftp close error")
        mock_ssh.close.side_effect = Exception("ssh close error")

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._get_sftp_connection", return_value=(mock_ssh, mock_sftp)),
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_sftp_folder", return_value=0),
        ):
            mock_settings.sftp_ingest_enabled = True
            mock_settings.sftp_ingest_folder = "/upload"
            mock_settings.sftp_ingest_delete_after_process = False
            result = scan_sftp_watch_folder()

        assert result["status"] == "ok"


@pytest.mark.unit
class TestScanLocalWatchFoldersMultipleFolders:
    """Test scan_local_watch_folders with multiple comma-separated folders."""

    def test_multiple_folders_scanned(self, tmp_path):
        """All folders in comma-separated WATCH_FOLDERS should be scanned."""
        from app.tasks.watch_folder_tasks import scan_local_watch_folders

        folder1 = str(tmp_path / "folder1")
        folder2 = str(tmp_path / "folder2")

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
            patch("app.tasks.watch_folder_tasks._save_cache"),
            patch("app.tasks.watch_folder_tasks._scan_local_folder", return_value=1) as mock_scan,
        ):
            mock_settings.watch_folders = f"{folder1},{folder2}"
            mock_settings.watch_folder_delete_after_process = False
            result = scan_local_watch_folders()

        assert result["status"] == "ok"
        assert result["folders_scanned"] == 2
        assert result["files_enqueued"] == 2
        assert mock_scan.call_count == 2

    def test_whitespace_only_entries_are_ignored(self):
        """Entries that are whitespace-only should be filtered out."""
        from app.tasks.watch_folder_tasks import scan_local_watch_folders

        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.watch_folders = "  ,  ,  "
            result = scan_local_watch_folders()

        assert result["status"] == "skipped"
        assert result["reason"] == "WATCH_FOLDERS is empty"


@pytest.mark.unit
class TestPartialFileCleanup:
    """Tests for partial file cleanup on download failures."""

    def test_ftp_download_failure_removes_partial_file(self, tmp_path):
        """When FTP download fails and partial file exists, it should be removed."""
        from app.tasks.watch_folder_tasks import _scan_ftp_folder

        mock_ftp = MagicMock()
        mock_ftp.cwd.return_value = None
        mock_ftp.nlst.return_value = ["partial.pdf"]

        # retrbinary creates a partial file then raises
        dest = tmp_path / "ftp_partial.pdf"

        def fake_retrbinary(cmd, callback):
            dest.write_bytes(b"%PDF partial")
            raise Exception("transfer interrupted")

        mock_ftp.retrbinary.side_effect = fake_retrbinary

        cache: dict = {}
        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path)
            count = _scan_ftp_folder(mock_ftp, "/inbox", cache, False)

        assert count == 0
        assert not dest.exists()

    def test_sftp_download_failure_removes_partial_file(self, tmp_path):
        """When SFTP get fails and partial file exists, it should be removed."""
        from app.tasks.watch_folder_tasks import _scan_sftp_folder

        mock_sftp = MagicMock()
        file_attr = MagicMock()
        file_attr.st_mode = stat.S_IFREG | 0o644
        file_attr.filename = "partial.pdf"
        mock_sftp.listdir_attr.return_value = [file_attr]

        # sftp.get creates a partial file then raises
        dest = tmp_path / "sftp_partial.pdf"

        def fake_get(remote, local):
            (tmp_path / "sftp_partial.pdf").write_bytes(b"partial")
            raise Exception("connection dropped")

        mock_sftp.get.side_effect = fake_get

        cache: dict = {}
        with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path)
            count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)

        assert count == 0
        assert not dest.exists()

    def test_dropbox_download_failure_removes_partial_file(self, tmp_path):
        """When Dropbox download fails and partial file exists, it should be removed."""
        import dropbox as dropbox_module

        from app.tasks.watch_folder_tasks import _scan_dropbox_folder

        mock_dbx = MagicMock()
        file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
        file_entry.name = "partial.pdf"
        file_entry.id = "id:partial"
        file_entry.path_lower = "/inbox/partial.pdf"

        mock_result = MagicMock()
        mock_result.entries = [file_entry]
        mock_result.has_more = False
        mock_dbx.files_list_folder.return_value = mock_result

        dest = tmp_path / "dropbox_partial.pdf"

        def fake_download(path):
            dest.write_bytes(b"partial")
            raise Exception("download aborted")

        mock_dbx.files_download.side_effect = fake_download

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_dropbox_folder("/Inbox", cache, False)

        assert count == 0
        assert not dest.exists()

    def test_google_drive_download_failure_removes_partial_file(self, tmp_path):
        """When Google Drive download fails and partial file exists, it should be removed."""

        from app.tasks.watch_folder_tasks import _scan_google_drive_folder

        mock_service = MagicMock()
        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "gid_partial", "name": "partial.pdf", "mimeType": "application/pdf"}],
        }

        dest = tmp_path / "gdrive_partial.pdf"

        def fake_downloader(*args, **kwargs):
            dest.write_bytes(b"partial")
            raise Exception("quota exceeded")

        mock_service.files.return_value.get_media.side_effect = fake_downloader

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path)
            count = _scan_google_drive_folder("folder123", cache, False)

        assert count == 0
        assert not dest.exists()

    def test_onedrive_download_failure_removes_partial_file(self, tmp_path):
        """When OneDrive download fails and partial file exists, it should be removed."""
        from app.tasks.watch_folder_tasks import _scan_onedrive_folder

        list_resp = MagicMock()
        list_resp.json.return_value = {
            "value": [
                {
                    "name": "partial.pdf",
                    "id": "partial1",
                    "@microsoft.graph.downloadUrl": "https://x.com/partial",
                }
            ]
        }
        list_resp.raise_for_status.return_value = None

        dest = tmp_path / "onedrive_partial.pdf"

        dl_call_count = 0

        def fake_get(url, **kwargs):
            nonlocal dl_call_count
            dl_call_count += 1
            if dl_call_count == 1:
                # First call is the listing request
                return list_resp
            # Second call is the download — create partial file then raise
            dest.write_bytes(b"partial")
            r = MagicMock()
            r.raise_for_status.side_effect = Exception("timeout")
            return r

        cache: dict = {}
        with (
            patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.get", side_effect=fake_get),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            count = _scan_onedrive_folder("/Inbox", cache, False)

        assert count == 0
        assert not dest.exists()


@pytest.mark.unit
class TestSftpKeyWithoutPassphrase:
    """Test SFTP key-based auth without passphrase."""

    def test_connects_with_key_no_passphrase(self, tmp_path):
        """Key file with no passphrase should still connect successfully."""
        import paramiko

        key_file = tmp_path / "id_rsa"
        key_file.write_text("fake key")

        mock_ssh = MagicMock(spec=paramiko.SSHClient)
        mock_sftp = MagicMock()
        mock_ssh.open_sftp.return_value = mock_sftp

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("paramiko.SSHClient", return_value=mock_ssh),
        ):
            mock_settings.sftp_host = "sftp.example.com"
            mock_settings.sftp_port = 22
            mock_settings.sftp_username = "user"
            mock_settings.sftp_disable_host_key_verification = False
            mock_settings.sftp_private_key = str(key_file)
            mock_settings.sftp_private_key_passphrase = None  # No passphrase
            mock_settings.sftp_password = None

            from app.tasks.watch_folder_tasks import _get_sftp_connection

            ssh, sftp = _get_sftp_connection()

        assert ssh is mock_ssh
        assert sftp is mock_sftp
        # Verify passphrase was NOT added to connect kwargs
        call_kwargs = mock_ssh.connect.call_args[1]
        assert "passphrase" not in call_kwargs


@pytest.mark.unit
class TestS3ClientAndPaginatorFailures:
    """Tests for S3 client creation and paginator failures."""

    def test_s3_client_creation_failure_returns_zero(self, tmp_path):
        """When boto3.client() raises, return 0."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        with (
            patch("boto3.client", side_effect=Exception("invalid credentials")),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            result = _scan_s3_prefix("inbox/", {}, False)

        assert result == 0

    def test_s3_paginate_failure_returns_zero(self, tmp_path):
        """When paginator.paginate() raises, return 0."""
        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.side_effect = Exception("ListObjectsV2 failed")

        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            result = _scan_s3_prefix("inbox/", {}, False)

        assert result == 0


@pytest.mark.unit
class TestHrefNullBranches:
    """Tests for null href branches in Nextcloud and WebDAV scanning."""

    def _nc_settings(self, mock_settings):
        """Configure mock Nextcloud settings."""
        mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
        mock_settings.nextcloud_username = "user"
        mock_settings.nextcloud_password = "pass"  # noqa: S105
        mock_settings.http_request_timeout = 30

    def _dav_settings(self, mock_settings, tmp_path):
        """Configure mock WebDAV settings."""
        mock_settings.webdav_url = "https://dav.example.com"
        mock_settings.webdav_username = "user"
        mock_settings.webdav_password = "pass"  # noqa: S105
        mock_settings.webdav_verify_ssl = True
        mock_settings.http_request_timeout = 30
        mock_settings.workdir = str(tmp_path)

    def test_nextcloud_response_with_no_href_element(self, tmp_path):
        """Response elements without d:href should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = '<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:propstat></d:propstat></d:response></d:multistatus>'
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
        ):
            self._nc_settings(mock_settings)
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", {}, False)
        assert count == 0

    def test_nextcloud_response_with_empty_href_text(self, tmp_path):
        """Response elements with empty d:href text should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = '<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href></d:href></d:response></d:multistatus>'
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
        ):
            self._nc_settings(mock_settings)
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", {}, False)
        assert count == 0

    def test_webdav_response_with_no_href_element(self, tmp_path):
        """WebDAV response elements without d:href should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = '<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:propstat></d:propstat></d:response></d:multistatus>'
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
        ):
            self._dav_settings(mock_settings, tmp_path)
            count = _scan_webdav_folder("/Inbox", {}, False)
        assert count == 0

    def test_webdav_response_with_empty_href_text(self, tmp_path):
        """WebDAV response elements with empty d:href text should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = '<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href></d:href></d:response></d:multistatus>'
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
        ):
            self._dav_settings(mock_settings, tmp_path)
            count = _scan_webdav_folder("/Inbox", {}, False)
        assert count == 0

    def test_nextcloud_download_failure_no_partial_file(self, tmp_path):
        """When Nextcloud download fails but no partial file created, skip gracefully."""
        from app.tasks.watch_folder_tasks import _scan_nextcloud_folder

        xml = '<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/remote.php/webdav/Scans/nopart.pdf</d:href></d:response></d:multistatus>'
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.raise_for_status.side_effect = Exception("network error")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
        ):
            self._nc_settings(mock_settings)
            mock_settings.workdir = str(tmp_path)
            count = _scan_nextcloud_folder("Scans", cache, False)
        assert count == 0

    def test_webdav_download_failure_no_partial_file(self, tmp_path):
        """When WebDAV download fails but no partial file exists, skip gracefully."""
        from app.tasks.watch_folder_tasks import _scan_webdav_folder

        xml = '<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/Inbox/nopart.pdf</d:href></d:response></d:multistatus>'
        propfind_resp = MagicMock()
        propfind_resp.text = xml
        propfind_resp.raise_for_status.return_value = None

        dl_resp = MagicMock()
        dl_resp.raise_for_status.side_effect = Exception("network error")

        cache: dict = {}
        with (
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("requests.request", return_value=propfind_resp),
            patch("requests.get", return_value=dl_resp),
        ):
            self._dav_settings(mock_settings, tmp_path)
            count = _scan_webdav_folder("/Inbox", cache, False)
        assert count == 0


@pytest.mark.unit
class TestS3DownloadFailureNoPartialFile:
    """Test S3 download failure where no partial file exists."""

    def test_s3_download_failure_no_partial_file(self, tmp_path):
        """When S3 ClientError occurs before file is created, skip gracefully."""
        from botocore.exceptions import ClientError

        from app.tasks.watch_folder_tasks import _scan_s3_prefix

        mock_s3 = MagicMock()
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/nopart.pdf"}]}]
        mock_s3.download_file.side_effect = ClientError(
            {"Error": {"Code": "NoSuchKey", "Message": "not found"}}, "GetObject"
        )

        cache: dict = {}
        with (
            patch("boto3.client", return_value=mock_s3),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.s3_bucket_name = "mybucket"
            mock_settings.aws_region = "us-east-1"
            mock_settings.aws_access_key_id = "key"
            mock_settings.aws_secret_access_key = "secret"  # noqa: S105
            mock_settings.workdir = str(tmp_path)
            count = _scan_s3_prefix("inbox/", cache, False)
        assert count == 0


# ---------------------------------------------------------------------------
# Tests for multi-tenant watch folder integration polling
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestIsSafeWatchPath:
    """Tests for _is_safe_watch_path path traversal security."""

    def test_absolute_path_is_safe(self):
        from app.tasks.watch_folder_tasks import _is_safe_watch_path

        assert _is_safe_watch_path("/data/watch") is True

    def test_empty_path_is_unsafe(self):
        from app.tasks.watch_folder_tasks import _is_safe_watch_path

        assert _is_safe_watch_path("") is False

    def test_relative_path_is_unsafe(self):
        from app.tasks.watch_folder_tasks import _is_safe_watch_path

        assert _is_safe_watch_path("relative/path") is False

    def test_traversal_path_is_unsafe(self):
        from app.tasks.watch_folder_tasks import _is_safe_watch_path

        assert _is_safe_watch_path("/data/../etc/passwd") is False

    def test_double_dot_component_is_unsafe(self):
        from app.tasks.watch_folder_tasks import _is_safe_watch_path

        assert _is_safe_watch_path("/data/watch/../../secret") is False

    def test_none_path_is_unsafe(self):
        from app.tasks.watch_folder_tasks import _is_safe_watch_path

        assert _is_safe_watch_path(None) is False


@pytest.mark.unit
class TestEnqueueFileOwnerIdPassthrough:
    """Test that _enqueue_file forwards owner_id to downstream tasks."""

    def test_pdf_forwards_owner_id(self):
        from app.tasks.watch_folder_tasks import _enqueue_file

        with (
            patch("app.tasks.watch_folder_tasks.convert_to_pdf") as mock_conv,
            patch("app.tasks.watch_folder_tasks.process_document") as mock_proc,
        ):
            _enqueue_file("/tmp/report.pdf", owner_id="user-123")
        mock_proc.delay.assert_called_once_with("/tmp/report.pdf", owner_id="user-123")
        mock_conv.delay.assert_not_called()

    def test_non_pdf_forwards_owner_id(self):
        from app.tasks.watch_folder_tasks import _enqueue_file

        with (
            patch("app.tasks.watch_folder_tasks.convert_to_pdf") as mock_conv,
            patch("app.tasks.watch_folder_tasks.process_document") as mock_proc,
        ):
            _enqueue_file("/tmp/doc.docx", owner_id="user-456")
        mock_conv.delay.assert_called_once_with("/tmp/doc.docx", owner_id="user-456")
        mock_proc.delay.assert_not_called()


@pytest.mark.unit
class TestScanUserWatchFolder:
    """Tests for _scan_user_watch_folder."""

    def test_scans_and_attributes_files_to_owner(self, tmp_path):
        from app.tasks.watch_folder_tasks import _scan_user_watch_folder

        # Create a test file
        (tmp_path / "test.pdf").write_bytes(b"%PDF-1.4")

        cache: dict[str, str] = {}
        with (
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path / "workdir")
            os.makedirs(mock_settings.workdir, exist_ok=True)
            count = _scan_user_watch_folder(str(tmp_path), cache, False, "owner-77")

        assert count == 1
        mock_enqueue.assert_called_once()
        call_kwargs = mock_enqueue.call_args
        assert call_kwargs.kwargs.get("owner_id") == "owner-77"

    def test_skips_already_processed_files(self, tmp_path):
        from app.tasks.watch_folder_tasks import _scan_user_watch_folder

        test_file = tmp_path / "test.pdf"
        test_file.write_bytes(b"%PDF-1.4")
        cache = {str(test_file): datetime.now(timezone.utc).isoformat()}

        with (
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path / "workdir")
            count = _scan_user_watch_folder(str(tmp_path), cache, False, "owner-77")

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_returns_zero_for_nonexistent_dir(self):
        from app.tasks.watch_folder_tasks import _scan_user_watch_folder

        count = _scan_user_watch_folder("/nonexistent/path", {}, False, "owner-1")
        assert count == 0


@pytest.mark.unit
class TestPullUserIntegrationWatchFolders:
    """Tests for _pull_user_integration_watch_folders."""

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    @patch("app.tasks.watch_folder_tasks._scan_user_watch_folder", return_value=2)
    @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
    @patch("app.tasks.watch_folder_tasks._save_cache")
    def test_polls_active_watch_folder_integrations(self, mock_save, mock_load, mock_scan, mock_session_factory):
        """Active WATCH_FOLDER integrations should be scanned."""
        from app.tasks.watch_folder_tasks import _pull_user_integration_watch_folders

        mock_integ = MagicMock()
        mock_integ.id = 20
        mock_integ.owner_id = "owner-wf"
        mock_integ.config = '{"folder_path": "/data/scans", "delete_after_process": false}'
        mock_integ.is_active = True

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        with patch("app.tasks.watch_folder_tasks._is_safe_watch_path", return_value=True):
            result = _pull_user_integration_watch_folders()

        assert result["status"] == "ok"
        assert result["integrations_processed"] == 1
        assert result["files_enqueued"] == 2
        mock_scan.assert_called_once()

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    def test_rejects_unsafe_paths(self, mock_session_factory):
        """Integrations with unsafe paths should be rejected."""
        from app.tasks.watch_folder_tasks import _pull_user_integration_watch_folders

        mock_integ = MagicMock()
        mock_integ.id = 21
        mock_integ.owner_id = "owner-bad"
        mock_integ.config = '{"folder_path": "/data/../etc/passwd"}'
        mock_integ.is_active = True

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        with patch("app.tasks.watch_folder_tasks._is_safe_watch_path", return_value=False):
            result = _pull_user_integration_watch_folders()

        assert result["status"] == "ok"
        assert mock_integ.last_error is not None

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    @patch("app.tasks.watch_folder_tasks._scan_user_watch_folder")
    @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
    @patch("app.tasks.watch_folder_tasks._save_cache")
    def test_handles_scan_failure_gracefully(self, mock_save, mock_load, mock_scan, mock_session_factory):
        """Scan failures should be recorded but not crash the loop."""
        from app.tasks.watch_folder_tasks import _pull_user_integration_watch_folders

        mock_integ = MagicMock()
        mock_integ.id = 22
        mock_integ.owner_id = "owner-err"
        mock_integ.config = '{"folder_path": "/data/broken"}'
        mock_integ.is_active = True

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        mock_scan.side_effect = Exception("Disk error")

        with patch("app.tasks.watch_folder_tasks._is_safe_watch_path", return_value=True):
            result = _pull_user_integration_watch_folders()

        assert mock_integ.last_error is not None
        assert "Disk error" in mock_integ.last_error

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    def test_handles_db_failure_gracefully(self, mock_session_factory):
        """Database failures should return error status without crashing."""
        from app.tasks.watch_folder_tasks import _pull_user_integration_watch_folders

        mock_session_factory.side_effect = Exception("DB unavailable")
        result = _pull_user_integration_watch_folders()
        assert result["status"] == "error"

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    def test_skips_integration_without_folder_path(self, mock_session_factory):
        """Integrations without folder_path should be skipped."""
        from app.tasks.watch_folder_tasks import _pull_user_integration_watch_folders

        mock_integ = MagicMock()
        mock_integ.id = 23
        mock_integ.owner_id = "owner-empty"
        mock_integ.config = '{"delete_after_process": false}'
        mock_integ.is_active = True

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        result = _pull_user_integration_watch_folders()
        assert result["status"] == "ok"

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
    @patch("app.tasks.watch_folder_tasks._save_cache")
    def test_dispatches_s3_source_type(self, mock_save, mock_load, mock_session_factory):
        """WATCH_FOLDER with source_type 's3' should dispatch to _scan_user_s3_folder."""
        from app.tasks.watch_folder_tasks import (
            _USER_WF_CLOUD_HANDLERS,
            _pull_user_integration_watch_folders,
        )

        mock_integ = MagicMock()
        mock_integ.id = 30
        mock_integ.owner_id = "owner-s3"
        mock_integ.config = (
            '{"source_type": "s3", "bucket": "test-bucket", "prefix": "inbox/", "delete_after_process": false}'
        )
        mock_integ.is_active = True
        mock_integ.credentials = "encrypted-s3-creds"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        mock_s3_handler = MagicMock(return_value=3)
        original_handler = _USER_WF_CLOUD_HANDLERS.get("s3")
        _USER_WF_CLOUD_HANDLERS["s3"] = mock_s3_handler
        try:
            with patch(
                "app.utils.encryption.decrypt_value",
                return_value='{"access_key_id": "AKI", "secret_access_key": "SK"}',
            ):
                result = _pull_user_integration_watch_folders()

            assert result["status"] == "ok"
            assert result["files_enqueued"] == 3
            mock_s3_handler.assert_called_once()
            args = mock_s3_handler.call_args
            assert args[0][0]["source_type"] == "s3"
            assert args[0][4] == "owner-s3"
        finally:
            if original_handler is not None:
                _USER_WF_CLOUD_HANDLERS["s3"] = original_handler

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
    @patch("app.tasks.watch_folder_tasks._save_cache")
    def test_dispatches_dropbox_source_type(self, mock_save, mock_load, mock_session_factory):
        """WATCH_FOLDER with source_type 'dropbox' should dispatch to _scan_user_dropbox_folder."""
        from app.tasks.watch_folder_tasks import (
            _USER_WF_CLOUD_HANDLERS,
            _pull_user_integration_watch_folders,
        )

        mock_integ = MagicMock()
        mock_integ.id = 31
        mock_integ.owner_id = "owner-dbx"
        mock_integ.config = '{"source_type": "dropbox", "folder_path": "/Inbox", "delete_after_process": false}'
        mock_integ.is_active = True
        mock_integ.credentials = "encrypted-dbx-creds"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        mock_dbx_handler = MagicMock(return_value=5)
        original_handler = _USER_WF_CLOUD_HANDLERS.get("dropbox")
        _USER_WF_CLOUD_HANDLERS["dropbox"] = mock_dbx_handler
        try:
            with patch(
                "app.utils.encryption.decrypt_value",
                return_value='{"refresh_token": "tok", "app_key": "ak", "app_secret": "as"}',
            ):
                result = _pull_user_integration_watch_folders()

            assert result["status"] == "ok"
            assert result["files_enqueued"] == 5
            mock_dbx_handler.assert_called_once()
            args = mock_dbx_handler.call_args
            assert args[0][0]["source_type"] == "dropbox"
            assert args[0][4] == "owner-dbx"
        finally:
            if original_handler is not None:
                _USER_WF_CLOUD_HANDLERS["dropbox"] = original_handler

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    def test_unknown_source_type_skipped(self, mock_session_factory):
        """Unknown source_type should be skipped gracefully."""
        from app.tasks.watch_folder_tasks import _pull_user_integration_watch_folders

        mock_integ = MagicMock()
        mock_integ.id = 32
        mock_integ.owner_id = "owner-unknown"
        mock_integ.config = '{"source_type": "unknown_provider", "delete_after_process": false}'
        mock_integ.is_active = True

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        result = _pull_user_integration_watch_folders()
        assert result["status"] == "ok"
        assert result["files_enqueued"] == 0

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    @patch("app.tasks.watch_folder_tasks._scan_user_watch_folder", return_value=4)
    @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
    @patch("app.tasks.watch_folder_tasks._save_cache")
    def test_local_source_type_dispatches_to_local_scanner(
        self, mock_save, mock_load, mock_scan_local, mock_session_factory
    ):
        """Explicit source_type 'local' should use the local filesystem scanner."""
        from app.tasks.watch_folder_tasks import _pull_user_integration_watch_folders

        mock_integ = MagicMock()
        mock_integ.id = 33
        mock_integ.owner_id = "owner-local"
        mock_integ.config = '{"source_type": "local", "folder_path": "/data/scans", "delete_after_process": false}'
        mock_integ.is_active = True

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        result = _pull_user_integration_watch_folders()
        assert result["status"] == "ok"
        assert result["files_enqueued"] == 4
        mock_scan_local.assert_called_once()
        assert mock_scan_local.call_args[0][0] == "/data/scans"
        assert mock_scan_local.call_args[0][3] == "owner-local"

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
    @patch("app.tasks.watch_folder_tasks._save_cache")
    def test_dispatches_google_drive_source_type(self, mock_save, mock_load, mock_session_factory):
        """WATCH_FOLDER with source_type 'google_drive' should dispatch to handler."""
        from app.tasks.watch_folder_tasks import (
            _USER_WF_CLOUD_HANDLERS,
            _pull_user_integration_watch_folders,
        )

        mock_integ = MagicMock()
        mock_integ.id = 34
        mock_integ.owner_id = "owner-gd"
        mock_integ.config = '{"source_type": "google_drive", "folder_id": "abc123", "delete_after_process": false}'
        mock_integ.is_active = True
        mock_integ.credentials = "encrypted-gd-creds"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        mock_gd_handler = MagicMock(return_value=2)
        original_handler = _USER_WF_CLOUD_HANDLERS.get("google_drive")
        _USER_WF_CLOUD_HANDLERS["google_drive"] = mock_gd_handler
        try:
            with patch(
                "app.utils.encryption.decrypt_value",
                return_value='{"credentials_json": "{}"}',
            ):
                result = _pull_user_integration_watch_folders()

            assert result["status"] == "ok"
            assert result["files_enqueued"] == 2
            mock_gd_handler.assert_called_once()
            args = mock_gd_handler.call_args
            assert args[0][0]["source_type"] == "google_drive"
            assert args[0][4] == "owner-gd"
        finally:
            if original_handler is not None:
                _USER_WF_CLOUD_HANDLERS["google_drive"] = original_handler

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
    @patch("app.tasks.watch_folder_tasks._save_cache")
    def test_dispatches_onedrive_source_type(self, mock_save, mock_load, mock_session_factory):
        """WATCH_FOLDER with source_type 'onedrive' should dispatch to handler."""
        from app.tasks.watch_folder_tasks import (
            _USER_WF_CLOUD_HANDLERS,
            _pull_user_integration_watch_folders,
        )

        mock_integ = MagicMock()
        mock_integ.id = 35
        mock_integ.owner_id = "owner-od"
        mock_integ.config = '{"source_type": "onedrive", "folder_path": "/Documents", "delete_after_process": false}'
        mock_integ.is_active = True
        mock_integ.credentials = "encrypted-od-creds"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        mock_od_handler = MagicMock(return_value=7)
        original_handler = _USER_WF_CLOUD_HANDLERS.get("onedrive")
        _USER_WF_CLOUD_HANDLERS["onedrive"] = mock_od_handler
        try:
            with patch(
                "app.utils.encryption.decrypt_value",
                return_value='{"refresh_token": "rt", "client_id": "ci", "client_secret": "cs"}',
            ):
                result = _pull_user_integration_watch_folders()

            assert result["status"] == "ok"
            assert result["files_enqueued"] == 7
            mock_od_handler.assert_called_once()
            args = mock_od_handler.call_args
            assert args[0][0]["source_type"] == "onedrive"
            assert args[0][4] == "owner-od"
        finally:
            if original_handler is not None:
                _USER_WF_CLOUD_HANDLERS["onedrive"] = original_handler

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
    @patch("app.tasks.watch_folder_tasks._save_cache")
    def test_dispatches_nextcloud_source_type(self, mock_save, mock_load, mock_session_factory):
        """WATCH_FOLDER with source_type 'nextcloud' should dispatch to handler."""
        from app.tasks.watch_folder_tasks import (
            _USER_WF_CLOUD_HANDLERS,
            _pull_user_integration_watch_folders,
        )

        mock_integ = MagicMock()
        mock_integ.id = 36
        mock_integ.owner_id = "owner-nc"
        mock_integ.config = (
            '{"source_type": "nextcloud", "url": "https://nc.example.com",'
            ' "folder_path": "/inbox", "delete_after_process": false}'
        )
        mock_integ.is_active = True
        mock_integ.credentials = "encrypted-nc-creds"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        mock_nc_handler = MagicMock(return_value=1)
        original_handler = _USER_WF_CLOUD_HANDLERS.get("nextcloud")
        _USER_WF_CLOUD_HANDLERS["nextcloud"] = mock_nc_handler
        try:
            with patch(
                "app.utils.encryption.decrypt_value",
                return_value='{"username": "u", "password": "p"}',
            ):
                result = _pull_user_integration_watch_folders()

            assert result["status"] == "ok"
            assert result["files_enqueued"] == 1
            mock_nc_handler.assert_called_once()
            args = mock_nc_handler.call_args
            assert args[0][0]["source_type"] == "nextcloud"
            assert args[0][4] == "owner-nc"
        finally:
            if original_handler is not None:
                _USER_WF_CLOUD_HANDLERS["nextcloud"] = original_handler

    @patch("app.tasks.watch_folder_tasks._get_db_session")
    @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
    @patch("app.tasks.watch_folder_tasks._save_cache")
    def test_dispatches_webdav_source_type(self, mock_save, mock_load, mock_session_factory):
        """WATCH_FOLDER with source_type 'webdav' should dispatch to handler."""
        from app.tasks.watch_folder_tasks import (
            _USER_WF_CLOUD_HANDLERS,
            _pull_user_integration_watch_folders,
        )

        mock_integ = MagicMock()
        mock_integ.id = 37
        mock_integ.owner_id = "owner-dav"
        mock_integ.config = (
            '{"source_type": "webdav", "url": "https://dav.example.com",'
            ' "folder_path": "/inbox", "delete_after_process": false}'
        )
        mock_integ.is_active = True
        mock_integ.credentials = "encrypted-dav-creds"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
        mock_session_factory.return_value = mock_db

        mock_dav_handler = MagicMock(return_value=6)
        original_handler = _USER_WF_CLOUD_HANDLERS.get("webdav")
        _USER_WF_CLOUD_HANDLERS["webdav"] = mock_dav_handler
        try:
            with patch(
                "app.utils.encryption.decrypt_value",
                return_value='{"username": "u", "password": "p"}',
            ):
                result = _pull_user_integration_watch_folders()

            assert result["status"] == "ok"
            assert result["files_enqueued"] == 6
            mock_dav_handler.assert_called_once()
            args = mock_dav_handler.call_args
            assert args[0][0]["source_type"] == "webdav"
            assert args[0][4] == "owner-dav"
        finally:
            if original_handler is not None:
                _USER_WF_CLOUD_HANDLERS["webdav"] = original_handler


@pytest.mark.unit
class TestScanUserS3Folder:
    """Tests for _scan_user_s3_folder per-user S3 scanning."""

    def test_returns_zero_when_bucket_not_configured(self):
        """Empty bucket config should return 0 immediately."""
        mock_boto3 = MagicMock()
        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()
        with patch.dict(
            "sys.modules", {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc}
        ):
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"prefix": "inbox/"},
                creds={"access_key_id": "AKI", "secret_access_key": "SK"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_credentials_incomplete(self):
        """Missing access_key_id or secret_access_key should return 0."""
        mock_boto3 = MagicMock()
        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()
        with patch.dict(
            "sys.modules", {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc}
        ):
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"bucket": "my-bucket"},
                creds={"access_key_id": "AKI"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_client_creation_fails(self):
        """boto3.client raising should return 0."""
        mock_boto3 = MagicMock()
        mock_boto3.client.side_effect = Exception("Bad credentials")
        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()
        with patch.dict(
            "sys.modules", {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc}
        ):
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"bucket": "my-bucket"},
                creds={"access_key_id": "AKI", "secret_access_key": "SK"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_pagination_fails(self):
        """Paginator raising should return 0."""
        mock_boto3 = MagicMock()
        mock_s3 = MagicMock()
        mock_boto3.client.return_value = mock_s3
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.side_effect = Exception("Access denied")
        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()
        with patch.dict(
            "sys.modules", {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc}
        ):
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"bucket": "my-bucket"},
                creds={"access_key_id": "AKI", "secret_access_key": "SK"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_downloads_and_enqueues_new_file(self, tmp_path):
        """Successful S3 download should enqueue file and return 1."""
        mock_boto3 = MagicMock()
        mock_s3 = MagicMock()
        mock_boto3.client.return_value = mock_s3
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]
        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()
        mock_botocore_exc.ClientError = type("ClientError", (Exception,), {})

        with (
            patch.dict(
                "sys.modules",
                {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
            ),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"bucket": "my-bucket", "prefix": "inbox/"},
                creds={"access_key_id": "AKI", "secret_access_key": "SK"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert result == 1
        mock_enqueue.assert_called_once()
        mock_mark.assert_called_once()

    def test_skips_cached_file(self, tmp_path):
        """Files already in cache should be skipped."""
        mock_boto3 = MagicMock()
        mock_s3 = MagicMock()
        mock_boto3.client.return_value = mock_s3
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]
        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()

        with (
            patch.dict(
                "sys.modules",
                {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
            ),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"bucket": "my-bucket", "prefix": "inbox/"},
                creds={"access_key_id": "AKI", "secret_access_key": "SK"},
                cache={"s3:my-bucket/inbox/doc.pdf": "done"},
                delete_after=False,
                owner_id="u1",
            )

        assert result == 0
        mock_enqueue.assert_not_called()

    def test_skips_disallowed_file_type(self, tmp_path):
        """Non-allowed files (e.g. .exe) should be skipped."""
        mock_boto3 = MagicMock()
        mock_s3 = MagicMock()
        mock_boto3.client.return_value = mock_s3
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/virus.exe"}]}]
        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()

        with (
            patch.dict(
                "sys.modules",
                {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
            ),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=False),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"bucket": "my-bucket", "prefix": "inbox/"},
                creds={"access_key_id": "AKI", "secret_access_key": "SK"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert result == 0
        mock_enqueue.assert_not_called()

    def test_deletes_after_download(self, tmp_path):
        """delete_after=True should call delete_object on the S3 key."""
        mock_boto3 = MagicMock()
        mock_s3 = MagicMock()
        mock_boto3.client.return_value = mock_s3
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]
        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()
        mock_botocore_exc.ClientError = type("ClientError", (Exception,), {})

        with (
            patch.dict(
                "sys.modules",
                {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
            ),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file"),
            patch("app.tasks.watch_folder_tasks._mark_processed"),
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"bucket": "my-bucket", "prefix": "inbox/"},
                creds={"access_key_id": "AKI", "secret_access_key": "SK"},
                cache={},
                delete_after=True,
                owner_id="u1",
            )

        assert result == 1
        mock_s3.delete_object.assert_called_once_with(Bucket="my-bucket", Key="inbox/doc.pdf")

    def test_handles_download_failure(self, tmp_path):
        """ClientError on download_file should skip the file and continue."""
        mock_boto3 = MagicMock()
        mock_s3 = MagicMock()
        mock_boto3.client.return_value = mock_s3
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]

        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()
        FakeClientError = type("ClientError", (Exception,), {})
        mock_botocore_exc.ClientError = FakeClientError
        mock_s3.download_file.side_effect = FakeClientError("403 Forbidden")

        with (
            patch.dict(
                "sys.modules",
                {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
            ),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"bucket": "my-bucket", "prefix": "inbox/"},
                creds={"access_key_id": "AKI", "secret_access_key": "SK"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert result == 0
        mock_enqueue.assert_not_called()

    def test_handles_delete_failure_gracefully(self, tmp_path):
        """delete_object raising should log warning but not crash."""
        mock_boto3 = MagicMock()
        mock_s3 = MagicMock()
        mock_boto3.client.return_value = mock_s3
        mock_paginator = MagicMock()
        mock_s3.get_paginator.return_value = mock_paginator
        mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]
        mock_s3.delete_object.side_effect = Exception("Delete denied")
        mock_botocore = MagicMock()
        mock_botocore_exc = MagicMock()
        mock_botocore_exc.ClientError = type("ClientError", (Exception,), {})

        with (
            patch.dict(
                "sys.modules",
                {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
            ),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file"),
            patch("app.tasks.watch_folder_tasks._mark_processed"),
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_s3_folder

            result = _scan_user_s3_folder(
                cfg={"bucket": "my-bucket", "prefix": "inbox/"},
                creds={"access_key_id": "AKI", "secret_access_key": "SK"},
                cache={},
                delete_after=True,
                owner_id="u1",
            )

        assert result == 1


@pytest.mark.unit
class TestScanUserDropboxFolder:
    """Tests for _scan_user_dropbox_folder per-user Dropbox scanning."""

    def _make_dropbox_mocks(self):
        """Create standard dropbox mock module and helper types."""
        mock_dropbox_mod = MagicMock()
        FakeFileMeta = type("FileMetadata", (), {})
        FakeFolderMeta = type("FolderMetadata", (), {})
        mock_dropbox_mod.files.FileMetadata = FakeFileMeta
        mock_dropbox_mod.files.FolderMetadata = FakeFolderMeta
        return mock_dropbox_mod, FakeFileMeta, FakeFolderMeta

    def test_returns_zero_when_credentials_incomplete(self):
        """Missing refresh_token/app_key/app_secret should return 0."""
        mock_dropbox_mod, _, _ = self._make_dropbox_mocks()
        with patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}):
            from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder

            result = _scan_user_dropbox_folder(
                cfg={"folder_path": "/inbox"},
                creds={"refresh_token": "tok", "app_key": ""},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_folder_path_empty(self):
        """Empty folder_path should return 0."""
        mock_dropbox_mod, _, _ = self._make_dropbox_mocks()
        with patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}):
            from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder

            result = _scan_user_dropbox_folder(
                cfg={"folder_path": ""},
                creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_auth_fails(self):
        """Dropbox constructor raising should return 0."""
        mock_dropbox_mod, _, _ = self._make_dropbox_mocks()
        mock_dropbox_mod.Dropbox.side_effect = Exception("Auth error")
        with patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}):
            from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder

            result = _scan_user_dropbox_folder(
                cfg={"folder_path": "/inbox"},
                creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_listing_fails(self):
        """files_list_folder raising should return 0."""
        mock_dropbox_mod, _, _ = self._make_dropbox_mocks()
        mock_dbx_client = MagicMock()
        mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
        mock_dbx_client.files_list_folder.side_effect = Exception("API error")

        with patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}):
            from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder

            result = _scan_user_dropbox_folder(
                cfg={"folder_path": "/inbox"},
                creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_downloads_and_enqueues_new_file(self, tmp_path):
        """Successful Dropbox download should enqueue file and return 1."""
        mock_dropbox_mod, FakeFileMeta, _ = self._make_dropbox_mocks()
        entry = FakeFileMeta()
        entry.name = "doc.pdf"
        entry.id = "id:abc123"
        entry.path_lower = "/inbox/doc.pdf"

        mock_dbx_client = MagicMock()
        mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
        result_obj = MagicMock()
        result_obj.entries = [entry]
        result_obj.has_more = False
        mock_dbx_client.files_list_folder.return_value = result_obj

        mock_response = MagicMock()
        mock_response.content = b"%PDF-1.4 test"
        mock_dbx_client.files_download.return_value = (MagicMock(), mock_response)

        with (
            patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder

            count = _scan_user_dropbox_folder(
                cfg={"folder_path": "/inbox"},
                creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 1
        mock_enqueue.assert_called_once()
        mock_mark.assert_called_once()

    def test_skips_cached_file(self, tmp_path):
        """Files already in cache should be skipped."""
        mock_dropbox_mod, FakeFileMeta, _ = self._make_dropbox_mocks()
        entry = FakeFileMeta()
        entry.name = "doc.pdf"
        entry.id = "id:abc123"
        entry.path_lower = "/inbox/doc.pdf"

        mock_dbx_client = MagicMock()
        mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
        result_obj = MagicMock()
        result_obj.entries = [entry]
        result_obj.has_more = False
        mock_dbx_client.files_list_folder.return_value = result_obj

        with (
            patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder

            count = _scan_user_dropbox_folder(
                cfg={"folder_path": "/inbox"},
                creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
                cache={"dropbox:id:abc123": "done"},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_skips_non_file_entries(self, tmp_path):
        """FolderMetadata entries should be skipped."""
        mock_dropbox_mod, _, FakeFolderMeta = self._make_dropbox_mocks()
        folder_entry = FakeFolderMeta()
        folder_entry.name = "subfolder"

        mock_dbx_client = MagicMock()
        mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
        result_obj = MagicMock()
        result_obj.entries = [folder_entry]
        result_obj.has_more = False
        mock_dbx_client.files_list_folder.return_value = result_obj

        with (
            patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder

            count = _scan_user_dropbox_folder(
                cfg={"folder_path": "/inbox"},
                creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_deletes_after_download(self, tmp_path):
        """delete_after=True should call files_delete_v2."""
        mock_dropbox_mod, FakeFileMeta, _ = self._make_dropbox_mocks()
        entry = FakeFileMeta()
        entry.name = "doc.pdf"
        entry.id = "id:abc123"
        entry.path_lower = "/inbox/doc.pdf"

        mock_dbx_client = MagicMock()
        mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
        result_obj = MagicMock()
        result_obj.entries = [entry]
        result_obj.has_more = False
        mock_dbx_client.files_list_folder.return_value = result_obj

        mock_response = MagicMock()
        mock_response.content = b"%PDF-1.4 test"
        mock_dbx_client.files_download.return_value = (MagicMock(), mock_response)

        with (
            patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file"),
            patch("app.tasks.watch_folder_tasks._mark_processed"),
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder

            count = _scan_user_dropbox_folder(
                cfg={"folder_path": "/inbox"},
                creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
                cache={},
                delete_after=True,
                owner_id="u1",
            )

        assert count == 1
        mock_dbx_client.files_delete_v2.assert_called_once_with("/inbox/doc.pdf")

    def test_handles_download_failure(self, tmp_path):
        """Download failure should skip the file and continue."""
        mock_dropbox_mod, FakeFileMeta, _ = self._make_dropbox_mocks()
        entry = FakeFileMeta()
        entry.name = "doc.pdf"
        entry.id = "id:abc123"
        entry.path_lower = "/inbox/doc.pdf"

        mock_dbx_client = MagicMock()
        mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
        result_obj = MagicMock()
        result_obj.entries = [entry]
        result_obj.has_more = False
        mock_dbx_client.files_list_folder.return_value = result_obj
        mock_dbx_client.files_download.side_effect = Exception("Download failed")

        with (
            patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder

            count = _scan_user_dropbox_folder(
                cfg={"folder_path": "/inbox"},
                creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()


@pytest.mark.unit
class TestScanUserGoogleDriveFolder:
    """Tests for _scan_user_google_drive_folder per-user Google Drive scanning."""

    def _make_google_mocks(self):
        """Create Google API mock modules for sys.modules patching."""
        mock_sa_mod = MagicMock()
        mock_gapi_disc = MagicMock()
        mock_gapi_http = MagicMock()

        mock_service = MagicMock()
        mock_gapi_disc.build.return_value = mock_service
        mock_sa_mod.Credentials.from_service_account_info.return_value = MagicMock()

        # Wire parent-child module attributes so `from X.Y import Z` resolves correctly
        mock_google_oauth2 = MagicMock()
        mock_google_oauth2.service_account = mock_sa_mod
        mock_google = MagicMock()
        mock_google.oauth2 = mock_google_oauth2

        mock_gapi = MagicMock()
        mock_gapi.discovery = mock_gapi_disc
        mock_gapi.http = mock_gapi_http

        modules = {
            "google": mock_google,
            "google.oauth2": mock_google_oauth2,
            "google.oauth2.service_account": mock_sa_mod,
            "googleapiclient": mock_gapi,
            "googleapiclient.discovery": mock_gapi_disc,
            "googleapiclient.http": mock_gapi_http,
        }
        return modules, mock_service, mock_gapi_http, mock_sa_mod

    def test_returns_zero_when_credentials_json_empty(self):
        """Empty credentials_json should return 0."""
        modules, _, _, _ = self._make_google_mocks()
        with patch.dict("sys.modules", modules):
            from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder

            result = _scan_user_google_drive_folder(
                cfg={"folder_id": "abc"},
                creds={"credentials_json": ""},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_folder_id_empty(self):
        """Empty folder_id should return 0."""
        modules, _, _, _ = self._make_google_mocks()
        with patch.dict("sys.modules", modules):
            from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder

            result = _scan_user_google_drive_folder(
                cfg={"folder_id": ""},
                creds={"credentials_json": '{"type": "service_account"}'},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_auth_fails(self):
        """service_account.Credentials raising should return 0."""
        modules, _, _, mock_sa_mod = self._make_google_mocks()
        mock_sa_mod.Credentials.from_service_account_info.side_effect = Exception("Bad SA key")
        with patch.dict("sys.modules", modules):
            from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder

            result = _scan_user_google_drive_folder(
                cfg={"folder_id": "abc"},
                creds={"credentials_json": '{"type": "service_account"}'},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_downloads_and_enqueues_new_file(self, tmp_path):
        """Successful Google Drive download should enqueue file and return 1."""
        modules, mock_service, mock_gapi_http, _ = self._make_google_mocks()

        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "f1", "name": "doc.pdf", "mimeType": "application/pdf"}],
        }

        mock_downloader = MagicMock()
        mock_downloader.next_chunk.return_value = (None, True)
        mock_gapi_http.MediaIoBaseDownload.return_value = mock_downloader

        with (
            patch.dict("sys.modules", modules),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder

            count = _scan_user_google_drive_folder(
                cfg={"folder_id": "abc"},
                creds={"credentials_json": '{"type": "service_account"}'},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 1
        mock_enqueue.assert_called_once()
        mock_mark.assert_called_once()

    def test_skips_cached_file(self, tmp_path):
        """Files already in cache should be skipped."""
        modules, mock_service, _, _ = self._make_google_mocks()

        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "f1", "name": "doc.pdf", "mimeType": "application/pdf"}],
        }

        with (
            patch.dict("sys.modules", modules),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder

            count = _scan_user_google_drive_folder(
                cfg={"folder_id": "abc"},
                creds={"credentials_json": '{"type": "service_account"}'},
                cache={"gdrive:f1": "done"},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_deletes_after_download(self, tmp_path):
        """delete_after=True should call files().delete()."""
        modules, mock_service, mock_gapi_http, _ = self._make_google_mocks()

        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "f1", "name": "doc.pdf", "mimeType": "application/pdf"}],
        }
        mock_downloader = MagicMock()
        mock_downloader.next_chunk.return_value = (None, True)
        mock_gapi_http.MediaIoBaseDownload.return_value = mock_downloader

        with (
            patch.dict("sys.modules", modules),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file"),
            patch("app.tasks.watch_folder_tasks._mark_processed"),
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder

            count = _scan_user_google_drive_folder(
                cfg={"folder_id": "abc"},
                creds={"credentials_json": '{"type": "service_account"}'},
                cache={},
                delete_after=True,
                owner_id="u1",
            )

        assert count == 1
        mock_service.files.return_value.delete.assert_called_once_with(fileId="f1")

    def test_handles_download_failure(self, tmp_path):
        """Download failure should skip the file and continue."""
        modules, mock_service, mock_gapi_http, _ = self._make_google_mocks()

        mock_service.files.return_value.list.return_value.execute.return_value = {
            "files": [{"id": "f1", "name": "doc.pdf", "mimeType": "application/pdf"}],
        }
        mock_gapi_http.MediaIoBaseDownload.side_effect = Exception("Download failed")

        with (
            patch.dict("sys.modules", modules),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder

            count = _scan_user_google_drive_folder(
                cfg={"folder_id": "abc"},
                creds={"credentials_json": '{"type": "service_account"}'},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_handles_listing_failure(self, tmp_path):
        """Listing failure should break and return 0."""
        modules, mock_service, _, _ = self._make_google_mocks()

        mock_service.files.return_value.list.return_value.execute.side_effect = Exception("API error")

        with (
            patch.dict("sys.modules", modules),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder

            count = _scan_user_google_drive_folder(
                cfg={"folder_id": "abc"},
                creds={"credentials_json": '{"type": "service_account"}'},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()


@pytest.mark.unit
class TestScanUserOneDriveFolder:
    """Tests for _scan_user_onedrive_folder per-user OneDrive scanning."""

    def test_returns_zero_when_credentials_incomplete(self):
        """Missing refresh_token/client_id/client_secret should return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        result = _scan_user_onedrive_folder(
            cfg={"folder_path": "/Documents"},
            creds={"refresh_token": "tok", "client_id": ""},
            cache={},
            delete_after=False,
            owner_id="u1",
        )
        assert result == 0

    def test_returns_zero_when_folder_path_empty(self):
        """Empty folder_path should return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        result = _scan_user_onedrive_folder(
            cfg={"folder_path": ""},
            creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
            cache={},
            delete_after=False,
            owner_id="u1",
        )
        assert result == 0

    def test_returns_zero_when_token_exchange_fails(self):
        """Token exchange failure should return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        with patch("requests.post") as mock_post:
            mock_post.side_effect = Exception("Token error")

            result = _scan_user_onedrive_folder(
                cfg={"folder_path": "/Documents"},
                creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_downloads_and_enqueues_new_file(self, tmp_path):
        """Successful OneDrive download should enqueue file and return 1."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        mock_token_resp = MagicMock()
        mock_token_resp.json.return_value = {"access_token": "tok123"}

        mock_list_resp = MagicMock()
        mock_list_resp.json.return_value = {
            "value": [
                {
                    "name": "doc.pdf",
                    "id": "item1",
                    "@microsoft.graph.downloadUrl": "https://dl.example.com/doc.pdf",
                }
            ],
        }

        mock_dl_resp = MagicMock()
        mock_dl_resp.content = b"%PDF-1.4"

        with (
            patch("requests.post", return_value=mock_token_resp),
            patch("requests.get", side_effect=[mock_list_resp, mock_dl_resp]),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_onedrive_folder(
                cfg={"folder_path": "/Documents"},
                creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 1
        mock_enqueue.assert_called_once()
        mock_mark.assert_called_once()

    def test_skips_folders(self, tmp_path):
        """Items with 'folder' key should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        mock_token_resp = MagicMock()
        mock_token_resp.json.return_value = {"access_token": "tok123"}

        mock_list_resp = MagicMock()
        mock_list_resp.json.return_value = {
            "value": [{"name": "subfolder", "id": "fold1", "folder": {"childCount": 3}}],
        }

        with (
            patch("requests.post", return_value=mock_token_resp),
            patch("requests.get", return_value=mock_list_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_onedrive_folder(
                cfg={"folder_path": "/Documents"},
                creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_skips_cached_file(self, tmp_path):
        """Files already in cache should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        mock_token_resp = MagicMock()
        mock_token_resp.json.return_value = {"access_token": "tok123"}

        mock_list_resp = MagicMock()
        mock_list_resp.json.return_value = {
            "value": [
                {
                    "name": "doc.pdf",
                    "id": "item1",
                    "@microsoft.graph.downloadUrl": "https://dl.example.com/doc.pdf",
                }
            ],
        }

        with (
            patch("requests.post", return_value=mock_token_resp),
            patch("requests.get", return_value=mock_list_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_onedrive_folder(
                cfg={"folder_path": "/Documents"},
                creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
                cache={"onedrive:item1": "done"},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_skips_items_without_download_url(self, tmp_path):
        """Items missing @microsoft.graph.downloadUrl should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        mock_token_resp = MagicMock()
        mock_token_resp.json.return_value = {"access_token": "tok123"}

        mock_list_resp = MagicMock()
        mock_list_resp.json.return_value = {
            "value": [{"name": "doc.pdf", "id": "item1"}],
        }

        with (
            patch("requests.post", return_value=mock_token_resp),
            patch("requests.get", return_value=mock_list_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_onedrive_folder(
                cfg={"folder_path": "/Documents"},
                creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_deletes_after_download(self, tmp_path):
        """delete_after=True should call requests.delete on the item."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        mock_token_resp = MagicMock()
        mock_token_resp.json.return_value = {"access_token": "tok123"}

        mock_list_resp = MagicMock()
        mock_list_resp.json.return_value = {
            "value": [
                {
                    "name": "doc.pdf",
                    "id": "item1",
                    "@microsoft.graph.downloadUrl": "https://dl.example.com/doc.pdf",
                }
            ],
        }

        mock_dl_resp = MagicMock()
        mock_dl_resp.content = b"%PDF-1.4"
        mock_del_resp = MagicMock()

        with (
            patch("requests.post", return_value=mock_token_resp),
            patch("requests.get", side_effect=[mock_list_resp, mock_dl_resp]),
            patch("requests.delete", return_value=mock_del_resp) as mock_delete,
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file"),
            patch("app.tasks.watch_folder_tasks._mark_processed"),
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_onedrive_folder(
                cfg={"folder_path": "/Documents"},
                creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
                cache={},
                delete_after=True,
                owner_id="u1",
            )

        assert count == 1
        mock_delete.assert_called_once()

    def test_handles_download_failure(self, tmp_path):
        """Download failure should skip the file and continue."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        mock_token_resp = MagicMock()
        mock_token_resp.json.return_value = {"access_token": "tok123"}

        mock_list_resp = MagicMock()
        mock_list_resp.json.return_value = {
            "value": [
                {
                    "name": "doc.pdf",
                    "id": "item1",
                    "@microsoft.graph.downloadUrl": "https://dl.example.com/doc.pdf",
                }
            ],
        }

        mock_dl_resp = MagicMock()
        mock_dl_resp.raise_for_status.side_effect = Exception("Download failed")

        with (
            patch("requests.post", return_value=mock_token_resp),
            patch("requests.get", side_effect=[mock_list_resp, mock_dl_resp]),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_onedrive_folder(
                cfg={"folder_path": "/Documents"},
                creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_handles_listing_failure(self, tmp_path):
        """Listing failure should break and return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder

        mock_token_resp = MagicMock()
        mock_token_resp.json.return_value = {"access_token": "tok123"}

        mock_list_resp = MagicMock()
        mock_list_resp.raise_for_status.side_effect = Exception("Listing failed")

        with (
            patch("requests.post", return_value=mock_token_resp),
            patch("requests.get", return_value=mock_list_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_onedrive_folder(
                cfg={"folder_path": "/Documents"},
                creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()


def _build_propfind_xml(href_paths: list[str]) -> str:
    """Build a minimal PROPFIND multi-status XML response for testing."""
    responses = ""
    for href in href_paths:
        responses += f"""
        <d:response>
            <d:href>{href}</d:href>
            <d:propstat>
                <d:prop><d:getcontentlength>1024</d:getcontentlength></d:prop>
                <d:status>HTTP/1.1 200 OK</d:status>
            </d:propstat>
        </d:response>"""
    return f"""<?xml version="1.0" encoding="UTF-8"?>
    <d:multistatus xmlns:d="DAV:">
        {responses}
    </d:multistatus>"""


@pytest.mark.unit
class TestScanUserNextcloudFolder:
    """Tests for _scan_user_nextcloud_folder per-user Nextcloud scanning."""

    def test_returns_zero_when_settings_incomplete(self):
        """Missing url, username, or password should return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder

        result = _scan_user_nextcloud_folder(
            cfg={"url": "", "folder_path": "/inbox"},
            creds={"username": "u", "password": "p"},
            cache={},
            delete_after=False,
            owner_id="u1",
        )
        assert result == 0

    def test_returns_zero_when_propfind_fails(self):
        """PROPFIND failure should return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder

        with patch("requests.request") as mock_request:
            mock_request.side_effect = Exception("Connection refused")

            result = _scan_user_nextcloud_folder(
                cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_xml_parse_fails(self):
        """Invalid XML response should return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder

        mock_resp = MagicMock()
        mock_resp.text = "not valid xml <<<"

        with patch("requests.request", return_value=mock_resp):
            result = _scan_user_nextcloud_folder(
                cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_downloads_and_enqueues_new_file(self, tmp_path):
        """Successful Nextcloud download should enqueue file and return 1."""
        from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder

        propfind_xml = _build_propfind_xml(
            [
                "/remote.php/dav/files/user/inbox/",
                "/remote.php/dav/files/user/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        mock_dl_resp = MagicMock()
        mock_dl_resp.content = b"%PDF-1.4"

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("requests.get", return_value=mock_dl_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_nextcloud_folder(
                cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 1
        mock_enqueue.assert_called_once()
        mock_mark.assert_called_once()

    def test_skips_folder_self_entry(self, tmp_path):
        """The folder itself (self entry) should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder

        propfind_xml = _build_propfind_xml(["/remote.php/dav/files/user/inbox/"])

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_nextcloud_folder(
                cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_skips_cached_file(self, tmp_path):
        """Files already in cache should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder

        propfind_xml = _build_propfind_xml(
            [
                "/remote.php/dav/files/user/inbox/",
                "/remote.php/dav/files/user/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_nextcloud_folder(
                cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={"nextcloud:/remote.php/dav/files/user/inbox/doc.pdf": "done"},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_deletes_after_download(self, tmp_path):
        """delete_after=True should send DELETE request."""
        from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder

        propfind_xml = _build_propfind_xml(
            [
                "/remote.php/dav/files/user/inbox/",
                "/remote.php/dav/files/user/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        mock_dl_resp = MagicMock()
        mock_dl_resp.content = b"%PDF-1.4"

        mock_del_resp = MagicMock()

        with (
            patch("requests.request", side_effect=[mock_propfind_resp, mock_del_resp]) as mock_request,
            patch("requests.get", return_value=mock_dl_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file"),
            patch("app.tasks.watch_folder_tasks._mark_processed"),
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_nextcloud_folder(
                cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=True,
                owner_id="u1",
            )

        assert count == 1
        assert mock_request.call_count == 2
        delete_call = mock_request.call_args_list[1]
        assert delete_call[0][0] == "DELETE"

    def test_handles_download_failure(self, tmp_path):
        """Download failure should skip the file and continue."""
        from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder

        propfind_xml = _build_propfind_xml(
            [
                "/remote.php/dav/files/user/inbox/",
                "/remote.php/dav/files/user/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("requests.get") as mock_get,
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            mock_get.side_effect = Exception("Download failed")

            count = _scan_user_nextcloud_folder(
                cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_handles_absolute_href_url(self, tmp_path):
        """Absolute href URLs should be used directly for download."""
        from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder

        propfind_xml = _build_propfind_xml(
            [
                "/remote.php/dav/files/user/inbox/",
                "https://nc.example.com/remote.php/dav/files/user/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        mock_dl_resp = MagicMock()
        mock_dl_resp.content = b"%PDF-1.4"

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("requests.get", return_value=mock_dl_resp) as mock_get,
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._mark_processed"),
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_nextcloud_folder(
                cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 1
        mock_enqueue.assert_called_once()
        download_url = mock_get.call_args[0][0]
        assert download_url.startswith("https://")


@pytest.mark.unit
class TestScanUserWebdavFolder:
    """Tests for _scan_user_webdav_folder per-user WebDAV scanning."""

    def test_returns_zero_when_url_not_configured(self):
        """Missing URL should return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_webdav_folder

        result = _scan_user_webdav_folder(
            cfg={"url": "", "folder_path": "/inbox"},
            creds={"username": "u", "password": "p"},
            cache={},
            delete_after=False,
            owner_id="u1",
        )
        assert result == 0

    def test_returns_zero_when_propfind_fails(self):
        """PROPFIND failure should return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_webdav_folder

        with patch("requests.request") as mock_request:
            mock_request.side_effect = Exception("Connection refused")

            result = _scan_user_webdav_folder(
                cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_returns_zero_when_xml_parse_fails(self):
        """Invalid XML response should return 0."""
        from app.tasks.watch_folder_tasks import _scan_user_webdav_folder

        mock_resp = MagicMock()
        mock_resp.text = "not valid xml <<<"

        with patch("requests.request", return_value=mock_resp):
            result = _scan_user_webdav_folder(
                cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )
        assert result == 0

    def test_downloads_and_enqueues_new_file(self, tmp_path):
        """Successful WebDAV download should enqueue file and return 1."""
        from app.tasks.watch_folder_tasks import _scan_user_webdav_folder

        propfind_xml = _build_propfind_xml(
            [
                "/inbox/",
                "/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        mock_dl_resp = MagicMock()
        mock_dl_resp.content = b"%PDF-1.4"

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("requests.get", return_value=mock_dl_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_webdav_folder(
                cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 1
        mock_enqueue.assert_called_once()
        mock_mark.assert_called_once()

    def test_skips_directory_entries(self, tmp_path):
        """Entries ending with / should be skipped (directories)."""
        from app.tasks.watch_folder_tasks import _scan_user_webdav_folder

        propfind_xml = _build_propfind_xml(
            [
                "/inbox/",
                "/inbox/subdir/",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_webdav_folder(
                cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_skips_cached_file(self, tmp_path):
        """Files already in cache should be skipped."""
        from app.tasks.watch_folder_tasks import _scan_user_webdav_folder

        propfind_xml = _build_propfind_xml(
            [
                "/inbox/",
                "/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_webdav_folder(
                cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={"webdav:/inbox/doc.pdf": "done"},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_deletes_after_download(self, tmp_path):
        """delete_after=True should send DELETE request."""
        from app.tasks.watch_folder_tasks import _scan_user_webdav_folder

        propfind_xml = _build_propfind_xml(
            [
                "/inbox/",
                "/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        mock_dl_resp = MagicMock()
        mock_dl_resp.content = b"%PDF-1.4"

        mock_del_resp = MagicMock()

        with (
            patch("requests.request", side_effect=[mock_propfind_resp, mock_del_resp]) as mock_request,
            patch("requests.get", return_value=mock_dl_resp),
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file"),
            patch("app.tasks.watch_folder_tasks._mark_processed"),
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_webdav_folder(
                cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=True,
                owner_id="u1",
            )

        assert count == 1
        assert mock_request.call_count == 2
        delete_call = mock_request.call_args_list[1]
        assert delete_call[0][0] == "DELETE"

    def test_handles_download_failure(self, tmp_path):
        """Download failure should skip the file and continue."""
        from app.tasks.watch_folder_tasks import _scan_user_webdav_folder

        propfind_xml = _build_propfind_xml(
            [
                "/inbox/",
                "/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("requests.get") as mock_get,
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30
            mock_get.side_effect = Exception("Download failed")

            count = _scan_user_webdav_folder(
                cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 0
        mock_enqueue.assert_not_called()

    def test_handles_absolute_href_url(self, tmp_path):
        """Absolute href URLs should be used directly for download."""
        from app.tasks.watch_folder_tasks import _scan_user_webdav_folder

        propfind_xml = _build_propfind_xml(
            [
                "/inbox/",
                "https://dav.example.com/inbox/doc.pdf",
            ]
        )

        mock_propfind_resp = MagicMock()
        mock_propfind_resp.text = propfind_xml

        mock_dl_resp = MagicMock()
        mock_dl_resp.content = b"%PDF-1.4"

        with (
            patch("requests.request", return_value=mock_propfind_resp),
            patch("requests.get", return_value=mock_dl_resp) as mock_get,
            patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
            patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
            patch("app.tasks.watch_folder_tasks._mark_processed"),
            patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.http_request_timeout = 30

            count = _scan_user_webdav_folder(
                cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
                creds={"username": "u", "password": "p"},
                cache={},
                delete_after=False,
                owner_id="u1",
            )

        assert count == 1
        mock_enqueue.assert_called_once()
        download_url = mock_get.call_args[0][0]
        assert download_url == "https://dav.example.com/inbox/doc.pdf"


@pytest.mark.unit
class TestScanAllWatchFoldersIncludesUserIntegrations:
    """Test that scan_all_watch_folders calls _pull_user_integration_watch_folders."""

    def test_includes_user_watch_folders(self):
        from app.tasks.watch_folder_tasks import scan_all_watch_folders

        with (
            patch("app.tasks.watch_folder_tasks._acquire_lock", return_value=True),
            patch("app.tasks.watch_folder_tasks._release_lock"),
            patch("app.tasks.watch_folder_tasks.scan_local_watch_folders", return_value={"status": "ok"}),
            patch("app.tasks.watch_folder_tasks.scan_ftp_watch_folder", return_value={"status": "skipped"}),
            patch("app.tasks.watch_folder_tasks.scan_sftp_watch_folder", return_value={"status": "skipped"}),
            patch("app.tasks.watch_folder_tasks.scan_dropbox_watch_folder", return_value={"status": "skipped"}),
            patch("app.tasks.watch_folder_tasks.scan_google_drive_watch_folder", return_value={"status": "skipped"}),
            patch("app.tasks.watch_folder_tasks.scan_onedrive_watch_folder", return_value={"status": "skipped"}),
            patch("app.tasks.watch_folder_tasks.scan_nextcloud_watch_folder", return_value={"status": "skipped"}),
            patch("app.tasks.watch_folder_tasks.scan_s3_watch_folder", return_value={"status": "skipped"}),
            patch("app.tasks.watch_folder_tasks.scan_webdav_watch_folder", return_value={"status": "skipped"}),
            patch(
                "app.tasks.watch_folder_tasks._pull_user_integration_watch_folders",
                return_value={"status": "ok", "integrations_processed": 1, "files_enqueued": 3},
            ) as mock_pull,
        ):
            result = scan_all_watch_folders()

        mock_pull.assert_called_once()
        assert result["results"]["user_watch_folders"]["files_enqueued"] == 3
