"""Generic Shipper class."""

from __future__ import annotations

import email
import logging
from email.header import decode_header
from pathlib import Path
from shutil import copyfile
from typing import Any

import anyio
from aioimaplib import IMAP4_SSL

from custom_components.mail_and_packages.const import (
    AMAZON_DELIEVERED_BY_OTHERS_SEARCH_TEXT,
    AMAZON_DELIVERED,
    ATTR_BODY,
    ATTR_BODY_COUNT,
    ATTR_COUNT,
    ATTR_EMAIL,
    ATTR_PATTERN,
    ATTR_SUBJECT,
    ATTR_TRACKING,
    CAMERA_DATA,
    CAMERA_EXTRACTION_CONFIG,
    CONF_FORWARDING_HEADER,
    SENSOR_DATA,
)
from custom_components.mail_and_packages.utils.cache import EmailCache
from custom_components.mail_and_packages.utils.email import find_text, find_text_matches
from custom_components.mail_and_packages.utils.imap import (
    email_fetch,
    email_fetch_headers,
    email_search,
)
from custom_components.mail_and_packages.utils.shipper import (
    generic_delivery_image_extraction,
    get_tracking,
)

from .base import Shipper

_LOGGER = logging.getLogger(__name__)


class GenericShipper(Shipper):
    """Generic Shipper class for UPS, FedEx, Walmart, etc."""

    @property
    def name(self) -> str:
        """Return the internal name of the shipper."""
        return "generic"

    @classmethod
    def handles_sensor(cls, sensor_type: str) -> bool:
        """Return True if this shipper handles the given sensor type."""
        return sensor_type in SENSOR_DATA

    async def process(
        self,
        account: IMAP4_SSL,
        date: str,
        sensor_type: str,
        cache: EmailCache | None = None,
        since_date: str | None = None,
    ) -> dict[str, Any]:
        """Process emails for this shipper on the given date.

        since_date: if provided, used instead of date for _delivering and
        _exception sensors so that emails from previous days are included.
        """
        _LOGGER.debug("Processing generic sensor: %s", sensor_type)

        if sensor_type not in SENSOR_DATA:
            _LOGGER.error("Sensor %s not found in SENSOR_DATA", sensor_type)
            return {ATTR_COUNT: 0}

        config = SENSOR_DATA[sensor_type]
        email_addresses = config.get(ATTR_EMAIL, [])
        subjects = config.get(ATTR_SUBJECT, [])

        # _packages sensors with no email/subject are computed in process_batch
        # as delivering + delivered; skip IMAP search here.
        if sensor_type.endswith("_packages") and not email_addresses and not subjects:
            _LOGGER.debug(
                "Skipping email search for %s: no email addresses configured",
                sensor_type,
            )
            return {ATTR_COUNT: 0, ATTR_TRACKING: []}

        forwarding_header, email_addresses = self._resolve_forwarding(email_addresses)

        # _delivering/_exception/_packages use the extended window so in-transit
        # packages remain visible across the midnight boundary.
        # _delivered uses today's date for the sensor count (resets at midnight)
        # but also searches the extended window to obtain tracking numbers for
        # deduplication — without those, a package delivered yesterday would still
        # appear as "delivering" today because the delivering email is in the window
        # but the delivered email is not.
        is_delivered = sensor_type.endswith("_delivered")
        search_date = date
        if (
            since_date
            and sensor_type.endswith(
                ("_delivering", "_exception", "_delivered", "_packages")
            )
            and sensor_type != "post_de_delivering"
        ):
            search_date = since_date

        result = {ATTR_COUNT: 0, ATTR_TRACKING: []}

        # Skip email search for sensors with no email addresses configured
        # (e.g. *_packages sensors that are empty dicts in SENSOR_DATA)
        if not email_addresses:
            _LOGGER.debug(
                "Skipping email search for %s: no email addresses configured",
                sensor_type,
            )
            return result

        image_path = self.config.get("image_path")
        # Setup image extraction
        shipper_cfg = await self._setup_image_extraction(sensor_type, image_path)
        image_found = False

        count, found_data, image_found = await self._search_for_emails(
            account,
            email_addresses,
            search_date,
            subjects,
            config,
            shipper_cfg,
            sensor_type,
            result,
            cache,
            forwarding_header,
        )

        # Process tracking numbers
        result[ATTR_TRACKING] = await self._process_tracking_numbers(
            sensor_type,
            found_data,
            account,
            cache,
        )
        if result[ATTR_TRACKING]:
            count = len(result[ATTR_TRACKING])

        if is_delivered:
            result["pre_filtered_tracking"] = result.get(ATTR_TRACKING, [])

        # For _delivered sensors, the extended-window search gives us tracking
        # numbers needed for deduplication (above), but the count must reflect
        # only today's deliveries so the sensor resets at midnight.
        if is_delivered and since_date and search_date != date:
            today_result: dict[str, Any] = {ATTR_COUNT: 0, ATTR_TRACKING: []}
            today_count, today_found, _ = await self._search_for_emails(
                account,
                email_addresses,
                date,
                subjects,
                config,
                shipper_cfg,
                sensor_type,
                today_result,
                cache,
                forwarding_header,
            )
            today_tracking = await self._process_tracking_numbers(
                sensor_type, today_found, account, cache
            )
            count = len(today_tracking) if today_tracking else today_count
            result[ATTR_TRACKING] = today_tracking

        result[ATTR_COUNT] = count
        if shipper_cfg:
            image_attr = f"{shipper_cfg['name']}_image"
            result[image_attr] = shipper_cfg["image_name"]
            result["image_path"] = image_path

            if not image_found:
                await self._copy_generic_placeholder(shipper_cfg)

        return result

    def _resolve_forwarding(self, email_addresses: list[str]) -> tuple[str, list[str]]:
        """Return (forwarding_header, resolved_email_addresses).

        Header mode: uses original-sender header for matching; address list
        is passed as-is so IMAP can match via HEADER substring.
        Address-list mode: prepends the user's forwarded addresses so that
        emails arriving through a forwarding service are also matched.
        """
        forwarding_header = self.config.get(CONF_FORWARDING_HEADER, "")
        if forwarding_header and forwarding_header != "(none)":
            return forwarding_header, email_addresses
        forwarding_header = ""
        forwarded_emails = self.config.get("forwarded_emails", [])
        if isinstance(forwarded_emails, str):
            forwarded_emails = [
                e.strip() for e in forwarded_emails.split(",") if e.strip()
            ]
        if forwarded_emails:
            email_addresses = forwarded_emails + email_addresses
        return forwarding_header, email_addresses

    async def process_batch(
        self,
        account: IMAP4_SSL,
        date: str,
        sensors: list[str],
        cache: EmailCache,
        since_date: str | None = None,
    ) -> dict[str, Any]:
        """Process multiple generic sensors in batch."""
        batch_results, all_tracking = await self._process_individual_sensors(
            account, date, sensors, cache, since_date
        )

        self._deduplicate_batch_tracking(batch_results)
        self._compute_package_totals(batch_results)

        # Merge results and aggregate global tracking
        res = {}
        for sensor, sensor_res in batch_results:
            tracking = (
                sensor_res.pop("pre_filtered_tracking", [])
                if sensor.endswith("_delivered")
                else sensor_res.get(ATTR_TRACKING)
            )
            res.update(sensor_res)
            # Expose per-sensor raw tracking for coordinator state management.
            # Keyed as "_tracking_details" to distinguish from the public data dict.
            if tracking and sensor.endswith(
                ("_delivering", "_delivered", "_exception")
            ):
                res.setdefault("_tracking_details", {})[sensor] = list(tracking)

        if all_tracking:
            res[ATTR_TRACKING] = list(all_tracking)

        return res

    async def _process_individual_sensors(
        self,
        account: IMAP4_SSL,
        date: str,
        sensors: list[str],
        cache: EmailCache,
        since_date: str | None = None,
    ) -> tuple[list[tuple[str, dict[str, Any]]], set[str]]:
        """Process each sensor independently and aggregate tracking."""
        batch_results = []
        all_tracking = set()

        for sensor in sensors:
            sensor_res = await self.process(
                account, date, sensor, cache, since_date=since_date
            )
            # Replicate coordinator dictionary logic for local sensor counts
            if sensor not in sensor_res and ATTR_COUNT in sensor_res:
                sensor_res[sensor] = sensor_res[ATTR_COUNT]

            # Capture today-only tracking for _delivered sensors BEFORE
            # _deduplicate_batch_tracking runs (which currently only modifies
            # _delivering and _packages sensor results).
            if sensor_res.get(ATTR_TRACKING) and sensor.endswith("_delivered"):
                sensor_res[f"{sensor}_tracking"] = sensor_res[ATTR_TRACKING]

            # Record results for post-processing
            batch_results.append((sensor, sensor_res))

            # Aggregate all tracking numbers found
            if sensor_res.get(ATTR_TRACKING):
                all_tracking.update(sensor_res[ATTR_TRACKING])

        return batch_results, all_tracking

    def _deduplicate_batch_tracking(
        self,
        batch_results: list[tuple[str, dict[str, Any]]],
    ) -> None:
        """Deduplicate tracking numbers across sensors based on shipper prefix."""
        shippers = {}
        for sensor, sensor_res in batch_results:
            # Prefix is everything before the last underscore (e.g., 'ups', 'fedex')
            prefix = "_".join(sensor.split("_")[:-1])
            if prefix not in shippers:
                shippers[prefix] = {
                    "delivered": set(),
                    "delivering": set(),
                    "update_targets": [],
                    "package_targets": [],
                }

            tracking = set(sensor_res.get(ATTR_TRACKING, []))
            if sensor.endswith("_delivered"):
                # ATTR_TRACKING on _delivered sensors holds only TODAY's
                # deliveries (so the sensor resets at midnight); dedup must
                # use the extended-window list or packages delivered on a
                # previous day are never subtracted from _delivering.
                extended = sensor_res.get("pre_filtered_tracking")
                shippers[prefix]["delivered"].update(
                    tracking if extended is None else set(extended)
                )
            elif sensor.endswith(("_delivering", "_exception")):
                shippers[prefix]["delivering"].update(tracking)
                shippers[prefix]["update_targets"].append((sensor, sensor_res))
            elif sensor.endswith("_packages"):
                shippers[prefix]["package_targets"].append((sensor, sensor_res))

        for data in shippers.values():
            # Remove "delivered" tracking numbers from in-transit sensors
            self._apply_deduplication(data["update_targets"], data["delivered"])
            # Remove "delivering" and "delivered" tracking numbers from _packages
            # so _packages only shows packages not yet out for delivery or delivered
            in_pipeline = data["delivering"] | data["delivered"]
            self._apply_deduplication(data["package_targets"], in_pipeline)

    def _apply_deduplication(
        self,
        targets: list[tuple[str, dict[str, Any]]],
        delivered_ids: set[str],
    ) -> None:
        """Apply deduplication logic to a list of target sensors."""
        if not delivered_ids:
            return

        for sensor, sensor_res in targets:
            original_tracking = sensor_res.get(ATTR_TRACKING, [])
            new_tracking = [
                tid for tid in original_tracking if tid not in delivered_ids
            ]

            if len(new_tracking) != len(original_tracking):
                sensor_res[ATTR_TRACKING] = new_tracking
                sensor_res[sensor] = len(new_tracking)
                if ATTR_COUNT in sensor_res:
                    sensor_res[ATTR_COUNT] = len(new_tracking)

    def _compute_package_totals(
        self,
        batch_results: list[tuple[str, dict[str, Any]]],
    ) -> None:
        """Compute _packages sensors with empty config as delivering + delivered.

        These sensors have no IMAP search of their own; their value is the
        sum of the shipper's _delivering and _delivered counts (matching the
        original pre-refactor behaviour in helpers.py).
        """
        sensor_counts = {
            sensor: sensor_res.get(sensor, sensor_res.get(ATTR_COUNT, 0))
            for sensor, sensor_res in batch_results
        }

        for sensor, sensor_res in batch_results:
            if not sensor.endswith("_packages"):
                continue
            config = SENSOR_DATA.get(sensor, {})
            if config.get(ATTR_EMAIL) or config.get(ATTR_SUBJECT):
                continue  # sensor has its own IMAP search config
            prefix = sensor.replace("_packages", "")
            computed = sensor_counts.get(f"{prefix}_delivering", 0) + sensor_counts.get(
                f"{prefix}_delivered", 0
            )
            sensor_res[sensor] = computed
            sensor_res[ATTR_COUNT] = computed

    async def _copy_generic_placeholder(self, shipper_cfg: dict[str, Any]) -> None:
        """Copy the generic placeholder for the shipper."""
        shipper_name = shipper_cfg["name"]
        # Try to find courier-specific placeholder
        placeholder = Path(__file__).parent.parent / f"no_deliveries_{shipper_name}.jpg"
        if not await anyio.Path(placeholder).exists():
            placeholder = Path(__file__).parent.parent / "mail_none.gif"

        target = (
            Path(shipper_cfg["image_path"]) / shipper_name / shipper_cfg["image_name"]
        )
        _LOGGER.debug(
            "No %s images found in emails, using placeholder: %s",
            shipper_name,
            placeholder.name,
        )
        try:
            await self.hass.async_add_executor_job(
                copyfile, str(placeholder), str(target)
            )
        except OSError as err:
            _LOGGER.error("Error attempting to copy placeholder: %s", err)

    async def _search_for_emails(
        self,
        account: IMAP4_SSL,
        email_addresses: list[str],
        date: str,
        subjects: list[str],
        config: dict[str, Any],
        shipper_cfg: dict[str, Any] | None,
        sensor_type: str,
        result: dict[str, Any],
        cache: EmailCache | None = None,
        forwarding_header: str = "",
    ) -> tuple[int, list[bytes], bool]:
        """Search for and process emails."""
        count = 0
        unique_email_ids = set()
        found_data = []
        image_found = False

        (server_response, sdata) = await email_search(
            account=account,
            address=email_addresses,
            date=date,
            subject=subjects,
            body=config.get(ATTR_BODY, ""),
            header=forwarding_header,
        )

        if server_response == "OK" and sdata[0]:
            raw_ids = sdata[0].split()
            _LOGGER.debug(
                "Found %d matching email IDs for %s: %s",
                len(raw_ids),
                sensor_type,
                [eid.decode() if isinstance(eid, bytes) else eid for eid in raw_ids],
            )
            verified_ids = await self._verify_matched_subjects(
                account, raw_ids, sensor_type, subjects, cache
            )
            filtered_new_ids = self._filter_unique_ids(verified_ids, unique_email_ids)

            if filtered_new_ids:
                count, img_found = await self._process_matched_emails(
                    account,
                    config,
                    filtered_new_ids,
                    count,
                    cache,
                    shipper_cfg,
                    sensor_type,
                    result,
                    found_data,
                )
                if img_found:
                    image_found = True

        return count, found_data, image_found

    def _decode_subject(self, header_part: bytes | bytearray) -> str | None:
        """Decode MIME encoded subject from email header part."""
        msg = email.message_from_bytes(header_part)
        header_val = msg.get("subject")
        if not header_val:
            return None

        decoded = decode_header(header_val)[0]
        subject_bytes, encoding = decoded
        if encoding:
            try:
                if isinstance(subject_bytes, bytes):
                    return subject_bytes.decode(encoding, "ignore").strip()
                return str(subject_bytes).strip()
            except (LookupError, UnicodeError):
                pass

        if isinstance(subject_bytes, bytes):
            return subject_bytes.decode("utf-8", "ignore").strip()
        return str(subject_bytes).strip()

    async def _verify_matched_subjects(
        self,
        account: IMAP4_SSL,
        email_ids: list[bytes],
        sensor_type: str,
        expected_subjects: list[str],
        cache: EmailCache | None = None,
    ) -> list[bytes]:
        """Verify the subject of each matched email locally and log for debugging."""
        if not expected_subjects:
            return email_ids

        verified_ids = []
        expected_subjects_lower = [s.lower() for s in expected_subjects]

        for eid in email_ids:
            try:
                if cache:
                    header_data = (
                        await cache.fetch(eid, "(BODY[HEADER.FIELDS (SUBJECT)])")
                    )[1]
                else:
                    header_data = (await email_fetch_headers(account, eid))[1]

                subject_found = False
                for part in header_data:
                    if isinstance(part, (bytes, bytearray)):
                        subject = self._decode_subject(part)
                        if not subject:
                            continue

                        _LOGGER.debug(
                            "Matched email for %s (ID %s): %s",
                            sensor_type,
                            eid.decode() if isinstance(eid, bytes) else eid,
                            subject,
                        )
                        subject_lower = subject.lower()
                        if any(
                            expected in subject_lower
                            for expected in expected_subjects_lower
                        ):
                            subject_found = True

                if subject_found:
                    verified_ids.append(eid)
                else:
                    _LOGGER.debug(
                        "Email ID %s rejected for %s: Subject did not match any expected subjects.",
                        eid.decode() if isinstance(eid, bytes) else eid,
                        sensor_type,
                    )
            except (OSError, AttributeError) as err:
                _LOGGER.debug("Could not fetch subject for email %s: %s", eid, err)

        return verified_ids

    def _filter_unique_ids(
        self, email_ids: list[bytes], unique_email_ids: set
    ) -> list[bytes]:
        """Filter out already processed email IDs."""
        new_ids = []
        for eid in email_ids:
            eid_str = eid.decode() if isinstance(eid, bytes) else str(eid)
            if eid_str not in unique_email_ids:
                unique_email_ids.add(eid_str)
                new_ids.append(eid)
        return new_ids

    async def _process_matched_emails(
        self,
        account: IMAP4_SSL,
        config: dict[str, Any],
        new_ids: list[bytes],
        current_count: int,
        cache: EmailCache | None,
        shipper_cfg: dict[str, Any] | None,
        sensor_type: str,
        result: dict[str, Any],
        found_data: list[bytes],
    ) -> tuple[int, bool]:
        """Process a batch of matched unique emails."""
        image_found = False
        count, matched_ids = await self._process_emails_by_type(
            account, config, new_ids, current_count, cache
        )
        if matched_ids:
            found_data.append(b" ".join(matched_ids))

            if shipper_cfg:
                if await self._extract_images_for_shipper(
                    account, matched_ids, shipper_cfg, cache
                ):
                    image_found = True

            if sensor_type.endswith("_delivered") and sensor_type != AMAZON_DELIVERED:
                await self._check_amazon_mentions(account, matched_ids, result, cache)

        return count, image_found

    async def _process_tracking_numbers(
        self,
        sensor_type: str,
        found_data: list,
        account: IMAP4_SSL,
        cache: EmailCache | None = None,
    ) -> list:
        """Process tracking numbers for the sensor."""
        tracking_key = f"{'_'.join(sensor_type.split('_')[:-1])}_tracking"
        if (
            tracking_key not in SENSOR_DATA
            or ATTR_PATTERN not in SENSOR_DATA[tracking_key]
        ):
            return []

        pattern = SENSOR_DATA[tracking_key][ATTR_PATTERN][0]
        tracking_nums = []
        for sdata in found_data:
            tracking_nums.extend(
                await get_tracking(sdata.decode(), account, pattern, cache)
            )

        return list(dict.fromkeys(tracking_nums))

    async def _setup_image_extraction(
        self,
        sensor_type: str,
        image_path: str,
    ) -> dict | None:
        """Set up image extraction configuration."""
        if not sensor_type.endswith("_delivered"):
            return None

        shipper_name = sensor_type.replace("_delivered", "")
        camera_key = f"{shipper_name}_camera"
        if camera_key not in CAMERA_DATA or camera_key in (
            "usps_camera",
            "generic_camera",
        ):
            return None

        extraction_config = CAMERA_EXTRACTION_CONFIG.get(shipper_name, {})
        absolute_image_path = image_path.rstrip("/") + "/"

        def _create_dir():
            path = Path(absolute_image_path) / shipper_name
            if not path.exists():
                path.mkdir(parents=True, exist_ok=True)

        await self.hass.async_add_executor_job(_create_dir)

        return {
            "name": shipper_name,
            "image_path": absolute_image_path,
            "image_name": self.config.get(f"{shipper_name}_image")
            or f"{shipper_name}_delivery.jpg",
            "image_type": extraction_config.get("image_type", "jpeg"),
            "cid_name": extraction_config.get("cid_name"),
            "pattern": extraction_config.get("attachment_filename_pattern"),
        }

    async def _process_emails_by_type(
        self,
        account: IMAP4_SSL,
        config: dict,
        ids: list,
        current_count: int,
        cache: EmailCache | None = None,
    ) -> tuple[int, list]:
        """Process emails based on body search or just count."""
        if ATTR_BODY in config:
            body_count = config.get(ATTR_BODY_COUNT, False)
            mock_data = (b" ".join(ids),)
            count, matched_ids = await find_text_matches(
                mock_data,
                account,
                config[ATTR_BODY],
                body_count,
                cache,
            )
            return current_count + count, matched_ids
        return current_count + len(ids), list(ids)

    async def _extract_images_for_shipper(
        self,
        account: IMAP4_SSL,
        ids: list,
        s_config: dict,
        cache: EmailCache | None = None,
    ) -> bool:
        """Extract delivery images from emails."""
        image_found = False
        for eid in ids:
            if cache:
                msg_parts = (await cache.fetch(eid, "(RFC822)"))[1]
            else:
                msg_parts = (await email_fetch(account, eid, "(RFC822)"))[1]
            for response_part in msg_parts:
                if isinstance(response_part, (bytes, bytearray)):
                    # The extraction does blocking file I/O (the image
                    # write) and CPU-heavy email parsing — run the whole
                    # sync function off the event loop.
                    if await self.hass.async_add_executor_job(
                        generic_delivery_image_extraction,
                        response_part,
                        s_config["image_path"],
                        s_config["image_name"],
                        s_config["name"],
                        s_config["image_type"],
                        s_config["cid_name"],
                        s_config["pattern"],
                    ):
                        _LOGGER.debug("Extracted image for %s", s_config["name"])
                        image_found = True
        return image_found

    async def _check_amazon_mentions(
        self,
        account: IMAP4_SSL,
        ids: list,
        result: dict,
        cache: EmailCache | None = None,
    ):
        """Check for Amazon mentions in emails."""
        mock_data = (b" ".join(ids),)
        amazon_mentions = await find_text(
            mock_data,
            account,
            AMAZON_DELIEVERED_BY_OTHERS_SEARCH_TEXT,
            False,
            cache,
        )
        if amazon_mentions > 0:
            result["amazon_delivered_by_others"] = (
                result.get("amazon_delivered_by_others", 0) + amazon_mentions
            )
