#!/usr/bin/env python3
"""
notify_to_cisco_webex module

Provides the Webex client for sending messages (with optional attachments)
to Cisco Webex, and a CLI entrypoint.

Attachment behavior (summary):
- CLI usage (via --file) accepts one or more local filesystem paths (strings).
- Programmatic usage (calling Webex.send_message):
  - Accepts a list of local filesystem path strings, and/or
  - Accepts a list of objects (dicts) each with keys:
      - "filename": desired filename (string)
      - "content": base64-encoded bytes (string)
  - Accepts a single string which is a base64-encoded JSON list of the objects
    described above (useful when passing via env or other string channels).
  - For object-style attachments the client will create temporary files from the
    decoded content, attach them, and remove the temp files after the requests.

Only external dependencies:
- dotenv
- httpx
"""

from __future__ import annotations

import base64
import io
import json
import logging
import mimetypes
import os
import tempfile
from typing import Dict, List, Optional, Tuple, Union

import httpx

logger = logging.getLogger(__name__)


class WebexError(Exception):
    """Generic error for Webex operations."""


class Webex:
    """Client for sending messages to Cisco Webex.

    Configuration priority:
      1. Constructor arguments
      2. OS environment variables
      3. .env file

    Environment variables used:
      - WEBEX_ACCESS_TOKEN
      - WEBEX_ROOM_ID
      - WEBEX_TO_EMAIL
      - WEBEX_PROXY
    """

    API_BASE = "https://webexapis.com/v1"

    def __init__(
        self,
        access_token: Optional[str] = None,
        room_id: Optional[str] = None,
        to_email: Optional[str] = None,
        proxy: Optional[str] = None,
        timeout: float = 10.0,
        verify: bool = True,
        verbose: bool = False,
    ) -> None:
        self.access_token = access_token or os.environ.get("WEBEX_ACCESS_TOKEN")
        self.room_id = room_id or os.environ.get("WEBEX_ROOM_ID")
        self.to_email = to_email or os.environ.get("WEBEX_TO_EMAIL")
        self.proxy = proxy or os.environ.get("WEBEX_PROXY")
        self.timeout = timeout
        self.verify = verify
        self.verbose = verbose

        # Map temporary file paths to original filename requested by caller.
        # This preserves the original filename when attachments are created from
        # base64 content (the module creates temp files and we want to show the
        # original filename in the uploaded multipart field).
        self._temp_upload_name_map: Dict[str, str] = {}
        # Store content bytes for temp paths created from base64 objects so we
        # can perform in-memory uploads and preserve the original filename
        # without reopening disk files. This map holds bytes for temp paths
        # (and is populated at the same time as _temp_upload_name_map).
        self._temp_upload_content_map: Dict[str, bytes] = {}

        if not self.access_token:
            raise WebexError(
                "WEBEX access token is required (constructor arg or WEBEX_ACCESS_TOKEN env)"
            )
        if not (self.room_id or self.to_email):
            raise WebexError(
                "Either room_id or to_email must be provided (constructor arg or env)"
            )

        if self.verbose:
            logging.basicConfig()
            logger.setLevel(logging.DEBUG)

        self.headers = {"Authorization": f"Bearer {self.access_token}"}

    def _build_target_payload(self) -> Dict[str, str]:
        if self.room_id:
            return {"roomId": self.room_id}
        return {"toPersonEmail": self.to_email}  # type: ignore[return-value]

    def _normalize_files(
        self, files: Optional[Union[List[Union[str, Dict[str, str]]], str]]
    ) -> Tuple[List[str], List[str]]:
        """Normalize the files parameter into filesystem paths.

        Returns a tuple (file_paths, temp_paths) where:
          - file_paths: list of paths (strings) to be attached
          - temp_paths: subset of file_paths created temporarily and should be removed
        """
        if not files:
            return [], []

        file_paths: List[str] = []
        temp_paths: List[str] = []

        # If the caller provided a single base64-encoded JSON string, decode it.
        if isinstance(files, str):
            # The CLI passes strings as a list (action=append), so a plain string
            # here is interpreted as the programmatic base64-encoded JSON format.
            try:
                decoded = base64.b64decode(files)
                parsed = json.loads(decoded.decode("utf-8"))
            except Exception as e:
                raise WebexError(
                    f"Failed to decode base64-encoded files list: {e}"
                ) from e

            if not isinstance(parsed, list):
                raise WebexError("Decoded base64 payload is not a JSON list")

            items = parsed
        else:
            items = list(files)  # type: ignore[arg-type]

        for item in items:
            # If it's a string, treat as local filesystem path
            if isinstance(item, str):
                file_paths.append(item)
                try:
                    # Preserve mapping for explicit filesystem paths so uploads can
                    # consistently use the original basename when needed.
                    self._temp_upload_name_map[item] = os.path.basename(item)
                except Exception:
                    pass
                continue

            # If it's a dict/object, expect 'filename' and 'content' (base64)
            if isinstance(item, dict):
                filename = item.get("filename") or item.get("name")
                content_b64 = item.get("content") or item.get("data")
                if not filename or not content_b64:
                    raise WebexError(
                        "File object must contain 'filename' and 'content' (base64)"
                    )
                try:
                    data = base64.b64decode(content_b64)
                except Exception as e:
                    raise WebexError(
                        f"Failed to decode base64 content for {filename}: {e}"
                    ) from e

                suffix = os.path.splitext(filename)[1]
                try:
                    tf = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
                    tf.write(data)
                    tf.flush()
                    tf.close()
                    file_paths.append(tf.name)
                    temp_paths.append(tf.name)
                    # Preserve mapping from temp path -> original filename so uploads
                    # can use the requested filename rather than the temp filename.
                    try:
                        # Map both full path and basename to the intended filename to be robust
                        self._temp_upload_name_map[tf.name] = filename
                        self._temp_upload_name_map[os.path.basename(tf.name)] = filename
                        # Also retain the raw bytes for this temp path so we can
                        # perform in-memory uploads (preserving filename) without
                        # re-opening disk files.
                        try:
                            self._temp_upload_content_map[tf.name] = data
                            self._temp_upload_content_map[os.path.basename(tf.name)] = (
                                data
                            )
                        except Exception:
                            # Best-effort: content map is an optimization, do not fail on errors here.
                            pass
                    except Exception:
                        # Best-effort mapping; don't fail the whole operation on mapping error.
                        pass
                except OSError as e:
                    raise WebexError(
                        f"Failed to create temporary file for {filename}: {e}"
                    ) from e
                continue

            raise WebexError(
                "Unsupported file item type; must be a path string or an object with 'filename'/'content'"
            )

        return file_paths, temp_paths

    def send_message(
        self,
        message: Optional[str] = None,
        format: str = "markdown",
        files: Optional[Union[List[Union[str, Dict[str, str]]], str]] = None,
    ) -> Union[Dict, List[Dict]]:
        """Send a message to Webex with optional attachments.

        Behavior:
          - If no files provided, send a single JSON POST.
          - If files provided, send one multipart POST per file.
          - If multiple files, the message body is included only on the first POST.
        """
        fmt = (format or "markdown").lower()
        if fmt not in ("markdown", "text"):
            raise WebexError("format must be 'markdown' or 'text'")

        # Normalize files input (handles local paths and programmatic base64 objects)
        file_paths, temp_paths = self._normalize_files(files)

        try:
            if not file_paths:
                return self._post_message(message=message, format=fmt)

            results: List[Dict] = []
            for idx, fpath in enumerate(file_paths):
                send_text = message if idx == 0 else None
                resp_json = self._post_message(
                    message=send_text, format=fmt, file_path=fpath
                )
                results.append(resp_json)

            if len(results) == 1:
                return results[0]
            return results
        finally:
            # Clean up any temporary files created from base64 objects
            for p in temp_paths:
                try:
                    os.unlink(p)
                    logger.debug("Removed temporary file %s", p)
                    try:
                        # Remove associated mapping entries if present (both full path and basename)
                        self._temp_upload_name_map.pop(p, None)
                        self._temp_upload_name_map.pop(os.path.basename(p), None)
                        # Also remove any in-memory content stored for this temp path
                        self._temp_upload_content_map.pop(p, None)
                        self._temp_upload_content_map.pop(os.path.basename(p), None)
                    except Exception:
                        pass
                except Exception:
                    logger.debug("Failed to remove temporary file %s", p)
                    try:
                        # Attempt to clean mapping even if file removal failed
                        self._temp_upload_name_map.pop(p, None)
                        self._temp_upload_name_map.pop(os.path.basename(p), None)
                        self._temp_upload_content_map.pop(p, None)
                        self._temp_upload_content_map.pop(os.path.basename(p), None)
                    except Exception:
                        pass

    def _post_message(
        self,
        message: Optional[str] = None,
        format: str = "markdown",
        file_path: Optional[str] = None,
    ) -> Dict:
        """Perform the HTTP POST to the Webex Messages API."""

        url = f"{self.API_BASE}/messages"
        payload = self._build_target_payload()
        if message:
            if format == "markdown":
                payload["markdown"] = message
            else:
                payload["text"] = message

        timeout = httpx.Timeout(self.timeout)
        client_kwargs = {
            "timeout": timeout,
            "verify": self.verify,
            "headers": self.headers,
        }
        if self.proxy:
            client_kwargs["proxies"] = self.proxy

        try:
            with httpx.Client(**client_kwargs) as client:
                if file_path:
                    # Ensure file exists
                    if not os.path.exists(file_path):
                        raise WebexError(f"File not found: {file_path}")

                    mime_type, _ = mimetypes.guess_type(file_path)
                    mime_type = mime_type or "application/octet-stream"
                    try:
                        # If we have in-memory content for this temp path, prefer using it
                        # to avoid reopening a disk file and to preserve the original filename.
                        if file_path in getattr(self, "_temp_upload_content_map", {}):
                            upload_name = self._temp_upload_name_map.get(
                                file_path,
                                self._temp_upload_name_map.get(
                                    os.path.basename(file_path),
                                    os.path.basename(file_path),
                                ),
                            )
                            bio = io.BytesIO(self._temp_upload_content_map[file_path])
                            try:
                                bio.name = upload_name  # type: ignore[attr-defined]
                            except Exception:
                                pass
                            # Ensure payload values are strings to preserve encoding
                            payload_encoded = {k: str(v) for k, v in payload.items()}
                            files_param = {"files": (upload_name, bio, mime_type)}
                            logger.debug(
                                "POST %s payload=%s file=%s (in-memory upload_name=%s)",
                                url,
                                payload_encoded,
                                file_path,
                                upload_name,
                            )
                            resp = client.post(
                                url, data=payload_encoded, files=files_param
                            )
                            try:
                                bio.close()
                            except Exception:
                                pass
                        else:
                            with open(file_path, "rb") as f:
                                # Ensure payload values are strings to preserve encoding
                                payload_encoded = {
                                    k: str(v) for k, v in payload.items()
                                }
                                # Use original filename if this path was created from a base64 object.
                                # Check mapping by full path first, then by basename, otherwise fall back.
                                upload_name = self._temp_upload_name_map.get(
                                    file_path,
                                    self._temp_upload_name_map.get(
                                        os.path.basename(file_path),
                                        os.path.basename(file_path),
                                    ),
                                )
                                files_param = {"files": (upload_name, f, mime_type)}
                                logger.debug(
                                    "POST %s payload=%s file=%s (upload_name=%s)",
                                    url,
                                    payload_encoded,
                                    file_path,
                                    upload_name,
                                )
                                resp = client.post(
                                    url, data=payload_encoded, files=files_param
                                )
                    except OSError as e:
                        raise WebexError(
                            f"Failed to open/read file {file_path}: {e}"
                        ) from e
                else:
                    # Send JSON with utf-8 charset
                    logger.debug("POST %s payload=%s", url, payload)
                    headers = self.headers.copy()
                    headers["Content-Type"] = "application/json; charset=utf-8"
                    resp = client.post(url, json=payload, headers=headers)
        except httpx.RequestError as e:
            raise WebexError(f"Request failed: {e}") from e

        if resp.status_code >= 400:
            body = None
            try:
                body = resp.json()
            except Exception:
                body = resp.text
            raise WebexError(
                f"Webex API returned error status {resp.status_code}: {body}"
            )

        try:
            return resp.json()
        except Exception as e:
            raise WebexError("Response is not valid JSON") from e

    def _post_message_with_fileobj(
        self,
        message: Optional[str] = None,
        format: str = "markdown",
        fileobj: Optional[io.BytesIO] = None,
        filename: Optional[str] = None,
        mime_type: Optional[str] = None,
    ) -> Dict:
        """Perform the HTTP POST using a file-like object (in-memory).

        This is similar to `_post_message` but accepts a BytesIO and filename/mime
        so callers (e.g. Dify integration) need not write temporary files to disk.
        """
        if not filename or fileobj is None:
            raise WebexError("filename and fileobj are required for file uploads")

        url = f"{self.API_BASE}/messages"
        payload = self._build_target_payload()
        if message:
            if format == "markdown":
                payload["markdown"] = message
            else:
                payload["text"] = message

        timeout = httpx.Timeout(self.timeout)
        client_kwargs = {
            "timeout": timeout,
            "verify": self.verify,
            "headers": self.headers,
        }
        if self.proxy:
            client_kwargs["proxies"] = self.proxy

        # Ensure fileobj is at start
        try:
            fileobj.seek(0)
        except Exception:
            pass

        try:
            with httpx.Client(**client_kwargs) as client:
                # Use file-like object directly in multipart upload.
                mime = mime_type or "application/octet-stream"
                # httpx accepts file-like objects as file tuples
                payload_encoded = {k: str(v) for k, v in payload.items()}
                files_param = {"files": (filename, fileobj, mime)}
                logger.debug(
                    "POST %s payload=%s fileobj=%s filename=%s mime=%s",
                    url,
                    payload_encoded,
                    getattr(fileobj, "name", "<memory>"),
                    filename,
                    mime,
                )
                resp = client.post(url, data=payload_encoded, files=files_param)
        except httpx.RequestError as e:
            raise WebexError(f"Request failed: {e}") from e

        if resp.status_code >= 400:
            body = None
            try:
                body = resp.json()
            except Exception:
                body = resp.text
            raise WebexError(
                f"Webex API returned error status {resp.status_code}: {body}"
            )

        try:
            return resp.json()
        except Exception as e:
            raise WebexError("Response is not valid JSON") from e

    def send_message_from_dify_files(
        self,
        message: Optional[str] = None,
        format: str = "markdown",
        dify_files: Optional[List[object]] = None,
        dify_base_url: Optional[str] = None,
        dify_headers: Optional[Dict[str, str]] = None,
    ) -> Union[Dict, List[Dict]]:
        """Send a message attaching files referenced by Dify without writing to disk.

        - `dify_files` may be a list of objects or dicts exposing `url` and `filename`/`name`.
        - If a Dify file URL is relative (starts with '/'), `dify_base_url` is required.
        - `dify_headers` can be provided to include headers when downloading files.
        - Files are downloaded into memory (BytesIO) and posted directly.
        """
        if not dify_files:
            return self.send_message(message=message, format=format, files=None)

        # Prepare HTTP client for downloads
        client_kwargs = {"timeout": httpx.Timeout(self.timeout), "verify": self.verify}
        if self.proxy:
            client_kwargs["proxies"] = self.proxy
        # Attach any headers required to access Dify-hosted files
        download_headers = {}
        if dify_headers:
            download_headers.update(dify_headers)

        results: List[Dict] = []
        # Each entry is a tuple: (BytesIO, filename, mime)
        fileobjs: List[Tuple[io.BytesIO, str, Optional[str]]] = []

        try:
            with httpx.Client(**client_kwargs) as download_client:
                for item in dify_files:
                    # Support dicts and objects
                    if isinstance(item, dict):
                        url = (
                            item.get("url") or item.get("file_url") or item.get("file")
                        )
                        filename = item.get("filename") or item.get("name")
                        mime = item.get("mime_type") or item.get("mime")
                    else:
                        url = (
                            getattr(item, "url", None)
                            or getattr(item, "file_url", None)
                            or getattr(item, "file", None)
                        )
                        filename = getattr(item, "filename", None) or getattr(
                            item, "name", None
                        )
                        mime = getattr(item, "mime_type", None) or getattr(
                            item, "mime", None
                        )

                    if not url:
                        raise WebexError("Dify file item missing 'url'")

                    # Resolve full URL when relative
                    if str(url).startswith("http"):
                        full_url = url
                    else:
                        if not dify_base_url:
                            raise WebexError(
                                f"Encountered relative URL '{url}' but no dify_base_url provided"
                            )
                        # ensure proper join
                        from urllib.parse import urljoin

                        full_url = urljoin(
                            dify_base_url.rstrip("/") + "/", str(url).lstrip("/")
                        )

                    try:
                        resp = download_client.get(full_url, headers=download_headers)
                        resp.raise_for_status()
                        data = resp.content
                    except httpx.RequestError as e:
                        raise WebexError(
                            f"Failed to download file from {full_url}: {e}"
                        ) from e
                    except httpx.HTTPStatusError as e:
                        raise WebexError(
                            f"Failed to download file from {full_url}: {e}"
                        ) from e

                    # Determine filename if missing
                    if not filename:
                        from os import path as _opath

                        raw_name = str(url).split("?")[0]
                        filename = _opath.basename(raw_name) or "attachment"

                    # Create an in-memory bytes buffer
                    bio = io.BytesIO(data)
                    # Optionally set a 'name' attribute for debugging (not required)
                    try:
                        bio.name = filename  # type: ignore[attr-defined]
                    except Exception:
                        pass

                    # Append (bio, filename, mime) tuple
                    fileobjs.append((bio, filename, mime))

                # Now post each file in-memory to Webex, including message only on first
                for idx, (bio, filename, mime) in enumerate(fileobjs):
                    send_text = message if idx == 0 else None
                    mimetype = (
                        mime
                        if mime
                        else (
                            mimetypes.guess_type(filename)[0]
                            or "application/octet-stream"
                        )
                    )
                    resp_json = self._post_message_with_fileobj(
                        message=send_text,
                        format=(format or "markdown"),
                        fileobj=bio,
                        filename=filename,
                        mime_type=mimetype,
                    )
                    results.append(resp_json)

            if len(results) == 1:
                return results[0]
            return results
        finally:
            # Close in-memory fileobjs (each entry is tuple (bio, filename, mime))
            for bio_entry in fileobjs:
                try:
                    bio, _, _ = bio_entry
                    bio.close()
                except Exception:
                    pass


def main(argv: Optional[List[str]] = None) -> int:
    """CLI entrypoint for the module."""
    import argparse

    # Attempt to locate a .env file and load it if present so that the CLI
    # honors WEBEX_ACCESS_TOKEN and other env vars defined there.
    # We intentionally avoid module-level dotenv loading so imports/tests are unaffected.
    from dotenv import find_dotenv, load_dotenv

    # 1) Prefer a .env file in the current working directory
    cwd_env = os.path.join(os.getcwd(), ".env")
    if os.path.exists(cwd_env):
        load_dotenv(cwd_env, override=False)
    else:
        # 2) Try a .env located at the project root relative to this module.
        #    This module lives at: <project>/notify_to_cisco_webex/notify_to_cisco_webex.py
        #    so the project root is two levels up from __file__.
        package_root_env = os.path.abspath(
            os.path.join(os.path.dirname(__file__), "..", "..", ".env")
        )
        if os.path.exists(package_root_env):
            load_dotenv(package_root_env, override=False)
        else:
            # 3) Fallback to find_dotenv which searches parent directories.
            _dotenv_path = find_dotenv(usecwd=True)
            if _dotenv_path:
                load_dotenv(_dotenv_path, override=False)

    parser = argparse.ArgumentParser(prog="notify-to-cisco-webex")
    parser.add_argument("-t", "--token", help="Webex access token")
    parser.add_argument("-r", "--room-id", help="Webex room id")
    parser.add_argument("-e", "--to-email", help="Recipient email address")
    parser.add_argument("-m", "--message", help="Message body", default=None)
    parser.add_argument(
        "-f",
        "--format",
        help="Message format ('text' or 'markdown')",
        default="Markdown",
    )
    parser.add_argument(
        "--timeout", type=float, help="HTTP timeout in seconds", default=10.0
    )
    parser.add_argument(
        "--no-verify", action="store_true", help="Disable SSL verification"
    )
    parser.add_argument(
        "-v", "--verbose", action="store_true", help="Enable verbose logging"
    )
    parser.add_argument("-p", "--proxy", help="HTTP proxy URL")
    parser.add_argument(
        "--file",
        action="append",
        help="File to attach. Can be specified multiple times.",
    )

    args = parser.parse_args(argv)

    fmt = (args.format or "markdown").lower()
    if fmt not in ("markdown", "text"):
        raise SystemExit(2)

    try:
        client = Webex(
            access_token=args.token,
            room_id=args.room_id,
            to_email=args.to_email,
            proxy=args.proxy,
            timeout=args.timeout,
            verify=not args.no_verify,
            verbose=args.verbose,
        )
    except WebexError as e:
        print(json.dumps({"error": str(e)}, ensure_ascii=False))
        return 2

    try:
        result = client.send_message(message=args.message, format=fmt, files=args.file)
    except WebexError as e:
        print(json.dumps({"error": str(e)}, ensure_ascii=False))
        return 3

    print(json.dumps(result, indent=2, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
