# Copyright (c) DevSecCode. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root.

"""Cold email outreach service.

Handles template rendering, AI-powered per-prospect personalisation,
SMTP delivery, sequence scheduling, and send-log tracking.

Use cases supported (``campaign.use_case``):
  sales            — B2B/B2C product or service sales
  creator_ugc      — Content creator pitching brands for UGC deals
  investor_outreach — Startup founder reaching out to investors
  job_search       — Student/professional seeking roles or internships
  partnership      — Partnership / co-marketing proposals
  general          — Generic outreach

Template variables (merge fields resolved per prospect):
  {{name}}         — prospect.name
  {{first_name}}   — first word of prospect.name
  {{company}}      — prospect.company
  {{role}}         — prospect.role
  {{website}}      — prospect.website
  {{email}}        — prospect.email
  {{custom.*}}     — any key in prospect.custom_fields
"""

from __future__ import annotations

import asyncio
import hashlib
import logging
import os
import re
import secrets
import smtplib
import ssl
import uuid
from datetime import datetime, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any

from dsc_api.services import state_store
from dsc_api.services.llm_client import generate_text

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Use-case system prompts for AI personalisation
# ---------------------------------------------------------------------------

_USE_CASE_SYSTEM: dict[str, str] = {
	"sales": (
		"You are an expert B2B/B2C sales copywriter. "
		"Write concise, genuine cold email copy that is personalised, "
		"value-first, and respectful of the prospect's time. "
		"Never use spammy phrases. Keep tone professional but warm."
	),
	"creator_ugc": (
		"You are a content creator writing to brands to pitch UGC collaboration deals. "
		"Be enthusiastic, concise, and highlight why you're a great fit for their brand. "
		"Reference their specific products or audience naturally."
	),
	"investor_outreach": (
		"You are a startup founder writing to potential investors. "
		"Be direct about your ask. Lead with traction or a compelling insight. "
		"Respect their time — keep it under 150 words."
	),
	"job_search": (
		"You are a candidate reaching out to hiring managers or employees at target companies. "
		"Be enthusiastic but professional. Mention a specific reason you're excited about "
		"the company. Keep it brief and end with a clear, easy ask."
	),
	"partnership": (
		"You are reaching out for a potential partnership or co-marketing collaboration. "
		"Be specific about the mutual benefit. Mention their work genuinely."
	),
	"general": (
		"You are writing a personalised cold outreach email. "
		"Be genuine, concise, and value-first. Avoid generic filler phrases."
	),
}

# ---------------------------------------------------------------------------
# Template rendering
# ---------------------------------------------------------------------------


def render_template(template: str, prospect: dict[str, Any]) -> str:
	"""Replace ``{{field}}`` merge fields with prospect values.

	Supports:
	  ``{{name}}``, ``{{first_name}}``, ``{{company}}``, ``{{role}}``,
	  ``{{website}}``, ``{{email}}``, ``{{custom.KEY}}``.

	Missing fields are left as-is so the AI step can still personalise them.
	"""
	custom = prospect.get("custom_fields") or {}
	name = prospect.get("name") or ""
	first_name = name.split()[0] if name else ""

	replacements: dict[str, str] = {
		"name": name,
		"first_name": first_name,
		"company": prospect.get("company") or "",
		"role": prospect.get("role") or "",
		"website": prospect.get("website") or "",
		"email": prospect.get("email") or "",
	}
	# Add custom fields under ``custom.*`` namespace
	for k, v in custom.items():
		replacements[f"custom.{k}"] = str(v)

	def _replace(m: re.Match) -> str:
		key = m.group(1).strip()
		return replacements.get(key, m.group(0))  # leave unknown fields intact

	return re.sub(r"\{\{([^}]+)\}\}", _replace, template)


def _generate_unsub_token(send_id: str) -> str:
	"""Generate a deterministic unsubscribe token for a send."""
	return hashlib.sha256(f"unsub:{send_id}:{os.environ.get('DSC_SMTP_PASS','secret')}".encode()).hexdigest()[:32]


# ---------------------------------------------------------------------------
# AI personalisation
# ---------------------------------------------------------------------------


async def ai_personalise(
	template: str,
	prospect: dict[str, Any],
	prompt_override: str | None = None,
	use_case: str = "general",
	model: str = "claude-haiku-4-5",
) -> str:
	"""Generate a personalised version of *template* for *prospect*.

	The LLM receives:
	  1. The rendered template (merge fields already substituted).
	  2. All available prospect context.
	  3. An optional prompt override with additional instructions.

	Returns the personalised email body/subject string.
	"""
	rendered = render_template(template, prospect)
	system = _USE_CASE_SYSTEM.get(use_case, _USE_CASE_SYSTEM["general"])

	context_parts: list[str] = []
	if prospect.get("name"):
		context_parts.append(f"Prospect name: {prospect['name']}")
	if prospect.get("company"):
		context_parts.append(f"Company: {prospect['company']}")
	if prospect.get("role"):
		context_parts.append(f"Role: {prospect['role']}")
	if prospect.get("website"):
		context_parts.append(f"Website: {prospect['website']}")
	if prospect.get("linkedin_url"):
		context_parts.append(f"LinkedIn: {prospect['linkedin_url']}")
	custom = prospect.get("custom_fields") or {}
	for k, v in custom.items():
		context_parts.append(f"{k}: {v}")

	prospect_context = "\n".join(context_parts) if context_parts else "No additional context."

	extra_instruction = ""
	if prompt_override:
		extra_instruction = f"\n\nAdditional personalisation instruction:\n{prompt_override}"

	user_prompt = (
		f"Prospect context:\n{prospect_context}\n\n"
		f"Email draft:\n{rendered}\n\n"
		f"Personalise this email for the specific prospect above. "
		f"Keep the same general structure and length. Return ONLY the final email text, "
		f"no explanation or markdown wrapper.{extra_instruction}"
	)

	try:
		result = await generate_text(
			user_prompt,
			model=model,
			system=system,
			max_tokens=600,
			timeout=30.0,
		)
		return result.strip() if result.strip() else rendered
	except Exception as exc:
		logger.warning("AI personalisation failed for %s: %s", prospect.get("email"), exc)
		return rendered


# ---------------------------------------------------------------------------
# SMTP delivery
# ---------------------------------------------------------------------------


def _get_smtp_config(smtp_config: dict[str, Any] | None = None) -> dict[str, Any]:
	"""Merge explicit config with env-var defaults.

	Explicit config (from request) takes priority; env vars are fallback.
	Environment variables:
	  DSC_SMTP_HOST, DSC_SMTP_PORT, DSC_SMTP_USER, DSC_SMTP_PASS,
	  DSC_SMTP_USE_SSL (1/true), DSC_SMTP_FROM_EMAIL, DSC_SMTP_FROM_NAME
	"""
	cfg = smtp_config or {}
	return {
		"host": cfg.get("host") or os.environ.get("DSC_SMTP_HOST", ""),
		"port": int(cfg.get("port") or os.environ.get("DSC_SMTP_PORT", "587")),
		"username": cfg.get("username") or os.environ.get("DSC_SMTP_USER", ""),
		"password": cfg.get("password") or os.environ.get("DSC_SMTP_PASS", ""),
		"use_ssl": cfg.get("use_ssl", os.environ.get("DSC_SMTP_USE_SSL", "").lower() in ("1", "true")),
		"from_email": cfg.get("from_email") or os.environ.get("DSC_SMTP_FROM_EMAIL", ""),
		"from_name": cfg.get("from_name") or os.environ.get("DSC_SMTP_FROM_NAME", ""),
	}


def send_email_smtp(
	to_email: str,
	to_name: str,
	subject: str,
	body: str,
	smtp_config: dict[str, Any] | None = None,
	*,
	is_html: bool = False,
) -> None:
	"""Send a single email via SMTP.

	Supports:
	  - SSL (port 465): ``use_ssl=True``
	  - STARTTLS (port 587, default): ``use_ssl=False``

	Raises:
	  ``ValueError`` if required SMTP config is missing.
	  ``smtplib.SMTPException`` on delivery failure.
	"""
	cfg = _get_smtp_config(smtp_config)

	if not cfg["host"]:
		raise ValueError(
			"SMTP host is not configured. "
			"Set DSC_SMTP_HOST in .env or pass smtp_config in the request."
		)
	if not cfg["username"] or not cfg["password"]:
		raise ValueError(
			"SMTP credentials are not configured. "
			"Set DSC_SMTP_USER and DSC_SMTP_PASS in .env or pass smtp_config."
		)

	from_addr = cfg["from_email"] or cfg["username"]
	from_display = f"{cfg['from_name']} <{from_addr}>" if cfg["from_name"] else from_addr
	to_display = f"{to_name} <{to_email}>" if to_name else to_email

	msg = MIMEMultipart("alternative")
	msg["Subject"] = subject
	msg["From"] = from_display
	msg["To"] = to_display
	msg["Message-ID"] = f"<{uuid.uuid4()}@devseccode.io>"
	msg["X-Mailer"] = "DevSecCode-IDE Outreach"

	mime_type = "html" if is_html else "plain"
	msg.attach(MIMEText(body, mime_type, "utf-8"))

	context = ssl.create_default_context()

	if cfg["use_ssl"]:
		with smtplib.SMTP_SSL(cfg["host"], cfg["port"], context=context, timeout=15) as server:
			server.login(cfg["username"], cfg["password"])
			server.sendmail(from_addr, [to_email], msg.as_string())
	else:
		with smtplib.SMTP(cfg["host"], cfg["port"], timeout=15) as server:
			server.ehlo()
			server.starttls(context=context)
			server.ehlo()
			server.login(cfg["username"], cfg["password"])
			server.sendmail(from_addr, [to_email], msg.as_string())


# ---------------------------------------------------------------------------
# Campaign helpers
# ---------------------------------------------------------------------------


def _resolve_prospects(campaign: dict[str, Any]) -> list[dict[str, Any]]:
	"""Return the full prospect records for a campaign.

	Prospects are selected by ``prospect_ids`` (explicit list) or
	``prospect_tags`` (any prospect whose tags overlap). Both are ORed.
	"""
	prospect_ids: set[str] = set(campaign.get("prospect_ids") or [])
	tags: list[str] = campaign.get("prospect_tags") or []

	results: list[dict[str, Any]] = []
	seen: set[str] = set()

	# By explicit IDs
	for pid in prospect_ids:
		p = state_store.get_prospect(pid)
		if p and p["prospect_id"] not in seen:
			results.append(p)
			seen.add(p["prospect_id"])

	# By tags
	if tags:
		all_prospects = state_store.list_prospects(limit=5000)
		for p in all_prospects:
			if p["prospect_id"] in seen:
				continue
			ptags: list[str] = p.get("tags") or []
			if any(t in ptags for t in tags):
				results.append(p)
				seen.add(p["prospect_id"])

	return results


def _should_send_step(
	step: dict[str, Any],
	prospect_id: str,
	campaign_id: str,
) -> bool:
	"""Determine whether a sequence step should fire for this prospect.

	Step conditions:
	  ``no_reply``     — send only if no previous step was replied to
	  ``no_open``      — send only if no previous email was opened
	  ``always``       — always send regardless of prior engagement
	  ``replied``      — send only if prospect has replied to a prior step
	"""
	condition = step.get("condition", "no_reply")
	if condition == "always":
		return True

	sends = state_store.list_sends(campaign_id=campaign_id, prospect_id=prospect_id)
	if not sends:
		return True  # first contact

	if condition == "no_reply":
		return not any(s.get("replied_at") for s in sends)
	if condition == "no_open":
		return not any(s.get("opened_at") for s in sends)
	if condition == "replied":
		return any(s.get("replied_at") for s in sends)
	return True


def _already_sent_step(step_id: str, prospect_id: str) -> bool:
	"""True if this specific step was already sent/attempted for this prospect."""
	sends = state_store.list_sends(prospect_id=prospect_id)
	return any(s["step_id"] == step_id for s in sends)


# ---------------------------------------------------------------------------
# Core send operation
# ---------------------------------------------------------------------------


async def send_step_to_prospect(
	campaign: dict[str, Any],
	step: dict[str, Any],
	prospect: dict[str, Any],
	smtp_config: dict[str, Any] | None = None,
	*,
	dry_run: bool = False,
) -> dict[str, Any]:
	"""Personalise and send (or preview) one sequence step to one prospect.

	Returns a ``send`` record dict with status and metadata.
	"""
	send_id = str(uuid.uuid4())
	now = datetime.now(timezone.utc).isoformat()

	# Render merge fields
	subject = render_template(step["subject_template"], prospect)
	body = render_template(step["body_template"], prospect)

	personalization: dict[str, Any] = {"method": "template"}
	unsub_token = _generate_unsub_token(send_id)
	personalization["unsub_token"] = unsub_token

	# AI personalisation (optional, per step)
	if step.get("ai_personalize"):
		body = await ai_personalise(
			body,
			prospect,
			prompt_override=step.get("ai_personalize_prompt"),
			use_case=campaign.get("use_case", "general"),
		)
		personalization["method"] = "ai"
		personalization["prompt"] = step.get("ai_personalize_prompt")

	# Inject unsubscribe link
	backend_url = os.environ.get("DSC_BACKEND_URL", "http://127.0.0.1:8000")
	unsub_url = f"{backend_url}/api/outreach/unsubscribe/{unsub_token}"
	if "{{unsubscribe_link}}" in body:
		body = body.replace("{{unsubscribe_link}}", unsub_url)
	else:
		body = body + f"\n\n---\nTo unsubscribe: {unsub_url}"

	# Check suppression list
	if not dry_run:
		if state_store.is_suppressed(prospect["email"]):
			return {
				"send_id": send_id,
				"campaign_id": campaign["campaign_id"],
				"step_id": step["step_id"],
				"prospect_id": prospect["prospect_id"],
				"subject": "",
				"body": "",
				"personalization": {},
				"status": "suppressed",
				"created_at": now,
			}

	send_record: dict[str, Any] = {
		"send_id": send_id,
		"campaign_id": campaign["campaign_id"],
		"step_id": step["step_id"],
		"prospect_id": prospect["prospect_id"],
		"subject": subject,
		"body": body,
		"personalization": personalization,
		"status": "preview" if dry_run else "pending",
		"scheduled_at": now,
		"created_at": now,
	}

	if dry_run:
		return send_record

	# Attempt SMTP delivery
	try:
		smtp_cfg = smtp_config or {}
		# Merge campaign sender info
		if campaign.get("sender_email"):
			smtp_cfg = {**smtp_cfg, "from_email": campaign["sender_email"]}
		if campaign.get("sender_name"):
			smtp_cfg = {**smtp_cfg, "from_name": campaign["sender_name"]}

		send_email_smtp(
			to_email=prospect["email"],
			to_name=prospect.get("name") or "",
			subject=subject,
			body=body,
			smtp_config=smtp_cfg,
		)
		send_record["status"] = "sent"
		send_record["sent_at"] = datetime.now(timezone.utc).isoformat()
	except Exception as exc:
		logger.error(
			"SMTP delivery failed for %s (campaign=%s, step=%s): %s",
			prospect["email"],
			campaign["campaign_id"],
			step["step_id"],
			exc,
		)
		send_record["status"] = "failed"
		send_record["error"] = str(exc)

	state_store.save_send(send_record)
	return send_record


# ---------------------------------------------------------------------------
# Campaign launch
# ---------------------------------------------------------------------------


async def launch_campaign(
	campaign_id: str,
	smtp_config: dict[str, Any] | None = None,
	*,
	dry_run: bool = False,
	delay_between_sends: float | None = None,
) -> dict[str, Any]:
	"""Run step 0 of a campaign for all matching prospects.

	In a real deployment this would be scheduled via APScheduler for
	subsequent steps. For now it fires the first (step_order=0) step
	immediately and returns a summary.

	Args:
		campaign_id:  ID of the campaign to launch.
		smtp_config:  Optional SMTP override (host, port, user, pass).
		dry_run:      If True, render emails but do NOT send.
		delay_between_sends: Seconds to sleep between sends (defaults to
		              ``campaign.delay_seconds`` or 1 for dry_run).

	Returns a summary dict with counts.
	"""
	campaign = state_store.get_campaign(campaign_id)
	if not campaign:
		raise ValueError(f"Campaign not found: {campaign_id}")

	steps = state_store.list_sequence_steps(campaign_id)
	if not steps:
		raise ValueError(f"Campaign has no sequence steps: {campaign_id}")

	step0 = next((s for s in steps if s["step_order"] == 0), steps[0])
	prospects = _resolve_prospects(campaign)

	if not prospects:
		return {"campaign_id": campaign_id, "total": 0, "sent": 0, "failed": 0, "skipped": 0, "dry_run": dry_run}

	# Update campaign status
	if not dry_run:
		state_store.save_campaign({**campaign, "status": "running"})

	sent = failed = skipped = 0
	daily_limit = campaign.get("daily_limit", 50)
	delay_s = delay_between_sends if delay_between_sends is not None else (
		0 if dry_run else campaign.get("delay_seconds", 60)
	)

	for i, prospect in enumerate(prospects):
		if sent >= daily_limit:
			skipped += len(prospects) - i
			break

		# Skip if already sent this step
		if not dry_run and _already_sent_step(step0["step_id"], prospect["prospect_id"]):
			skipped += 1
			continue

		if not _should_send_step(step0, prospect["prospect_id"], campaign_id):
			skipped += 1
			continue

		result = await send_step_to_prospect(campaign, step0, prospect, smtp_config, dry_run=dry_run)

		if result["status"] in ("sent", "preview"):
			sent += 1
			# Schedule follow-up steps for this prospect
			if steps and result["status"] == "sent":
				schedule_followup_steps(campaign, steps, prospect["prospect_id"], smtp_config)
		else:
			failed += 1

		# Rate limiting
		if delay_s > 0 and i < len(prospects) - 1:
			await asyncio.sleep(delay_s)

	if not dry_run:
		stats = state_store.get_campaign_stats(campaign_id)
		final_status = "paused" if failed > 0 and sent == 0 else "running"
		state_store.save_campaign({**campaign, "status": final_status, "stats": stats})

	return {
		"campaign_id": campaign_id,
		"total": len(prospects),
		"sent": sent,
		"failed": failed,
		"skipped": skipped,
		"dry_run": dry_run,
	}


# ---------------------------------------------------------------------------
# Follow-up runner (step N for a single prospect)
# ---------------------------------------------------------------------------


async def run_followup(
	campaign_id: str,
	prospect_id: str,
	step_order: int,
	smtp_config: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
	"""Send the next follow-up step (step_order) to a single prospect.

	Returns the send record, or None if the step should be skipped.
	"""
	campaign = state_store.get_campaign(campaign_id)
	if not campaign:
		raise ValueError(f"Campaign not found: {campaign_id}")

	steps = state_store.list_sequence_steps(campaign_id)
	step = next((s for s in steps if s["step_order"] == step_order), None)
	if not step:
		return None

	prospect = state_store.get_prospect(prospect_id)
	if not prospect:
		return None

	if _already_sent_step(step["step_id"], prospect_id):
		return None

	if not _should_send_step(step, prospect_id, campaign_id):
		return None

	return await send_step_to_prospect(campaign, step, prospect, smtp_config)


# ---------------------------------------------------------------------------
# Preview (no send)
# ---------------------------------------------------------------------------


async def preview_email(
	campaign_id: str,
	prospect_id: str,
	step_order: int = 0,
) -> dict[str, Any]:
	"""Render and AI-personalise an email for preview (no SMTP call)."""
	campaign = state_store.get_campaign(campaign_id)
	if not campaign:
		raise ValueError(f"Campaign not found: {campaign_id}")

	steps = state_store.list_sequence_steps(campaign_id)
	step = next((s for s in steps if s["step_order"] == step_order), None)
	if not step:
		raise ValueError(f"No step at order {step_order} for campaign {campaign_id}")

	prospect = state_store.get_prospect(prospect_id)
	if not prospect:
		raise ValueError(f"Prospect not found: {prospect_id}")

	return await send_step_to_prospect(campaign, step, prospect, dry_run=True)


def schedule_followup_steps(
	campaign: dict[str, Any],
	steps: list[dict[str, Any]],
	prospect_id: str,
	smtp_config: dict[str, Any] | None = None,
) -> int:
	"""Schedule APScheduler DateTrigger jobs for all follow-up steps (step_order > 0).

	Called after step 0 is successfully sent to a prospect.
	Returns number of jobs scheduled.
	"""
	from datetime import timedelta
	from dsc_api.services.automation_scheduler import scheduler
	from apscheduler.triggers.date import DateTrigger

	now_dt = datetime.now(timezone.utc)
	scheduled = 0

	followup_steps = [s for s in steps if s.get("step_order", 0) > 0]
	for step in sorted(followup_steps, key=lambda s: s.get("step_order", 0)):
		delay_days = int(step.get("delay_days") or 0)
		if delay_days <= 0:
			continue

		run_at = now_dt + timedelta(days=delay_days)
		job_id = f"outreach-followup:{campaign['campaign_id']}:{prospect_id}:{step['step_order']}"

		try:
			scheduler.add_job(
				_run_followup_job,
				trigger=DateTrigger(run_date=run_at),
				id=job_id,
				kwargs={
					"campaign_id": campaign["campaign_id"],
					"prospect_id": prospect_id,
					"step_order": step["step_order"],
					"smtp_config": smtp_config,
				},
				replace_existing=True,
				misfire_grace_time=86400,  # 24h grace for day-level scheduling
			)
			scheduled += 1
			logger.info(
				"Scheduled follow-up step %d for prospect %s in campaign %s at %s",
				step["step_order"], prospect_id, campaign["campaign_id"], run_at.isoformat(),
			)
		except Exception as exc:
			logger.error("Failed to schedule follow-up job %s: %s", job_id, exc)

	return scheduled


async def _run_followup_job(
	campaign_id: str,
	prospect_id: str,
	step_order: int,
	smtp_config: dict[str, Any] | None = None,
) -> None:
	"""APScheduler callback — sends a single follow-up step."""
	try:
		result = await run_followup(campaign_id, prospect_id, step_order, smtp_config)
		if result:
			logger.info(
				"Auto follow-up step %d sent to prospect %s (status=%s)",
				step_order, prospect_id, result.get("status"),
			)
	except Exception as exc:
		logger.error(
			"Auto follow-up job failed (campaign=%s, prospect=%s, step=%d): %s",
			campaign_id, prospect_id, step_order, exc,
		)
