# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

# You must generate a "Long-Lived Access Token". This can be done from your
# Home Assistant Profile page.
from itertools import chain
from json import dumps
import math
import re
from uuid import uuid4

import requests

from ..common import NotifyType
from ..locale import gettext_lazy as _
from ..url import PrivacyMode
from ..utils.parse import (
    is_domain_service_target,
    parse_bool,
    parse_domain_service_targets,
    validate_regex,
)
from .base import NotifyBase

# This regex matches exactly 8 hex digits,
# a dot, then exactly 64 hex digits. it can also be a JWT
# token in which case it will be 180 characters+
RE_IS_LONG_LIVED_TOKEN = re.compile(
    r"^([0-9a-f]{8}\.[0-9a-f]{64}|[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+)$",
    re.I,
)

# Define our supported device notification formats:
# - service
#     - default domain is always 'notify' if one isn't detected
# - service:target
# - service:target1,target2,target3
# - domain.service
# - domain.service:target
# - domain.service:target1,target2,target3
# -   - targets can be comma/space separated if more hten one
# - service:target1,target2,target3

# Define a persistent entry (used for handling message delivery
PERSISTENT_ENTRY = (None, None, [])


class NotifyHomeAssistant(NotifyBase):
    """
    A wrapper for Home Assistant Notifications
    """

    # The default descriptive name associated with the Notification
    service_name = "HomeAssistant"

    # The services URL
    service_url = "https://www.home-assistant.io/"

    # Insecure Protocol Access
    protocol = "hassio"

    # Secure Protocol
    secure_protocol = "hassios"

    # Default to Home Assistant Default Insecure port of 8123 instead of 80
    default_insecure_port = 8123

    # The maximum amount of services that can be notified in a single batch
    default_batch_size = 10

    # The default ha notification domain if one isn't detected
    default_domain = "notify"

    # A URL that takes you to the setup/help of the specific protocol
    setup_url = "https://appriseit.com/services/homeassistant/"

    # Define object templates
    templates = (
        "{schema}://{host}/{accesstoken}",
        "{schema}://{host}/{accesstoken}/{targets}",
        "{schema}://{host}:{port}/{accesstoken}",
        "{schema}://{host}:{port}/{accesstoken}/{targets}",
        "{schema}://{user}@{host}/{accesstoken}",
        "{schema}://{user}@{host}/{accesstoken}/{targets}",
        "{schema}://{user}@{host}:{port}/{accesstoken}",
        "{schema}://{user}@{host}:{port}/{accesstoken}/{targets}",
        "{schema}://{user}:{password}@{host}/{accesstoken}",
        "{schema}://{user}:{password}@{host}/{accesstoken}/{targets}",
        "{schema}://{user}:{password}@{host}:{port}/{accesstoken}",
        "{schema}://{user}:{password}@{host}:{port}/{accesstoken}/{targets}",
    )

    # Define our template tokens
    template_tokens = dict(
        NotifyBase.template_tokens,
        **{
            "host": {
                "name": _("Hostname"),
                "type": "string",
                "required": True,
            },
            "port": {
                "name": _("Port"),
                "type": "int",
                "min": 1,
                "max": 65535,
            },
            "user": {
                "name": _("Username"),
                "type": "string",
            },
            "password": {
                "name": _("Password"),
                "type": "string",
                "private": True,
            },
            "accesstoken": {
                "name": _("Long-Lived Access Token"),
                "type": "string",
                "private": True,
                "required": True,
            },
            "target_device": {
                "name": _("Target Device"),
                "type": "string",
                "map_to": "targets",
            },
            "targets": {
                "name": _("Targets"),
                "type": "list:string",
            },
        },
    )

    # Define our template arguments
    template_args = dict(
        NotifyBase.template_args,
        **{
            "nid": {
                # Optional Unique Notification ID
                "name": _("Notification ID"),
                "type": "string",
                "regex": (r"^[a-z0-9_-]+$", "i"),
            },
            "batch": {
                "name": _("Batch Mode"),
                "type": "bool",
                "default": False,
            },
            "to": {
                "alias_of": "targets",
            },
            "token": {
                # Shorthand alias for accesstoken in query string
                "alias_of": "accesstoken",
            },
            "prefix": {
                "name": _("Path Prefix"),
                "type": "string",
            },
        },
    )

    def __init__(
        self,
        accesstoken,
        nid=None,
        targets=None,
        batch=None,
        prefix=None,
        **kwargs,
    ):
        """Initialize Home Assistant Object."""
        super().__init__(**kwargs)

        self.fullpath = kwargs.get("fullpath", "")
        self.prefix = prefix if prefix else ""

        if not (self.secure or self.port):
            # Use default insecure port
            self.port = self.default_insecure_port

        # Long-Lived Access token (generated from User Profile)
        self.accesstoken = validate_regex(accesstoken)
        if not self.accesstoken:
            msg = (
                "An invalid Home Assistant Long-Lived Access Token "
                f"({accesstoken}) was specified."
            )
            self.logger.warning(msg)
            raise TypeError(msg)

        # An Optional Notification Identifier
        self.nid = None
        if nid:
            self.nid = validate_regex(nid, *self.template_args["nid"]["regex"])
            if not self.nid:
                msg = (
                    "An invalid Home Assistant Notification Identifier "
                    f"({nid}) was specified."
                )
                self.logger.warning(msg)
                raise TypeError(msg)

        # Prepare Batch Mode Flag
        self.batch = (
            self.template_args["batch"]["default"] if batch is None else batch
        )

        # Store our targets
        self.targets = []

        # Track our invalid targets
        self._invalid_targets = []

        if targets:
            for target in parse_domain_service_targets(targets):
                result = is_domain_service_target(
                    target, domain=self.default_domain
                )
                if result:
                    self.targets.append(
                        (
                            result["domain"],
                            result["service"],
                            result["targets"],
                        )
                    )
                    continue

                self.logger.warning(
                    "Dropped invalid [domain.]service[:target] entry "
                    "({}) specified.".format(target),
                )
                self._invalid_targets.append(target)
        else:
            self.targets = [PERSISTENT_ENTRY]

        return

    def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):
        """Sends Message."""

        # Base payload; notification_id is only added for persistent
        # notification calls — other service domains reject it.
        payload = {
            "title": title,
            "message": body,
        }

        # Prepare our headers
        headers = {
            "User-Agent": self.app_id,
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.accesstoken}",
        }

        auth = None
        if self.user:
            auth = (self.user, self.password)

        # Set our schema
        schema = "https" if self.secure else "http"

        url = f"{schema}://{self.host}"
        if isinstance(self.port, int):
            url += f":{self.port}"

        # Determine if we're doing it the old way (using persistent notices)
        # or the new (supporting device targets)
        has_targets = bool(
            not self.targets or self.targets[0] is not PERSISTENT_ENTRY
        )

        if has_targets and not self.targets:
            self.logger.warning(
                "No valid Home Assistant service targets to notify."
            )
            return False

        base_url = url + (
            self.fullpath.rstrip("/")
            + "/api/services/persistent_notification/create"
        )

        # Send in batches if identified to do so
        batch_size = 1 if not self.batch else self.default_batch_size

        for target in self.targets:
            # Use a unique ID so we don't over-write the last message we
            # posted. Otherwise use the notification id specified
            if has_targets:
                # Base target details
                domain = target[0]
                service = target[1]

                # Prepare our URL
                base_url = (
                    url
                    + self.prefix.rstrip("/")
                    + f"/api/services/{domain}/{service}"
                )

                # Possibly prepare batches
                if target[2]:
                    _payload = payload.copy()
                    for index in range(0, len(target[2]), batch_size):
                        _payload["targets"] = target[2][
                            index : index + batch_size
                        ]
                        if not self._ha_post(
                            base_url, _payload, headers, auth
                        ):
                            return False

                    continue

            if not self._ha_post(
                base_url,
                payload,
                headers,
                auth,
                persistent=not has_targets,
            ):
                return False

        return True

    def _ha_post(self, url, payload, headers, auth=None, persistent=False):
        """
        Wrapper to single upstream server post
        """
        # notification_id is only meaningful for persistent_notification;
        # other HA service domains reject it with a 400.
        if persistent:
            payload["notification_id"] = self.nid if self.nid else str(uuid4())

        self.logger.debug(
            "Home Assistant POST URL: {} (cert_verify={!r})".format(
                url, self.verify_certificate
            )
        )
        self.logger.debug("Home Assistant Payload: {}".format(str(payload)))

        # Always call throttle before any remote server i/o is made
        self.throttle()
        try:
            r = requests.post(
                url,
                data=dumps(payload),
                headers=headers,
                auth=auth,
                verify=self.verify_certificate,
                timeout=self.request_timeout,
            )
            if r.status_code != requests.codes.ok:
                # We had a problem
                status_str = NotifyHomeAssistant.http_response_code_lookup(
                    r.status_code
                )

                self.logger.warning(
                    "Failed to send Home Assistant notification: "
                    "{}{}error={}.".format(
                        status_str, ", " if status_str else "", r.status_code
                    )
                )

                self.logger.debug(
                    "Response Details:\r\n%r", (r.content or b"")[:2000]
                )

                # Return; we're done
                return False

            else:
                self.logger.info("Sent Home Assistant notification.")

        except requests.RequestException as e:
            self.logger.warning(
                "A Connection error occurred sending Home Assistant "
                f"notification to {self.host}."
            )
            self.logger.debug(f"Socket Exception: {e!s}")

            # Return; we're done
            return False

        return True

    @property
    def url_identifier(self):
        """Returns all of the identifiers that make this URL unique from
        another simliar one.

        Targets or end points should never be identified here.
        """
        return (
            self.secure_protocol if self.secure else self.protocol,
            self.user,
            self.password,
            self.host,
            self.port,
            self.fullpath.rstrip("/"),
            self.accesstoken,
        )

    def url(self, privacy=False, *args, **kwargs):
        """Returns the URL built dynamically based on specified arguments."""

        # Define any URL parameters
        params = {
            "batch": "yes" if self.batch else "no",
        }

        if self.prefix not in ("", "/"):
            params["prefix"] = (
                "/"
                if not self.prefix
                else "/{}/".format(self.prefix.strip("/"))
            )

        if self.nid:
            params["nid"] = self.nid

        # Extend our parameters
        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))

        # Determine Authentication
        auth = ""
        if self.user and self.password:
            auth = "{user}:{password}@".format(
                user=NotifyHomeAssistant.quote(self.user, safe=""),
                password=self.pprint(
                    self.password, privacy, mode=PrivacyMode.Secret, safe=""
                ),
            )
        elif self.user:
            auth = "{user}@".format(
                user=NotifyHomeAssistant.quote(self.user, safe=""),
            )

        default_port = 443 if self.secure else self.default_insecure_port

        url = (
            "{schema}://{auth}{hostname}{port}{fullpath}"
            "{accesstoken}/{targets}?{params}"
        )

        # Determine if we're doing it the old way (using persistent notices)
        # or the new (supporting device targets)
        has_targets = bool(
            not self.targets or self.targets[0] is not PERSISTENT_ENTRY
        )

        return url.format(
            schema=self.secure_protocol if self.secure else self.protocol,
            auth=auth,
            # never encode hostname since we're expecting it to be a valid one
            hostname=self.host,
            port=(
                ""
                if not self.port or self.port == default_port
                else f":{self.port}"
            ),
            fullpath=(
                "/"
                if not self.fullpath
                else "/{}/".format(
                    NotifyHomeAssistant.quote(
                        self.fullpath.strip("/"), safe="/"
                    )
                )
            ),
            accesstoken=self.pprint(self.accesstoken, privacy, safe=""),
            targets=""
            if not has_targets
            else "/".join(
                chain(
                    [
                        NotifyHomeAssistant.quote(
                            "{}.{}{}".format(
                                x[0],
                                x[1],
                                "" if not x[2] else ":" + ",".join(x[2]),
                            ),
                            safe="",
                        )
                        for x in self.targets
                    ],
                    [
                        NotifyHomeAssistant.quote(x, safe="")
                        for x in self._invalid_targets
                    ],
                )
            ),
            params=NotifyHomeAssistant.urlencode(params),
        )

    def __len__(self):
        """
        Returns the number of targets associated with this notification
        """
        #
        # Factor batch into calculation
        #

        # Determine if we're doing it the old way (using persistent notices)
        # or the new (supporting device targets)
        has_targets = bool(
            not self.targets or self.targets[0] is not PERSISTENT_ENTRY
        )

        if not has_targets:
            return 1

        # Handle targets
        batch_size = 1 if not self.batch else self.default_batch_size
        return sum(
            math.ceil(len(identities) / batch_size) if identities else 1
            for _, _, identities in self.targets
        )

    @staticmethod
    def parse_url(url):
        """Parses the URL and returns enough arguments that can allow us to re-
        instantiate this object."""

        results = NotifyBase.parse_url(url, verify_host=False)
        if not results:
            # We're done early as we couldn't load the results
            return results

        # Initialize targets
        results["targets"] = []

        # Get our Long-Lived Access Token — check query string first
        # (?accesstoken= or the shorthand ?token=)
        if "accesstoken" in results["qsd"] and results["qsd"]["accesstoken"]:
            results["accesstoken"] = NotifyHomeAssistant.unquote(
                results["qsd"]["accesstoken"]
            )
            # Remaining path elements become targets
            results["targets"] = NotifyHomeAssistant.split_path(
                results["fullpath"]
            )
            results["fullpath"] = ""

        elif "token" in results["qsd"] and results["qsd"]["token"]:
            results["accesstoken"] = NotifyHomeAssistant.unquote(
                results["qsd"]["token"]
            )
            # Remaining path elements become targets
            results["targets"] = NotifyHomeAssistant.split_path(
                results["fullpath"]
            )
            results["fullpath"] = ""

        else:
            # Scan path forward; the first element matching the token
            # regex is the access token.  Everything before it is the
            # prefix path; everything after it (reversed for consistent
            # call ordering) is treated as service targets.
            tokens = NotifyHomeAssistant.split_path(results["fullpath"])
            token_idx = None
            for idx, t in enumerate(tokens):
                if RE_IS_LONG_LIVED_TOKEN.match(t):
                    token_idx = idx
                    results["accesstoken"] = t
                    break

            if token_idx is not None:
                # Elements after the token are service targets (reversed
                # so the last URL segment is called first).
                post_targets = list(reversed(tokens[token_idx + 1 :]))
                # Elements before the token are also treated as service
                # targets (reversed to maintain consistent ordering).
                pre_targets = list(reversed(tokens[:token_idx]))
                results["targets"] = post_targets + pre_targets
                results["fullpath"] = ""

            elif tokens:
                # No regex match — treat the last path element as the
                # access token (plain token like "accesstoken" in tests).
                results["accesstoken"] = tokens[-1]
                results["fullpath"] = ""

            else:
                results["accesstoken"] = None

        # Support ?to= for additional targets
        if "to" in results["qsd"] and results["qsd"]["to"]:
            results["targets"] += NotifyHomeAssistant.split_path(
                NotifyHomeAssistant.unquote(results["qsd"]["to"])
            )

        # Support ?prefix= path-prefix override
        if "prefix" in results["qsd"] and results["qsd"]["prefix"]:
            results["prefix"] = NotifyHomeAssistant.unquote(
                results["qsd"]["prefix"]
            )

        # Get Batch Mode Flag
        results["batch"] = parse_bool(
            results["qsd"].get(
                "batch",
                NotifyHomeAssistant.template_args["batch"]["default"],
            )
        )

        # Allow the specification of a unique notification_id so that
        # it will always replace the last one sent.
        if "nid" in results["qsd"] and len(results["qsd"]["nid"]):
            results["nid"] = NotifyHomeAssistant.unquote(results["qsd"]["nid"])

        return results
