# Copyright 2025 certbot-dns-technitium contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""DNS Authenticator for Technitium DNS Server."""

import logging
import time
from typing import Optional

import requests
from requests.exceptions import RequestException
from certbot import errors
from certbot.plugins.dns_common import DNSAuthenticator
from certbot.plugins.dns_common import CredentialsConfiguration

logger = logging.getLogger(__name__)


class Authenticator(DNSAuthenticator):
    """DNS Authenticator for Technitium DNS Server.

    This Authenticator uses the Technitium DNS Server HTTP API to fulfill
    a dns-01 challenge. It requires credentials to be supplied via a
    credentials file containing your Technitium DNS Server API URL and token.

    The plugin supports both wildcard and non-wildcard certificates, and
    automatically handles DNS record creation and cleanup during the
    certificate issuance process.
    """

    description = (
        "Obtain certificates using a DNS TXT record (if you are using "
        "Technitium DNS Server for DNS)."
    )

    def __init__(self, *args, **kwargs):
        super(Authenticator, self).__init__(*args, **kwargs)
        self.credentials: Optional[CredentialsConfiguration] = None

    @classmethod
    def add_parser_arguments(cls, add, default_propagation_seconds: int = 10):
        """Add plugin arguments to the CLI argument parser.

        :param callable add: Function to add arguments to parser
        :param int default_propagation_seconds: Default propagation seconds
        """
        super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds)
        add(
            "api-url",
            default="http://localhost:5380",
            help=(
                "Technitium DNS Server API URL. "
                "Default: http://localhost:5380. "
                "For remote servers, use http://your-server-ip:53443 or https://your-server:53443"
            ),
        )
        add(
            "api-token",
            default=None,
            help=(
                "Technitium DNS Server API token (required). "
                "Generate this token in Technitium DNS Server web interface "
                "under Administration → Sessions → Create Token."
            ),
        )
        add(
            "credentials",
            help=(
                "Path to Technitium DNS Server credentials INI file. "
                "The file should contain 'dns_technitium_api_url' and "
                "'dns_technitium_api_token' entries."
            ),
        )

    def more_info(self):
        """Human-readable string describing the plugin."""
        return (
            "This plugin configures a DNS TXT record to respond to a dns-01 challenge using "
            "the Technitium DNS Server HTTP API. It supports both wildcard and non-wildcard "
            "certificates. To use this plugin, you need to configure an API token in your "
            "Technitium DNS Server and provide it via a credentials file or command-line "
            "arguments."
        )

    def _validate_credentials(self, credentials: CredentialsConfiguration) -> None:
        """Validate credentials configuration.

        :param credentials: The credentials configuration to validate
        :raises certbot.errors.PluginError: If credentials are invalid
        """
        api_url = credentials.conf("api-url")
        api_token = credentials.conf("api-token")

        if not api_url:
            raise errors.PluginError(
                f"{credentials.confobj.filename}: dns_technitium_api_url is required. "
                "Specify in credentials file or use --dns-technitium-api-url"
            )

        if not api_token:
            raise errors.PluginError(
                f"{credentials.confobj.filename}: dns_technitium_api_token is required. "
                "Specify in credentials file or use --dns-technitium-api-token"
            )

    def _setup_credentials(self):
        """Load credentials from config file or command-line arguments."""
        # Try to use credentials file first
        self.credentials = self._configure_credentials(
            "credentials",
            "Technitium DNS Server credentials INI file",
            None,
            self._validate_credentials,
        )

        # If credentials file is used, read from it
        # Certbot automatically converts argument names (api-url) to config keys (dns_technitium_api_url)
        if self.credentials:
            self.api_url = self.credentials.conf("api-url")
            self.api_token = self.credentials.conf("api-token")
        else:
            # Fall back to command-line arguments
            self.api_url = self.conf("api-url")
            self.api_token = self.conf("api-token")

        if not self.api_url:
            raise errors.PluginError(
                "dns_technitium:api-url is required (specify in credentials file as 'dns_technitium_api_url' or use --dns-technitium-api-url)"
            )

        if not self.api_token:
            raise errors.PluginError(
                "dns_technitium:api-token is required (specify in credentials file as 'dns_technitium_api_token' or use --dns-technitium-api-token)"
            )

    def _perform(self, domain: str, validation_name: str, validation: str):
        """Perform the dns-01 challenge.

        :param str domain: The domain being validated
        :param str validation_name: The TXT record name (e.g., _acme-challenge.example.com)
        :param str validation: The TXT record value
        :raises certbot.errors.PluginError: If the challenge cannot be fulfilled
        """
        self._get_session_token()
        # Find zone based on validation_name to handle subdomains correctly
        zone = self._find_zone(validation_name)
        self._add_txt_record(zone, validation_name, validation)

    def _cleanup(self, domain: str, validation_name: str, validation: str):
        """Clean up a dns-01 challenge.

        :param str domain: The domain being validated
        :param str validation_name: The TXT record name (e.g., _acme-challenge.example.com)
        :param str validation: The TXT record value
        :raises certbot.errors.PluginError: If the cleanup cannot be completed
        """
        self._get_session_token()
        # Find zone based on validation_name to handle subdomains correctly
        zone = self._find_zone(validation_name)
        self._delete_txt_record(zone, validation_name, validation)

    def _get_session_token(self):
        """Get API token for authentication."""
        if not self.api_token:
            raise errors.PluginError("API token is required but not configured")

    def _find_zone(self, domain: str) -> str:
        """Find the zone that contains the given domain.

        :param str domain: The domain to find the zone for
        :returns: The zone name
        :rtype: str
        :raises certbot.errors.PluginError: If the zone cannot be found
        """
        try:
            # Get list of zones
            response = requests.get(
                f"{self.api_url}/api/zones/list",
                params={"token": self.api_token},
                timeout=30,
            )
            response.raise_for_status()
            data = response.json()
            if data.get("status") != "ok":
                error_msg = data.get("errorMessage") or data.get("response") or "Unknown error"
                raise errors.PluginError(f"Failed to list zones: {error_msg}")

            # Handle different response formats
            response_data = data.get("response", [])
            if isinstance(response_data, dict):
                # Response might be {"zones": [...]} or similar
                zones = response_data.get("zones", response_data.get("response", []))
            else:
                zones = response_data if isinstance(response_data, list) else []
            if not zones:
                raise errors.PluginError("No zones found in Technitium DNS Server")

            # Find the longest matching zone
            domain_parts = domain.split(".")
            best_match = None
            best_match_length = 0

            for zone in zones:
                # Handle both string and dict formats
                if isinstance(zone, str):
                    zone_name = zone.rstrip(".")
                elif isinstance(zone, dict):
                    zone_name = zone.get("name", "").rstrip(".")
                else:
                    zone_name = str(zone).rstrip(".")
                
                if not zone_name:
                    continue
                    
                zone_parts = zone_name.split(".")
                if len(zone_parts) > len(domain_parts):
                    continue

                # Check if domain ends with zone name
                if domain.endswith(zone_name) or domain.endswith(zone_name + "."):
                    if len(zone_parts) > best_match_length:
                        best_match = zone_name
                        best_match_length = len(zone_parts)

            if not best_match:
                raise errors.PluginError(
                    f"Could not find zone for domain {domain} in Technitium DNS Server"
                )

            return best_match
        except RequestException as e:
            raise errors.PluginError(f"Error finding zone for {domain}: {e}")

    def _add_txt_record(self, zone: str, record_name: str, record_value: str):
        """Add a TXT record to the zone.

        :param str zone: The zone name
        :param str record_name: The full domain name (e.g., _acme-challenge.example.com)
        :param str record_value: The TXT record value
        :raises certbot.errors.PluginError: If the record cannot be added
        """
        try:
            # Add the TXT record
            # According to API docs: /api/zones/records/add?token=x&domain=example.com&zone=example.com
            # Zone is a query parameter, not in URL path
            # For TXT records, use 'text' parameter, not 'value'
            # domain should be the full domain name
            response = requests.post(
                f"{self.api_url}/api/zones/records/add",
                params={
                    "token": self.api_token,
                    "domain": record_name.rstrip("."),
                    "zone": zone.rstrip("."),
                    "type": "TXT",
                    "ttl": 60,
                    "text": record_value,
                },
                timeout=30,
            )
            response.raise_for_status()
            data = response.json()
            if data.get("status") != "ok":
                error_msg = data.get("errorMessage") or data.get("response") or "Unknown error"
                raise errors.PluginError(f"Failed to add TXT record: {error_msg}")

            logger.info(f"Successfully added TXT record {record_name}")

            # Wait for DNS propagation
            # The base class handles propagation, but we wait here to ensure
            # the record is available before Let's Encrypt checks
            propagation_seconds = getattr(self, 'propagation_seconds', None)
            if propagation_seconds is None:
                # Fallback to config value if attribute not set
                try:
                    propagation_seconds = int(self.conf("propagation-seconds") or 10)
                except (ValueError, TypeError):
                    propagation_seconds = 10
            time.sleep(propagation_seconds)
        except RequestException as e:
            raise errors.PluginError(f"Error adding TXT record: {e}")

    def _delete_txt_record(self, zone: str, record_name: str, record_value: str):
        """Delete a TXT record from the zone.

        :param str zone: The zone name
        :param str record_name: The full domain name (e.g., _acme-challenge.example.com)
        :param str record_value: The TXT record value
        :raises certbot.errors.PluginError: If the record cannot be deleted
        """
        try:
            # Try to delete the record directly using the API
            # According to API docs: /api/zones/records/delete?token=x&domain=example.com&zone=example.com&type=TXT&text=...
            # For TXT records, use 'text' parameter, not 'value'
            # domain should be the full domain name
            logger.debug(f"Attempting to delete TXT record: domain={record_name}, zone={zone}, text={record_value[:20]}...")
            
            delete_response = requests.post(
                f"{self.api_url}/api/zones/records/delete",
                params={
                    "token": self.api_token,
                    "domain": record_name.rstrip("."),
                    "zone": zone.rstrip("."),
                    "type": "TXT",
                    "text": record_value,
                },
                timeout=30,
            )
            delete_response.raise_for_status()
            delete_data = delete_response.json()
            
            if delete_data.get("status") == "ok":
                logger.info(f"Successfully deleted TXT record {record_name}")
                return
            
            # If direct delete failed, try to list records first to debug
            error_msg = delete_data.get("errorMessage") or delete_data.get("response") or "Unknown error"
            logger.debug(f"Direct delete failed: {error_msg}. Attempting to list records to find the record...")
            
            # Fallback: List records to see what's there
            response = requests.get(
                f"{self.api_url}/api/zones/records/get",
                params={
                    "token": self.api_token,
                    "domain": record_name.rstrip("."),
                    "zone": zone.rstrip("."),
                    "listZone": "false",
                },
                timeout=30,
            )
            response.raise_for_status()
            data = response.json()
            
            if data.get("status") != "ok":
                error_msg = data.get("errorMessage") or data.get("response") or "Unknown error"
                logger.warning(f"Failed to list records for cleanup: {error_msg}")
                return

            records = data.get("response", {}).get("records", [])
            logger.debug(f"Found {len(records)} record(s) for {record_name}")
            
            for record in records:
                record_name_from_api = record.get("name", "").rstrip(".")
                record_type = record.get("type", "")
                record_data = record.get("rData", "")
                
                logger.debug(f"Checking record: name={record_name_from_api}, type={record_type}, rData={record_data[:50] if record_data else 'None'}...")
                
                # Match by name and type
                if record_type == "TXT" and (
                    record_name_from_api == record_name.rstrip(".")
                    or record_name_from_api == f"{record_name.rstrip('.')}.{zone.rstrip('.')}".rstrip(".")
                    or record_name.rstrip(".").endswith(record_name_from_api)
                ):
                    # Check if value matches (TXT records might have quotes)
                    if record_value in record_data or record_value in record_data.replace('"', ''):
                        logger.debug(f"Found matching record, attempting delete with exact match...")
                        # Try delete again with the exact record name from API
                        delete_response2 = requests.post(
                            f"{self.api_url}/api/zones/records/delete",
                            params={
                                "token": self.api_token,
                                "domain": record_name_from_api,
                                "zone": zone.rstrip("."),
                                "type": "TXT",
                                "text": record_value,
                            },
                            timeout=30,
                        )
                        delete_response2.raise_for_status()
                        delete_data2 = delete_response2.json()
                        if delete_data2.get("status") == "ok":
                            logger.info(f"Successfully deleted TXT record {record_name_from_api}")
                            return
                        else:
                            error_msg2 = delete_data2.get("errorMessage") or delete_data2.get("response") or "Unknown error"
                            logger.warning(f"Failed to delete TXT record on second attempt: {error_msg2}")

            logger.warning(f"TXT record {record_name} not found for cleanup (checked {len(records)} record(s))")
        except RequestException as e:
            logger.warning(f"Error deleting TXT record (non-fatal): {e}")

