<?php
$doisAnos = 63072000;

//session_name('Airpostid');

session_set_cookie_params([
  'secure' => true,       // Apenas HTTPS
  'httponly' => true,     // Bloqueia acesso via JavaScript
  'domain' => 'airpost.com.br',
  'samesite' => 'Strict', // Evita vazamento via terceiros
]);
// ini_set('session.gc_maxlifetime', $doisAnos);
 


include('config.php');

// Mapeamento de status da equipe (Equipes > Fluxo). Tudo la dentro e gateado
// por equipeStatusAtivo(); com a flag desligada ou a migration nao aplicada,
// e como se este include nao existisse.
// Migration: BIN/migrations/2026-07-31_equipe_status_mapa.sql
require_once(__DIR__.'/equipe_status.php');

// Navegacao MARCA > MES > CONTEUDOS do bloco "Conteúdos" do popup da tarefa.
// Sem gate nenhum: vincular um post do planejamento a uma tarefa e acao basica
// do produto, e nao um recurso de equipe.
require_once(__DIR__.'/tarefa_conteudo.php');

// =========================================================================
// VERSAO DA API DA META (Graph + Marketing) - FONTE UNICA
// -------------------------------------------------------------------------
// Cada versao da Meta vive ~2 anos. Manter em UM lugar so evita ficar com
// arquivos em versoes diferentes (ja aconteceu: robo em v22, footer em v23,
// resto em v25). Ao subir de versao, mudar SO aqui e testar as chamadas.
//
// Atual: v25.0 (fev/2026). Proxima esperada: v26.0 (~set/2026).
// Marketing API v23.0 expirou em 09/06/2026 - v24.0 e a mais antiga suportada.
// =========================================================================
if (!defined('META_API_VERSION')) define('META_API_VERSION', 'v25.0');
if (!defined('META_GRAPH_URL'))   define('META_GRAPH_URL', 'https://graph.facebook.com/' . META_API_VERSION);
if (!defined('META_IG_URL'))      define('META_IG_URL', 'https://graph.instagram.com/' . META_API_VERSION);

function verificaConexao(){
    if (!isset($_SESSION['UserID']) && isset($_COOKIE['remember_token'])) {
        $token = $_COOKIE['remember_token'];

        //echo $token ;exit();

        $sql = "
        SELECT
            u.*
        FROM usuario u
        INNER JOIN usuario_login ul ON ul.UserID = u.ID
        WHERE ul.TokenHash = ?
        ";
        
        $user = pesquisarUmComBindParam($sql, 's', $token);

        if($user['ID'] > 0){
            $_SESSION["UserID"] 		= $user['ID'];
            $_SESSION["UserEmail"] 		= $user['Email'];
            $_SESSION["UserNome"] 		= $user['Nome'];
            $_SESSION["UserImagemPerfil"] 	= $user['ImagemPerfil'];
            $_SESSION["UserStyle"] = $user['EstiloSys'];
        }
    }
}

function gerarTokenUsuario($userId) {
    $aleatorio = bin2hex(random_bytes(16)); // 32 caracteres aleatórios
    $timestamp = time(); // 10 dígitos
    $token = $userId . '_' . $timestamp . '_' . $aleatorio;

    return $token; // Exemplo: 15_1713110312_ab12cd34ef56...
}
function getUserIp() {
    if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
        return $_SERVER['HTTP_CLIENT_IP'];
    }

    if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        // Pode vir uma lista de IPs separados por vírgula
        $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
        return trim($ips[0]); // primeiro IP da cadeia
    }

    return $_SERVER['REMOTE_ADDR'] ?? 'UNKNOWN';
}
function convert_to_ascii($str) {
    // Remove caracteres de controle ASCII e outros específicos
    $cleaned_str = preg_replace('/[\x00-\x1F\x7F-\x9F\xA0]/u', '', $str);

    // Converte para ASCII
    $ascii_str = '';
    for ($i = 0; $i < mb_strlen($cleaned_str, 'UTF-8'); $i++) {
        $char = mb_substr($cleaned_str, $i, 1, 'UTF-8');
        if (mb_check_encoding($char, 'UTF-8')) {
            $ascii_str .= '&#' . mb_ord($char, 'UTF-8') . ';';
        } else {
            $ascii_str .= '&#' . ord($char) . ';';
        }
    }

    return $ascii_str;
}
function clean_and_convert_to_utf8($str) {
    // Remove caracteres de controle ASCII e outros específicos, incluindo caracteres invisíveis
    $cleaned_str = preg_replace('/[\x00-\x1F\x7F-\x9F\xA0\x{200B}-\x{200D}\x{FEFF}]/u', '', $str);

    // Normaliza a entrada de texto para NFC (Forma Normal de Composição)
    if (class_exists('Normalizer')) {
        $cleaned_str = Normalizer::normalize($cleaned_str, Normalizer::FORM_C);
    }

    // Converte para UTF-8
    $utf8_str = mb_convert_encoding($cleaned_str, 'UTF-8', 'UTF-8');

    return $utf8_str;
}


function convertYoutube($string) {
    return preg_replace(
        "/\s*[a-zA-Z\/\/:\.]*youtu(be.com\/watch\?v=|.be\/)([a-zA-Z0-9\-_]+)([a-zA-Z0-9\/\*\-\_\?\&\;\%\=\.]*)/i",
        "<iframe src=\"//www.youtube.com/embed/$2\" allowfullscreen></iframe>",
        $string
    );
}
function returnYoutubeID($string) {
    //if( preg_match( '/youtube\.com\/shorts\/(\w+\s*\/?)*([0-9]+)*$/i', $link[2], $matches ) ){



    if(stripos($string, "shorts") != false) {
        $x = str_replace('?feature=share','', explode("shorts/", $string));
        return $x[1];
    }
    else{
        return preg_replace(
            "/\s*[a-zA-Z\/\/:\.]*youtu(be.com\/watch\?v=|.be\/)([a-zA-Z0-9\-_]+)([a-zA-Z0-9\/\*\-\_\?\&\;\%\=\.]*)/i",
            "$2",
            $string
        );
    }
}


function remove_arroba($txt){
	return str_replace('@','', $txt);
  }


function antiInject($string){
    $conexao = conexaoBD();
    //$string = strip_tags($string);
    $string = mysqli_real_escape_string($conexao,$string);
    mysqli_close($conexao);
    return addslashes($string);
}


function FormataData($data){
    return  date("Y-m-d",strtotime($data));//formata para a-m-d
}

function dataView($data){
    return  date("d/m/Y h:m:s",strtotime($data));//formata para a-m-d
}
function dataViewDMY($data){
    return  date("d/m/y",strtotime($data));//formata para a-m-d
}

function retiraCaracteres($get_valor) {
	$source = array(',', '.','-','(',')', ' ');
	$replace = array('', '', '','','');
	$valor = str_replace($source, $replace, $get_valor); //remove os pontos e substitui a virgula pelo ponto
	return $valor; //retorna o valor formatado para gravar no banco
}

function dateDiff ($d1, $d2) {
    // Return the number of days between the two dates:    
    return round(abs(strtotime($d1) - strtotime($d2))/86400);
}
function tokenNew($tamanho=10, $id="", $up=false) {
	$characters = $id.'abcdefghijklmnopqrstuvwxyz0123456789';
	$charactersLength = strlen($characters);
	$randomString = '';
	for ($i = 0; $i < $tamanho; $i++) {
		$randomString .= $characters[rand(0, $charactersLength - 1)];
	}
	if($up === true) {
	  return strtoupper($id.$randomString);
	} else {
	  return $id.$randomString;
	}
  }
function CriarUsuario($cliente,$customerID){
	$conexao = conexaoBD();

	if(arrayCount(PesquisaClienteEmail( antiInject($cliente['email']) )) > 0){
		$comando = "UPDATE usuario
					SET
					Nome 							= '".antiInject($cliente['name'])."'
					,Email 							= '".antiInject($cliente['email'])."'
					,Celular 						= '".antiInject($cliente['phone_number'])."'
					,CustomerID 					= '".antiInject($customerID)."'
					,SituacaoID 					= 1
					WHERE Email = '".antiInject($cliente['email'])."'
		";
	}
	else{
		$comando = "INSERT INTO usuario
					(
					Nome
					,Senha
					,Email
					,Celular
					,DataCadastrado
					,CustomerID
					)
					VALUES
					(
						'".antiInject($cliente['name'])."'
						,'".antiInject($cliente['senha'])."'
						,'".antiInject($cliente['email'])."'
						,'".antiInject($cliente['phone_number'])."'
						,NOW()
						,'".antiInject($customerID)."'
					)
		";
	}
	mysqli_query($conexao,$comando);
	mysqli_close($conexao);
	//echo $comando;
}
function UpdateInscricaoStatus($subscription_id,$statusID){ 
	$conexao = conexaoBD();

	$comando = " UPDATE page SET SituacaoID = ".$statusID.", DataAtualizado = NOW() WHERE CustomerID = " . $subscription_id;
	//echo $comando;
	mysqli_query($conexao,$comando);
	mysqli_close($conexao);

	$page = PesquisaCliente($subscription_id);

	foreach ($page as $item){
        //insere Log page_pagamento
		InsertPagePagamento($item['ID'], $statusID, $subscription_id, $item['PlanoID']);
    }

}
function UpdateInscricaoCartao($cartao,$subscription_id){ 
	$conexao = conexaoBD();
	$comando = "UPDATE usuario 
				SET 
				CartaoNome = '".$cartao['name']."'
				,CartaoBandeira = '".$cartao['brand']."'
				,CartaoNumero = '".$cartao['number']."'
				,CartaoDataVencimento = '".$cartao['expiration']."'
				,CartaoCodigo = '".$cartao['cvv']."' 
				,DataAtualizado = NOW()  
				WHERE CustomerID = '" . $subscription_id . "'";

	mysqli_query($conexao,$comando);
	mysqli_close($conexao);
}
function UpdateInscricaoToken($compraID,$token){ 
	$conexao = conexaoBD();

	$comando = " UPDATE usuario SET Token = ".$token." WHERE CustomerID = " . $compraID;
	mysqli_query($conexao,$comando);
	mysqli_close($conexao);
}
function InsertSituacao($UsuarioID,$SituacaoID,$CustomerID,$PlanoID){
	$conexao = conexaoBD();
	$comando = "INSERT INTO usuario_pagamento
				(
				UsuarioID
				,SituacaoID
				,CustomerID
				,Data
				,PlanoID
				)
				VALUES
				(
					'".$UsuarioID."'
					,'".$SituacaoID."'
					,'".$CustomerID."'
					,NOW()
					,'".$PlanoID."'
				)
	";
	//echo $comando;
	mysqli_query($conexao,$comando);
	mysqli_close($conexao);
}
function PesquisaCliente($subscription_id){
	$conexao = conexaoBD();
	$comando = "SELECT * FROM page WHERE CustomerID = '".$subscription_id."'";
	//echo $comando;
	$retorno = mysqli_query($conexao,$comando);
	mysqli_close($conexao);
	return $retorno;
}

#PESQUISAR GERAL
function pesquisarUm($select){
	$conexao = conexaoBD();
	$retorno = mysqli_query($conexao,$select);
	mysqli_close($conexao);
	if(arrayCount($retorno)>0){
		foreach($retorno as $r){
			return $r;
		}
	}
	else
		return null;
}
function pesquisar($select){
	$conexao = conexaoBD();
	$retorno = mysqli_query($conexao,$select);
	mysqli_close($conexao);
	return $retorno;
}

// ---------------------------------------------------------------------------
// COLUNA EXISTE? (cache por request)
// ---------------------------------------------------------------------------
// O deploy manda TODOS os arquivos juntos, mas a migration é rodada à mão. Nesse
// intervalo o PHP novo pode citar uma coluna que ainda não existe — e um SELECT
// que cita coluna inexistente falha no prepare, derrubando a tela inteira (não
// só o campo novo). Use isto para citar a coluna só quando ela já está lá:
//
//   $col = colunaExiste('perfis','LinkedinNome') ? 'LinkedinNome' : "'' AS LinkedinNome";
//
// $tabela/$coluna são nomes vindos do CÓDIGO, nunca do usuário — o regex abaixo
// existe só para garantir que continue assim (SHOW COLUMNS não aceita bind).
function colunaExiste($tabela, $coluna){
	static $cache = [];

	if(!preg_match('/^[A-Za-z0-9_]+$/', $tabela) || !preg_match('/^[A-Za-z0-9_]+$/', $coluna))
		return false;

	if(!isset($cache[$tabela])){
		$cache[$tabela] = [];
		$r = pesquisar("SHOW COLUMNS FROM `".$tabela."`");
		if($r){
			while($linha = mysqli_fetch_assoc($r)){
				// MySQL não diferencia maiúsculas em nome de coluna; a chave também não.
				$cache[$tabela][strtolower($linha['Field'])] = true;
			}
		}
	}

	return isset($cache[$tabela][strtolower($coluna)]);
}
function pesquisarV2($select){
	$conexao = conexaoBDsubPaginas();
	$retorno = mysqli_query($conexao,$select);
	mysqli_close($conexao);
	return $retorno;
}
function insert($select){
	$conexao = conexaoBD();
	$retorno = mysqli_query($conexao,$select);
	$last_id = mysqli_insert_id($conexao);
	mysqli_close($conexao);
	return $last_id;
}
function delete($select){
	$conexao = conexaoBD();
	$retorno = mysqli_query($conexao,$select);
	mysqli_close($conexao);
}
function PesquisaClienteEmail($email){
	$conexao = conexaoBD();
	$comando = "SELECT * FROM usuario WHERE Email = '".$email."'";
	$retorno = mysqli_query($conexao,$comando);
	mysqli_close($conexao);
	return $retorno;
}

#PESQUISAR PAGINAS DE DADOS
function pesquisarUmSubPaginas($select){
	$conexao = conexaoBDsubPaginas();
	$retorno = mysqli_query($conexao,$select);
	mysqli_close($conexao);
	if(arrayCount($retorno)>0){
		foreach($retorno as $r){
			return $r;
		}
	}
	else
		return null;
}
function pesquisarSubPaginas($select){
	$conexao = conexaoBDsubPaginas();
	$retorno = mysqli_query($conexao,$select);
	mysqli_close($conexao);
	return $retorno;
}
function execSubPaginas($select){
	$conexao = conexaoBDsubPaginas();
	mysqli_query($conexao,$select);
	mysqli_close($conexao);
}
function insertSubPaginas($select){
	$conexao = conexaoBDsubPaginas();
	$retorno = mysqli_query($conexao,$select);
	$last_id = mysqli_insert_id($conexao);
	mysqli_close($conexao);
	return $last_id;
}
function deleteSubPaginas($select){
	$conexao = conexaoBDsubPaginas();
	$retorno = mysqli_query($conexao,$select);
	mysqli_close($conexao);
}
#--------------

function InsertWebhook($json, $event){
	$conexao = conexaoBD();
	$comando = "INSERT INTO webhook
				(
				Json
				,Evento
				,Data
				)
				VALUES
				(
					'".$json."'
					,'".$event."'
					,NOW()
				)
	";
	//echo $comando;
	mysqli_query($conexao,$comando);
	mysqli_close($conexao);
}
function InsertPagePagamento($pageID, $situacaoID, $customerID, $planoID){
	$conexao = conexaoBD();
	$comando = "INSERT INTO page_pagamento
				(
                    PageID, SituacaoID, CustomerID, PlanoID, Data
				)
				VALUES
				(
					'".$pageID."'
					,'".$situacaoID."'
					,'".$customerID."'
					,'".$planoID."'
					,NOW()
				)
	";
	//echo $comando;
	mysqli_query($conexao,$comando);
	mysqli_close($conexao);
}

function UpdateUsuarioProducao($usuarioID, $producaoID, $time){ 
	$conexao = conexaoBD();

	$comando = "SELECT * FROM usuario_producao WHERE UsuarioID = '".$usuarioID."' AND ProducaoID = '".$producaoID."'";
	$retorno = mysqli_query($conexao,$comando);
	if(arrayCount($retorno) > 0){
		$comando = " UPDATE usuario_producao SET Time = '".$time."', DataAtualizado = NOW() WHERE UsuarioID = " . $usuarioID . " AND ProducaoID = " . $producaoID . " ";
		mysqli_query($conexao,$comando);
	}
	else{
		$comando = "INSERT INTO usuario_producao
				(
				UsuarioID
				,ProducaoID
				,Time
				,DataCriado
				,DataAtualizado
				)
				VALUES
				(
					'".$usuarioID."'
					,'".$producaoID."'
					,'".$time."'
					,NOW()
					,NOW()
				)
		";
		mysqli_query($conexao,$comando);
	}
	mysqli_close($conexao);
}
function UpdateUsuarioProducaoVistoAteFinal($usuarioID, $producaoID){ 
	$conexao = conexaoBD();

	$comando = "SELECT * FROM usuario_producao WHERE UsuarioID = '".$usuarioID."' AND ProducaoID = '".$producaoID."'";
	$retorno = mysqli_query($conexao,$comando);
	if(arrayCount($retorno) > 0){
		$comando = " UPDATE usuario_producao SET AssistidoAteFinal = 1 WHERE UsuarioID = " . $usuarioID . " AND ProducaoID = " . $producaoID;
		mysqli_query($conexao,$comando);
	}
	
	mysqli_close($conexao);
}
function PesquisarUsuarioProducao($usuarioID, $producaoID){ 
	$time = 0;
	$conexao = conexaoBD();

	$comando = "SELECT * FROM usuario_producao WHERE UsuarioID = '".$usuarioID."' AND ProducaoID = '".$producaoID."'";
	$retorno = mysqli_query($conexao,$comando);
	mysqli_close($conexao);
	foreach ($retorno as $item) {
		$time = $item['Time'];
	}
	return $time;
}

function InsertRecuperarSenha($email){
	$conexao = conexaoBD();
	$comando = "INSERT INTO usuario_recuperarEmail
				(
				Email
				,Data
				)
				VALUES
				(
					'".$email."'
					,NOW()
				)
	";
	//echo $comando;
	mysqli_query($conexao,$comando);
	mysqli_close($conexao);
}

function buscaBtnConteudo($valor, $btnTipoID, $pageID){
	$r = '';
  
	if($btnTipoID==16){
	  $lstPaginas = pesquisar("SELECT *
							  FROM page_pagina 
							  WHERE PageID = ".$pageID."");
  
	
  
	  $dropdown =  '<div class="dropdown-paginas">
					  <select>';
  
	  if($valor > 0){
		$dropdown .= '<option value="0">Nenhuma</option>';
	  }
	  else{
		$dropdown .= '<option value="0" selected>Nenhuma</option>';
	  }
	  foreach($lstPaginas as $pg){
		$dropdown .= '<option value="'.base64_encode($pg['ID']).'" '.($pg['ID'] == $valor ? 'selected' : '').'>'.$pg['Titulo'].'</option>';
	  }
  
	  $dropdown .=  '</select>
					</div>';  
  
  
	  $r .= $dropdown;
	}
	return $r;
}



// function enviaEmail($emailDestinatario, $remetenteEmail, $remetenteNome, $EmailQueRecebeResposta, $titulo, $texto, $EmailsObservadores, $emailDoSite){
// 	$headers = "MIME-Version: 1.0\n";
// 	// $headers .= "Content-type: text/plain; charset=UTF-8\n";
// 	$headers .= "Content-type: text/html; charset=UTF-8\n";
// 	$headers .= "From: ".$remetenteNome." <".$remetenteEmail.">\n"; // remetente
// 	$headers .= "Return-Path: ".$remetenteEmail."\n"; // return-path
// 	$headers .= "Reply-To: ".$EmailQueRecebeResposta."\n"; // return-path
// 	if(strlen($EmailsObservadores) > 0)
// 		$headers .= "Bcc: ".$EmailsObservadores."\n";
// 	$headers .= "X-Priority: 1\n"; 
// 	$envio = mail(
// 					$emailDestinatario
// 					,$titulo
// 					,$texto
// 					,$headers
// 					,"-r". $emailDoSite
// 				);
                
// 	// if($envio)
// 	// 	return $emailDestinatario.' ---- '. $titulo;//. '----'. $texto. '-----' .$headers;
// 	// else
// 	// 	return "A mensagem não pode ser enviada";
    
// }


function preparaClass($e){
    $e = str_replace("(","A", $e);
    $e = str_replace(")","B", $e);
    $e = str_replace("$","C", $e);
    $e = str_replace("#","D", $e);
    $e = str_replace("=","E", $e);
    return $e;
}












$url_prod 		= 'https://app.vindi.com.br/api/v1/';
$key_prod 		= '8pAmRgdZ4ucHDKONgH9jGvs8b1ClLvYTxfd8g4PwOUc';


function alterarNome($customerid, $key_prod, $nome, $email){
    $url = 'https://app.vindi.com.br/api/v1/customers/'.$customerid;
    $json = '
      {
        "name": "'.$nome.'",
        "email": "'.$email.'"
      }
    ';


    $json = vindiPost($url, $key_prod, $json, 'PUT');
    //echo $json;
}
function desativarAssinatura($key_prod, $assinatuaID){
    $url = 'https://app.vindi.com.br/api/v1/subscriptions/'.$assinatuaID;
    vindiGet($url, $key_prod, 'DELETE');
}
function ativarAssinatura($key_prod, $assinatuaID){
    $url = 'https://app.vindi.com.br/api/v1/subscriptions/'.$assinatuaID.'/reactivate';
    vindiGet($url, $key_prod, 'POST');
}
function getAssinatura($key_prod, $query){
    $url = 'https://app.vindi.com.br/api/v1/subscriptions?query='.$query;

    $json = vindiGet($url, $key_prod, 'GET');
    //echo $json ;

    $lst = json_decode($json);
    if(isset($lst->subscriptions[0])){
        $item = $lst->subscriptions[0];
        return $item;
    }
    else{
        return 0;
    }
}

function cobrarCobranca($key_prod, $cobrancaID){
           
    $url = 'https://app.vindi.com.br/api/v1/charges/'.$cobrancaID.'/charge';

    $json = vindiPost($url, $key_prod, '', 'POST');
    //echo '<br/> COBRANCA: '.$json;

    $jsonDados = json_decode($json);
    if(isset($jsonDados)){
        $charge = $jsonDados;
        return $charge;
    }
    else{
        return 0;
    }
}


function getFaturaLstPendente($key_prod, $customerID){
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status:pending&sort_by=created_at&sort_order=desc';

    $json = vindiGet($url, $key_prod, 'GET');
    //echo ' <br/> FATURA: '. $json;

    $jsonDados = json_decode($json);
    if(isset($jsonDados->bills[0])){
        $faturas = $jsonDados->bills;
        return $faturas;
    }
    else{
        return 0;
    }
}

function getFaturaLst($key_prod, $customerID){
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20-status:canceled&sort_by=created_at&sort_order=desc';

    $json = vindiGet($url, $key_prod, 'GET');
    //echo ' <br/> FATURA: '. $json;

    $jsonDados = json_decode($json);
    if(isset($jsonDados->bills[0])){
        $faturas = $jsonDados->bills;
        return $faturas;
    }
    else{
        return 0;
    }
}

function getFatura($key_prod, $customerID){
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status:pending';

    $json = vindiGet($url, $key_prod, 'GET');
    //echo ' <br/> FATURA: '. $json;

    $jsonDados = json_decode($json);
    if(isset($jsonDados->bills[0])){
        $i = 0;
        $agr = 0;
        foreach($jsonDados->bills as $bill)
            $i++;
        foreach($jsonDados->bills as $bill){
            $agr++;
            if($i == $agr){
                $fatura = $bill;
                return $fatura;
            }
        }
        return 0;
    }
    else{
        return 0;
    }
}
function getFaturaLast($key_prod, $customerID){
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'';

    $json = vindiGet($url, $key_prod, 'GET');
    //echo ' <br/> FATURA: '. $json;

    $jsonDados = json_decode($json);
    if(isset($jsonDados->bills[0])){
        $i = 0;
        $agr = 0;
        foreach($jsonDados->bills as $bill)
            $i++;
        foreach($jsonDados->bills as $bill){
            $agr++;
            if($i == $agr){
                $fatura = $bill;
                return $fatura;
            }
        }
        return 0;
    }
    else{
        return 0;
    }
}
function getFaturaPendente($key_prod, $customerID){
    //$url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status:pending';
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status:pending';

    $json = vindiGet($url, $key_prod, 'GET');
    //echo ' <br/> FATURA: '. $json;

    $jsonDados = json_decode($json);
    if(isset($jsonDados->bills[0])){
        $i = 0;
        $agr = 0;
        foreach($jsonDados->bills as $bill)
            $i++;
        foreach($jsonDados->bills as $bill){
            $agr++;
            if($i == $agr){
                $fatura = $bill;
                return $fatura;
            }
        }
        return 0;
    }
    else{
        return 0;
    }
}
function getFaturaPendenteLst($key_prod, $customerID){
    //$url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status:pending';
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status:pending';

    $json = vindiGet($url, $key_prod, 'GET');
    //echo ' <br/> FATURA: '. $json;

    $jsonDados = json_decode($json);
    if(isset($jsonDados->bills[0])){
        $fatura = $jsonDados->bills;
        return $fatura;
    }
    else{
        return 0;
    }
}
function getFaturaPaga($key_prod, $customerID){
    //$url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status:pending';
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status:paid';

    $json = vindiGet($url, $key_prod, 'GET');
    //echo ' <br/> FATURA: '. $json;

    $jsonDados = json_decode($json);
    if(isset($jsonDados->bills[0])){
        $i = 0;
        $agr = 0;
        foreach($jsonDados->bills as $bill)
            $i++;
        foreach($jsonDados->bills as $bill){
            $agr++;
            if($i == $agr){
                $fatura = $bill;
                return $fatura;
            }
        }
        return 0;
    }
    else{
        return 0;
    }
}

function inativaPerfilPagamentoAtual($customerid, $key_prod){
    //PESQUISA PROFILE DE PAGAMENTO
   $url = 'https://app.vindi.com.br/api/v1/payment_profiles?query=customer_id:'.$customerid.'%20status=active';
   $cartaoDados = json_decode(vindiGet($url, $key_prod, 'GET'));

   if(isset($cartaoDados->payment_profiles[0])){
        //DELETA PROFILE DE PAGAMENTO
        $pagtoProfileID = $cartaoDados->payment_profiles[0]->id;
        if($pagtoProfileID > 0){
            $url = 'https://app.vindi.com.br/api/v1/payment_profiles/'.$pagtoProfileID;
            vindiGet($url, $key_prod, 'DELETE');
        }
    }
    else{
        return 0;
    }
}


function createPerfilPagamento($key_prod, $customerID, $cartao){
    $json = '
    {
        "holder_name": "'.$cartao['name'].'",
        "card_expiration": "'.$cartao['expiration'].'",
        "card_number": "'.$cartao['number'].'",
        "card_cvv": "'.$cartao['cvv'].'",
        "payment_method_code": "credit_card",
        "payment_company_code": "'.$cartao['brand'].'",
        "customer_id": '.$customerID.'
      }
    ';

    $url = 'https://app.vindi.com.br/api/v1/payment_profiles';

    $json = vindiPost($url, $key_prod, $json, 'POST');
    //echo $json;

    $jsonDados = json_decode($json);
    $item = $jsonDados->payment_profile;
    if(isset($item->id)){
        return $item;
    }
    else{
        return 0;
    }
}


function createSubscription($key_prod, $planoID, $customerID, $metodoPagamento, $produtoID){
    $json = '
    {
        "plan_id": '.$planoID.',
        "customer_id": '.$customerID.',
        "payment_method_code": "'.$metodoPagamento.'",
        "product_items": [
          { "product_id": '.$produtoID.' }
        ]
      }
    ';

    $url = 'https://app.vindi.com.br/api/v1/subscriptions';

    $jsonGetCustomer = vindiPost($url, $key_prod, $json, 'POST');
    //echo $jsonGetCustomer;

    $subscriptionDados = json_decode($jsonGetCustomer);
    $subscription = $subscriptionDados->subscription;
    if(isset($subscription->id)){
        return $subscription;
    }
    else{
        return 0;
    }
}
function createSubscription_v2($key_prod, $planoID, $customerID, $metodoPagamento, $produtoID, $qt){
    $json = '
    {
        "plan_id": '.$planoID.',
        "customer_id": '.$customerID.',
        "payment_method_code": "'.$metodoPagamento.'",
        "product_items": [
          { "product_id": '.$produtoID.', "quantity": '.$qt.' }
        ]
      }
    ';

    $url = 'https://app.vindi.com.br/api/v1/subscriptions';

    $jsonGetCustomer = vindiPost($url, $key_prod, $json, 'POST');
    //echo $jsonGetCustomer;

    $subscriptionDados = json_decode($jsonGetCustomer);
    $subscription = $subscriptionDados->subscription;
    if(isset($subscription->id)){
        return $subscription;
    }
    else{
        return 0;
    }
}

function situacaoNomePt($s){
	$r = '';
	if($s == 'paid')
		$r = 'Pago';
	else if($s == 'pending')
		$r = 'Aguardando pagamento';
	else
		$r = $s;

	return $r;
}


function getSubscriptions($key_prod, $query){
    $url = 'https://app.vindi.com.br/api/v1/subscriptions?query='.$query;

    $jsonGetCustomer = vindiGet($url, $key_prod, 'GET');
    //echo $jsonGetCustomer;

    $subscriptionsLst = json_decode($jsonGetCustomer);
    if(isset($subscriptionsLst->subscriptions[0])){
        $subscriptions = $subscriptionsLst->subscriptions[0];
        return $subscriptions;
    }
    else{
        return 0;
    }
}
function getSubscriptionsList($key_prod, $query){
    $url = 'https://app.vindi.com.br/api/v1/subscriptions?query='.$query;
//echo $url; 
    $jsonGetCustomer = vindiGet($url, $key_prod, 'GET');
    //echo $jsonGetCustomer;

    $subscriptionsLst = json_decode($jsonGetCustomer);
    if(isset($subscriptionsLst->subscriptions[0])){
        $subscriptions = $subscriptionsLst->subscriptions;


        return $subscriptions;
    }
    else{
        //echo $jsonGetCustomer;
        return 0;
    }
}


function createCustomer($key_prod, $nome, $email, $cpf, $street, $number, $cep, $bairro, $cidade, $uf){
    $json = '
    {
        "name": "'.$nome.'",
        "email": "'.$email.'",
        "registry_code": "'.$cpf.'",
        "code": "",
        "notes": "Site AirPage",';

    $json .= '
        "address": {
          "street": "'.$street.'",
          "number": "'.$number.'",
          "additional_details": "",
          "zipcode": "'.$cep.'",
          "neighborhood": "'.$bairro.'",
          "city": "'.$cidade.'",
          "state": "'.$uf.'",
          "country": "BR"
        }';
    $json .= '
      }
    ';

    $url = 'https://app.vindi.com.br/api/v1/customers?query=';

    $jsonGetCustomer = vindiPost($url, $key_prod, $json, 'POST');
    $customerDados = json_decode($jsonGetCustomer);
    $customer = $customerDados->customer;
    if(isset($customer->id)){
        return $customer;
    }
    else{
        return 0;
    }
}

function getCustomer($key_prod, $query){
    $url = 'https://app.vindi.com.br/api/v1/customers?query='.$query;

    $jsonGetCustomer = vindiGet($url, $key_prod, 'GET');
    //echo $jsonGetCustomer ;

    $csutomerLst = json_decode($jsonGetCustomer);
    if(isset($csutomerLst->customers[0])){
        $customer = $csutomerLst->customers[0];
        return $customer;
    }
    else{
        return 0;
    }

    //{"customers":[{"id":27134533,"name":"estern","email":"contato@estern.com.br","registry_code":"05853995000157","code":null,"notes":"","status":"active","created_at":"2021-12-08T11:15:36.000-03:00","updated_at":"2021-12-08T11:35:21.000-03:00","metadata":{},"address":{"street":"Rua Graciliano Ramos","number":"3540","additional_details":null,"zipcode":"93610100","neighborhood":"União","city":"Estância Velha","state":"RS","country":"BR"},"phones":[]}]}
}
function getCustomerList($key_prod, $query){
    $url = 'https://app.vindi.com.br/api/v1/customers?query='.$query;

    $jsonGetCustomer = vindiGet($url, $key_prod, 'GET');
    //echo $jsonGetCustomer ;

    $csutomerLst = json_decode($jsonGetCustomer);
    if(isset($csutomerLst->customers[0])){
        $customer = $csutomerLst->customers;
        return $customer;
    }
    else{
        return 0;
    }

    //{"customers":[{"id":27134533,"name":"estern","email":"contato@estern.com.br","registry_code":"05853995000157","code":null,"notes":"","status":"active","created_at":"2021-12-08T11:15:36.000-03:00","updated_at":"2021-12-08T11:35:21.000-03:00","metadata":{},"address":{"street":"Rua Graciliano Ramos","number":"3540","additional_details":null,"zipcode":"93610100","neighborhood":"União","city":"Estância Velha","state":"RS","country":"BR"},"phones":[]}]}
}
function getCustomerPeloID($key_prod, $customerid){
    $url = 'https://app.vindi.com.br/api/v1/customers/'.$customerid;

    $jsonGetCustomer = vindiGet($url, $key_prod, 'GET');
	//echo '</br>';
    //echo $jsonGetCustomer;
    
    $customerDados = json_decode($jsonGetCustomer);
    if(isset($customerDados->customer->id)){
		$customer = $customerDados->customer;
        return $customer;
    }
    else{
        return 0;
    }
}

function getUrlBoleto($key_prod, $customerID){
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status=pending';
    //echo vindiGet($url, $key_prod, 'GET');
    $get_data = json_decode(vindiGet($url, $key_prod, 'GET'));
    return $get_data->bills[0]->charges[0]->print_url;
}
function getCobranca($key_prod, $customerID){
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'%20status=pending';
    //echo vindiGet($url, $key_prod, 'GET');
    $get_data = json_decode(vindiGet($url, $key_prod, 'GET'));
	if(isset($get_data->bills[0]))
    	return $get_data->bills[0]->charges[0];
	else
		return 0;
}
function getCobrancaLast($key_prod, $customerID){
    $url = 'https://app.vindi.com.br/api/v1/bills?query=customer_id:'.$customerID.'';
    //echo vindiGet($url, $key_prod, 'GET');
    $jsonDados = json_decode(vindiGet($url, $key_prod, 'GET'));
    if(isset($jsonDados->bills[0])){
        $i = 0;
        $agr = 0;
        foreach($jsonDados->bills as $bill)
            $i++;
        foreach($jsonDados->bills as $bill){
            $agr++;
            if($i == $agr){
                $fatura = $bill;
                return $fatura;
            }
        }
        return 0;
    }
    else{
        return 0;
    }
}

function getPerfilPagamentoAtual($key_prod, $customerid){
    //PESQUISA PROFILE DE PAGAMENTO
   $url = 'https://app.vindi.com.br/api/v1/payment_profiles?query=customer_id:'.$customerid.'%20status=active';
   $cartaoDados = json_decode(vindiGet($url, $key_prod, 'GET'));
   
   //echo $url;
   //return $cartaoDados;

   if(isset($cartaoDados->payment_profiles[0])){
        //DELETA PROFILE DE PAGAMENTO
        $pagtoProfileID = $cartaoDados->payment_profiles[0]->id;
        if($pagtoProfileID > 0){
            return $cartaoDados->payment_profiles[0];
        }
    }
    else{
        return 0;
    }
}


function vindiGet($url, $key_prod, $type){
    $curl = curl_init();

    curl_setopt_array($curl, array(
    CURLOPT_URL => $url,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_ENCODING => '',
    CURLOPT_MAXREDIRS => 10,
    CURLOPT_TIMEOUT => 0,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
    CURLOPT_CUSTOMREQUEST => $type,
    CURLOPT_HTTPHEADER => array(
        'API_KEY: '.$key_prod,
        'Authorization: Basic OHBBbVJnZFo0dWNIREtPTmdIOWpHdnM4YjFDbEx2WVR4ZmQ4ZzRQd09VYzo='
    ),
    ));

    $response = curl_exec($curl);

    curl_close($curl);
    return $response;
 }
 function vindiPost($url, $key_prod, $json, $type){
    $curl = curl_init();

    curl_setopt_array($curl, array(
    CURLOPT_URL => $url,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_ENCODING => '',
    CURLOPT_MAXREDIRS => 10,
    CURLOPT_TIMEOUT => 0,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
    CURLOPT_CUSTOMREQUEST => $type,
    CURLOPT_POSTFIELDS => $json,
    CURLOPT_HTTPHEADER => array(
        'API_KEY: '.$key_prod,
        'Authorization: Basic OHBBbVJnZFo0dWNIREtPTmdIOWpHdnM4YjFDbEx2WVR4ZmQ4ZzRQd09VYzo=',
        'Content-Type: application/json'
    )
    ));

    $response = curl_exec($curl);

    curl_close($curl);
    return $response;
 }


// Função para gerar uma miniatura do PDF
function gerarThumbnailPDF($caminhoPDF, $caminhoThumbnail) {
    if (!extension_loaded('imagick')) {
        return false;
    }
    
    try {
        $imagick = new Imagick();
        
        // Define uma resolução maior para melhor qualidade inicial
        $imagick->setResolution(120, 120); // 150 DPI é um equilíbrio entre qualidade e peso

        $imagick->readImage($caminhoPDF . '[0]'); // Ler apenas a primeira página
        
        // Configurações para melhorar a qualidade
        $imagick->setImageCompression(Imagick::COMPRESSION_JPEG); // Compressão JPEG
        $imagick->setImageCompressionQuality(85); // Qualidade JPEG (0 a 100)
        $imagick->setInterlaceScheme(Imagick::INTERLACE_PLANE); // Para otimização na web

        $imagick->setImageFormat('jpeg'); // Definir formato JPEG
        
        // Ajusta a largura/altura mantendo a proporção
        $imagick->thumbnailImage(800, 800, true); // Tamanho final da miniatura

        $imagick->writeImage($caminhoThumbnail); // Salva a miniatura
        $imagick->clear();
        $imagick->destroy();

        return true;
    } catch (Exception $e) {
        return false;
    }
}

function formataDataPadrao($data) {
    // Verifica se a data foi passada e não está vazia
    if (empty($data)) {
        return null; // Retorna nulo se a data for inválida
    }

    // Tenta criar o objeto DateTime com o formato de data e hora com segundos
    $dateTime = DateTime::createFromFormat('Y-m-d H:i:s', $data);

    // Se o formato com segundos falhar, tenta o formato sem segundos
    if ($dateTime === false) {
        $dateTime = DateTime::createFromFormat('Y-m-d H:i', $data);
    }

    // Verifica novamente se foi possível criar o DateTime
    if ($dateTime === false) {
        return null; // Retorna nulo se o formato não for válido
    }

    // Retorna a data formatada para o padrão dia/mês/ano e hora:minuto
    return $dateTime->format('d/m/Y H:i');
}
function calcularDiferencaTexto($antigo, $novo) {
    $diferencasDe = [];
    $diferencasPara = [];

    // Obtém a porcentagem de similaridade entre os textos
    similar_text($antigo, $novo, $porcentagemSimilaridade);

    // Se a similaridade for baixa, podemos assumir que há mudanças
    if ($porcentagemSimilaridade < 100) {
        // Identificar as diferenças com base em uma lógica
        // Se precisar de mais precisão, podemos segmentar os textos por palavras ou frases
        $antigoArray = explode(" ", $antigo);
        $novoArray = explode(" ", $novo);

        // Comparando as palavras
        foreach ($antigoArray as $index => $palavraAntiga) {
            if (!in_array($palavraAntiga, $novoArray)) {
                $diferencasDe[] = $palavraAntiga;
            }
        }

        foreach ($novoArray as $index => $palavraNova) {
            if (!in_array($palavraNova, $antigoArray)) {
                $diferencasPara[] = $palavraNova;
            }
        }
    }

    return [
        'de' => implode(' ', $diferencasDe),
        'para' => implode(' ', $diferencasPara)
    ];
}

// Extrai os itens de checklist do HTML do editor de texto da tarefa, na ordem em que
// aparecem: [['texto' => string, 'marcado' => bool], ...]. Mantém o texto HTML-escapado
// (sem html_entity_decode) — é seguro renderizar direto no front e a comparação fica
// consistente entre o "antes" e o "depois" (ambos vêm da mesma serialização). Usado para
// gerar log "bonito" ao marcar/desmarcar item, já que o diff por palavras do saveLogPerfis
// não distingue esse tipo de mudança (todo checkbox vira o mesmo token checked="checked").
function extrairChecklistItens($html){
    $itens = [];
    if(!$html) return $itens;

    if(preg_match_all('/<div[^>]*class="[^"]*\bchecklist-item\b[^"]*"[^>]*>\s*<input([^>]*)>(.*?)<\/div>/is', $html, $matches, PREG_SET_ORDER)){
        foreach($matches as $m){
            $marcado = (bool) preg_match('/\bchecked\b/i', $m[1]);

            $texto = '';
            if(preg_match('/<span[^>]*>(.*?)<\/span>/is', $m[2], $ms))
                $texto = $ms[1];

            $texto = trim(preg_replace('/\s+/u', ' ', strip_tags($texto)));

            $itens[] = ['texto' => $texto, 'marcado' => $marcado];
        }
    }

    return $itens;
}

/**
 * Marca (PerfisID) de uma tarefa, para o log nao nascer sem escopo.
 * tarefa.PerfisID e o caminho direto; tarefa_perfis cobre a tarefa multi-marca.
 * Devolve 0 quando a tarefa realmente nao tem marca — que e um caso valido
 * (tarefa interna), nao um erro.
 */
function perfisIDdaTarefa($tarefaID){
    $tarefaID = (int)$tarefaID;
    if($tarefaID <= 0) return 0;

    $r = pesquisarUmComBindParam("SELECT PerfisID FROM tarefa WHERE ID = ?", 'i', $tarefaID);
    if(isset($r['PerfisID']) && (int)$r['PerfisID'] > 0) return (int)$r['PerfisID'];

    $r = pesquisarUmComBindParam("SELECT PerfisID FROM tarefa_perfis WHERE TarefaID = ? LIMIT 1", 'i', $tarefaID);
    return isset($r['PerfisID']) ? (int)$r['PerfisID'] : 0;
}

function saveLogPerfis($perfisID, $userID, $postID, $acao, $para, $campo, $tarefa = false, $lista = false, $espaco = false, $autorExterno = '', $paraID = null){
    // FASE 0 — TRANSICAO POR ID.
    // De/Para guardam o NOME do status e as metricas refazem o join por
    // UPPER(Titulo): renomear um status apaga o historico em silencio.
    // A gravacao passa a ser DUPLA (texto E id) para nada que le hoje quebrar.
    //
    // $paraID vem do chamador porque so ele tem o id do DESTINO. O de ORIGEM e
    // lido aqui do banco, o que e seguro: saveLogPerfis e sempre chamado ANTES
    // do UPDATE que muda o status.
    //
    // ATENCAO as sentinelas de tarefa: 0 = status inicial da lista, 1 = final,
    // e NENHUMA existe como linha (AD.php:2514-2535). Por isso o tipo aqui e
    // "int ou null", nunca "int > 0" — 0 e um id valido.
    // $autorExterno: nome de quem agiu SEM login (aprovação por link/convidado). Quando
    // preenchido, o display do histórico mostra esse nome no lugar do usuario (UserID fica
    // como fallback só p/ o INNER JOIN não derrubar a linha).

    $notificacao = 0;
    $uID = $para;
    $titulo = $_SESSION["UserNome"];
    $caminhoUrl = '?pid='.$perfisID.'&cid='.$postID;
    $mensagem = '';

    if($tarefa && !$lista && !$espaco){
        $caminhoUrl = '?tid='.$postID;
    }

    $membros = [];


    $de = '';
    $deID = null;   // FASE 0: id do status de ORIGEM (null = campo que nao e status)
    $obs = '';

    $save = 1;

    if($tarefa || $lista || $espaco){ //tarefas
        if($campo == 'Título' && $tarefa){
            $antigo = getTarefaTitulo($postID);
            $antigo = strip_tags($antigo); // Remover tags HTML

            $diferenca = calcularDiferencaTexto($antigo, $para);

            $de = strip_tags($diferenca['de']); // Remover tags HTML
            $para = strip_tags($diferenca['para']); // Remover tags HTML

            if($de == $para){
                $save = 0;
            }
        }
        else if($campo == 'Comentario'){
            // A notificação guarda só um TRECHO em texto puro do comentário.
            // Sem limpar, o HTML cru (ex.: chip de anexo com <svg> inline do PDF)
            // estoura a coluna Mensagem de perfis_log_usuario e derruba o save
            // inteiro (sobra o log órfão, o anexo não é vinculado e o front recebe
            // um fatal em vez de JSON → "não foi possível comentar").
            $mensagem = 'Comentou na tarefa: '.limitarTexto(tratarTextoHtml($para), 140);
            $para = '';
        }
        else if($campo == 'Texto' && $tarefa){
            $antigo = getTarefaTexto($postID);
            $antigo = ($antigo); // Remover tags HTML

            $diferenca = calcularDiferencaTexto($antigo, $para);

            $de = ($diferenca['de']); // Remover tags HTML
            $para = ($diferenca['para']); // Remover tags HTML

            if($de == $para){
                $save = 0;
            }

            $de = strip_tags($diferenca['de']); // Remover tags HTML
            $para = strip_tags($diferenca['para']); // Remover tags HTML
        }
        else if($campo == 'Data início' && $tarefa){
            $de = strip_tags(formataDataPadrao(getTarefaDataInicio($postID)));
            $mensagem = 'Alterou a data início da tarefa para '.$para;
        }
        else if($campo == 'Data vencimento' && $tarefa){
            $de = strip_tags(formataDataPadrao(getTarefaDataFim($postID)));
            $mensagem = 'Alterou o vencimento da tarefa para '.$para;
        }
        else if($campo == 'Prioridade' && $tarefa){
            $de = getTarefaPrioridade($postID);
            $mensagem = 'Alterou a prioridade da tarefa para '.$para;
        }
        else if($campo == 'Status'){
            $de = strip_tags(getTarefaSatus($postID));
            $rDe = pesquisarUmComBindParam("SELECT StatusID FROM tarefa WHERE ID = ?", 'i', (int)$postID);
            $deID = isset($rDe['StatusID']) ? (int)$rDe['StatusID'] : null;
            $para = mb_strtoupper($para, 'UTF-8');

            $mensagem = 'Alterou o status da tarefa para '.$para;
        }
        else if($campo == 'Membro'){
            $notificacao = 1;

            if($acao==15)
                $mensagem = 'Adicionou você a tarefa';
            if($acao==16)
                $mensagem = 'Removeu você da tarefa';
        }
    }
    else{ // conteudos

        if($campo == 'Status'){
            $de = strip_tags(getPostSatus($postID));
            $rDe = pesquisarUmComBindParam("SELECT StatusID FROM perfis_posts WHERE ID = ?", 'i', (int)$postID);
            $deID = isset($rDe['StatusID']) ? (int)$rDe['StatusID'] : null;
            $mensagem = 'Alterou o status do conteúdo para '.$para.'. #'.$postID;
            if($de == $para){
                return 0;
            }
        }
        else if($campo == 'Comentario'){
            $titulo .= ' #'.$postID;
            // Mesmo motivo do branch de tarefa: só um trecho em texto puro,
            // para o HTML do comentário não estourar a coluna Mensagem.
            $mensagem = 'Comentou no conteúdo: '.limitarTexto(tratarTextoHtml($para), 140);
            $para = '';
        }
        else if($campo == 'Tipo'){
            $de = (getPostTipo($postID));
            $mensagem = 'Alterou o Tipo do conteúdo de '.$de.' para '.$para.'. #'.$postID;

            if($de == $para){
                $save = 0;
            }
        }
        else if($campo == 'Local'){
            $de = (getPostLocal($postID));
            $mensagem = 'Alterou o Local do conteúdo de '.$de.' para '.$para.'. #'.$postID;

            if($de == $para){
                $save = 0;
            }
        }
        else if($campo == 'AgendarFacebook' || $campo == 'AgendarInstagram')
            $de = $para == 'Ativo' ? 'Inativo' : 'Ativo';
        else if($campo == 'Data')
            $de = strip_tags(formataDataPadrao(getPostData($postID)));
        else if($campo == 'DataEntrega'){
            /* Diferente de 'Data', a entrega pode ser LIMPA (volta a NULL) e
               pode nao ter valor anterior — sem os dois rotulos abaixo o log
               sairia "alterou de  para " e ninguem entenderia a linha. */
            $anterior = getPostDataEntrega($postID);
            $de = $anterior ? strip_tags(formataDataPadrao($anterior)) : 'sem prazo';
            if($para === '' || $para === null) $para = 'sem prazo';

            if($de == $para){
                $save = 0;
            }
        }
        else if($campo == 'Legenda'){
            $antigo = getPostLegenda($postID);
            $antigo = strip_tags($antigo); // Remover tags HTML

            $diferenca = calcularDiferencaTexto($antigo, $para);

            $de = strip_tags($diferenca['de']); // Remover tags HTML
            $para = strip_tags($diferenca['para']); // Remover tags HTML

            if($de == $para){
                $save = 0;
            }
        }
        else if($campo == 'Observacao'){
            $antigo = getPostObservacao($postID);
            $antigo = strip_tags($antigo); // Remover tags HTML

            $diferenca = calcularDiferencaTexto($antigo, $para);

            $de = strip_tags($diferenca['de']); // Remover tags HTML
            $para = strip_tags($diferenca['para']); // Remover tags HTML

            if($de == $para){
                $save = 0;
            }
        }
        else if($campo == 'Membro'){
            $notificacao = 1;

            if($acao==15)
                $mensagem = 'Adicionou você ao conteúdo #'.$postID;
            if($acao==16)
                $mensagem = 'Removeu você do conteúdo #'.$postID;
        }
    }

    
    if($mensagem != '')
        $notificacao = true;
    
   

    // Verifica se deve salvar
    $id = 0;
    if($save == 1){

        if($tarefa || $lista || $espaco){
            if($lista){
                $sql = "
                        INSERT INTO perfis_log 
                        (EspacoID, ListaID, UserID, AcaoID, De, Para, Campo) 
                        VALUES (?, ?, ?, ?, ?, ?, ?)
                    ";
                $id = insertComBindParam($sql, 'iiiisss', $perfisID, $postID, $userID, $acao, $de, $para, $campo);
            }
            else if($espaco){
                $sql = "
                        INSERT INTO perfis_log 
                        (EspacoID, UserID, AcaoID, De, Para, Campo) 
                        VALUES (?, ?, ?, ?, ?, ?)
                    ";
                $id = insertComBindParam($sql, 'iiisss', $perfisID, $userID, $acao, $de, $para, $campo);
            }
            else{

                // PerfisID vazio no log de tarefa era o outro motivo de a medicao
                // "medir um pipeline vazio": sem marca, a tarefa some de toda
                // consulta que recorta por marca. Resolvido aqui, na escrita.
                $marcaDaTarefa = perfisIDdaTarefa($postID);

                $sql = "
                        INSERT INTO perfis_log 
                        (PerfisID, EspacoID, TarefaID, UserID, AcaoID, De, DeID, Para, ParaID, Campo) 
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    ";
                $id = insertComBindParam($sql, 'iiiiisisis', $marcaDaTarefa, $perfisID, $postID, $userID, $acao, $de, $deID, $para, $paraID, $campo);

                if($notificacao && $campo == 'Membro'){
                    $ttl = limitarTexto(getTarefaTitulo($postID));
                    $titulo .= ' - '.$ttl.' #'.$postID;

                    enviarNotificacaoOneSignal($uID, $titulo, $mensagem, $caminhoUrl);

                    //SALVA NOTIFICAO NO SISTEMA, PARA O USUARIO
                    salvaNotificacaoUsuario($uID, $id, $ttl, $mensagem, $userID);
                }
                else if($notificacao){
                    $membros = getTarefaMembros($postID);

                    if(arrayCount($membros) > 0){
                        $ttl = limitarTexto(getTarefaTitulo($postID));
                        $titulo .= ' - '.$ttl.' #'.$postID;

                        foreach($membros as $membro){
                            enviarNotificacaoOneSignal($membro['ID'], $titulo, $mensagem, $caminhoUrl);

                            //SALVA NOTIFICAO NO SISTEMA, PARA O USUARIO
                            salvaNotificacaoUsuario($membro['ID'], $id, $ttl, $mensagem, $userID);
                        }
                    }
                }
            
               

            }
        }
        else{

            $sql = "
                    INSERT INTO perfis_log
                    (PerfisID, PostID, UserID, AutorExterno, AcaoID, De, DeID, Para, ParaID, Campo)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ";
            // NULL quando vazio -> display usa COALESCE(pl.AutorExterno, u.Nome) sem aspas
            $autorExternoVal = ($autorExterno !== '' && $autorExterno !== null) ? $autorExterno : null;
            $id = insertComBindParam($sql, 'iiisisisis', $perfisID, $postID, $userID, $autorExternoVal, $acao, $de, $deID, $para, $paraID, $campo);


            if($notificacao && $campo == 'Membro'){
                $ttl = limitarTexto(getPostPerfilNome($perfisID));
                $titulo .= ' - '.$ttl;
                enviarNotificacaoOneSignal($uID, $titulo, $mensagem, $caminhoUrl);

                //SALVA NOTIFICAO NO SISTEMA, PARA O USUARIO
                salvaNotificacaoUsuario($uID, $id, $ttl, $mensagem, $userID);
            }
            else if($notificacao){
                $membros = getPostMembros($postID);
                if(arrayCount($membros) > 0){
                    $ttl = limitarTexto(getPostPerfilNome($perfisID));
                    $titulo .= ' - '.$ttl;

                    $mensagem = tratarTextoHtml($mensagem);

                    foreach($membros as $membro){
                        enviarNotificacaoOneSignal($membro['ID'], $titulo, $mensagem, $caminhoUrl);

                        //SALVA NOTIFICAO NO SISTEMA, PARA O USUARIO
                        salvaNotificacaoUsuario($membro['ID'], $id, $ttl, $mensagem, $userID);

                    }
                }
            }
        
            
            
        }
    }

    return $id;
}

function salvaNotificacaoUsuario($usuarioID, $logID, $ttl, $mensagem, $userAcaoID){
    if($usuarioID > 0 && $usuarioID != $userAcaoID){
        $sql = "INSERT INTO perfis_log_usuario (UsuarioID, LogID, Titulo, Mensagem) VALUES (?, ?, ?, ?)";
        insertComBindParam($sql, 'iiss', $usuarioID, $logID, $ttl, $mensagem);
    }
}

// ============================================================================
// RESPONSÁVEL POR STATUS (config híbrida + rotação automática)
// ----------------------------------------------------------------------------
// Cada status pode ter VÁRIOS responsáveis. Os responsáveis de um item são as
// próprias linhas das tabelas de vínculo N:N existentes:
//   - Conteúdo: perfis_posts_usuarios (PerfisID, PostID, UserID)
//   - Tarefa:   tarefa_usuario       (TarefaID, UsuarioID)
// (NÃO existe coluna ResponsavelID.)
//
// Ao mudar de status: entram os responsáveis do NOVO status; saem os do status
// ANTERIOR que não respondem pelo novo. Membros manuais (que não são
// responsáveis de nenhum status envolvido) permanecem. Se o novo status não tem
// responsável, mantém os atuais.
//
// Conteúdo (statusPost é global): override por perfil (perfil_status_responsavel)
//   substitui o padrão da conta (statuspost_responsavel por OwnerID = dono do
//   perfil) quando existir ao menos uma linha de override p/ aquele status.
// Tarefa (espaco_lista_status já é por espaço): responsável direto por status.
//
// O responsável de CONTEÚDO ainda tem duas dimensões OPCIONAIS além do status:
// TipoID (tipoPost: vídeo, arte, carrossel…) e LocalID (o canal). 0 = "qualquer";
// dentro de um mesmo NÍVEL vence a regra mais específica — ver respRegraMelhor.
//
// IMPORTANTE: chamar a rotação ANTES de gravar o novo StatusID — ela lê o
// status ATUAL do item (= antigo) direto do banco.
// ============================================================================

// As colunas TipoID/LocalID vêm de migration manual
// (BIN/migrations/2026-08-12_responsavel_tipo_canal.sql). Sem elas o responsável
// degrada para "só por status" em vez de estourar erro de SQL.
function respStatusTemDimensao(){
    return equipeTemEstrutura('perfil_status_responsavel', 'TipoID');
}

// Entre as regras de um mesmo nível, devolve só as que se aplicam ao contexto e
// EMPATAM na maior especificidade. 0 em uma dimensão = "qualquer" (casa com
// tudo, mas perde para quem cravou o valor). Peso: TipoID (2) > LocalID (1).
//
// A filtragem é em PHP de propósito: cada dimensão viraria um "X = ? OR X = 0"
// no WHERE e, com duas, 4 SELECTs em UNION. O conjunto por (marca, status) é de
// poucas linhas — filtrar aqui é mais simples E mais rápido, sem OR nenhum.
// (Mesma mecânica de equipeRegraResponsavel, do lado das tarefas.)
function respRegraMelhor($regras, $tipoID, $localID){
    $tipoID = (int)$tipoID; $localID = (int)$localID;
    $melhor = -1; $vencedores = [];
    foreach($regras as $r){
        $rTipo  = (int)($r['TipoID'] ?? 0);
        $rLocal = (int)($r['LocalID'] ?? 0);
        if($rTipo  !== 0 && $rTipo  !== $tipoID)  continue;
        if($rLocal !== 0 && $rLocal !== $localID) continue;

        $peso = ($rTipo !== 0 ? 2 : 0) + ($rLocal !== 0 ? 1 : 0);
        if($peso > $melhor){ $melhor = $peso; $vencedores = [$r]; }
        elseif($peso === $melhor){ $vencedores[] = $r; }
    }
    return $vencedores;
}

// Lista de responsáveis (array de IDs) de um status de CONTEÚDO para uma marca.
// $tipoID/$localID são o contexto do conteúdo (0 = desconhecido/qualquer) e só
// servem para desempatar entre as regras do nível — a ordem dos níveis é a
// mesma de sempre.
function getResponsaveisStatusConteudo($perfisID, $statusID, $tipoID = 0, $localID = 0){
    $perfisID = (int)$perfisID;
    $statusID = (int)$statusID;
    if($perfisID <= 0 || $statusID <= 0) return [];

    $colDim = respStatusTemDimensao() ? 'TipoID, LocalID' : '0 AS TipoID, 0 AS LocalID';

    // 1) override da marca (Geral=0) — se houver QUALQUER linha que se APLIQUE ao
    //    conteúdo (incl. a sentinela de "override vazio" STATUS_RESP_VAZIO), o override
    //    é ativo e substitui o resto. Os responsáveis são só os UsuarioID > 0 (pode ser
    //    lista vazia = sem responsável). Regra específica que não casa com este conteúdo
    //    não conta como override — o status cai para o nível de baixo, como se ela não
    //    existisse.
    $ov = pesquisarComBindParam(
        "SELECT UsuarioID, $colDim FROM perfil_status_responsavel WHERE PerfisID = ? AND StatusID = ? AND Geral = 0",
        'ii', $perfisID, $statusID) ?: [];
    $ov = respRegraMelhor($ov, $tipoID, $localID);
    if(count($ov) > 0){
        $r = []; foreach($ov as $x){ $u = (int)$x['UsuarioID']; if($u > 0) $r[] = $u; } return array_values(array_unique($r));
    }

    // 2) config GERAL da equipe (Geral=1, gravada na marca) — substitui o padrão da conta.
    $ge = pesquisarComBindParam(
        "SELECT UsuarioID, $colDim FROM perfil_status_responsavel WHERE PerfisID = ? AND StatusID = ? AND UsuarioID > 0 AND Geral = 1",
        'ii', $perfisID, $statusID) ?: [];
    $ge = respRegraMelhor($ge, $tipoID, $localID);
    if(count($ge) > 0){
        $r = []; foreach($ge as $x){ $r[] = (int)$x['UsuarioID']; } return array_values(array_unique($r));
    }

    // 3) padrão da conta (dono da marca)
    $g = pesquisarComBindParam(
        "SELECT spr.UsuarioID
         FROM perfis p
         INNER JOIN statuspost_responsavel spr ON spr.OwnerID = p.UsuarioID AND spr.StatusID = ?
         WHERE p.ID = ?",
        'ii', $statusID, $perfisID) ?: [];
    $r = []; foreach($g as $x){ $r[] = (int)$x['UsuarioID']; } return $r;
}

// Lista de responsáveis (array de IDs) de um status de TAREFA.
function getResponsaveisStatusTarefa($statusID){
    $statusID = (int)$statusID;
    if($statusID <= 0) return [];
    $rows = pesquisarComBindParam(
        "SELECT UsuarioID FROM espaco_lista_status_responsavel WHERE StatusID = ?",
        'i', $statusID) ?: [];
    $r = []; foreach($rows as $x){ $r[] = (int)$x['UsuarioID']; } return $r;
}

// Clientes (usuários com permissão de Cliente) de uma marca. perfis_usuario.PermissaoID = 4.
function perfilClientes($perfisID){
    $perfisID = (int)$perfisID;
    if($perfisID <= 0) return [];
    $rows = pesquisarComBindParam(
        "SELECT UsuarioID FROM perfis_usuario WHERE PerfisID = ? AND PermissaoID = 4",
        'i', $perfisID) ?: [];
    $r = []; foreach($rows as $x){ $r[] = (int)$x['UsuarioID']; } return $r;
}

// Sentinela em perfil_status_responsavel.UsuarioID que representa "o cliente da marca"
// (marcação automática). Reaproveita o vínculo de status existente — sem tabela nova.
// As leituras/escritas de responsáveis (pessoas) filtram UsuarioID > 0 para ignorá-la.
if(!defined('STATUS_RESP_CLIENTE')) define('STATUS_RESP_CLIENTE', -1);

// Sentinela em perfil_status_responsavel.UsuarioID (tier Geral=0) que representa um
// "override de perfil ATIVO porém VAZIO" — a marca ficou SEM responsável neste status
// (≠ herdar a config Geral). As leituras de responsáveis filtram UsuarioID > 0.
if(!defined('STATUS_RESP_VAZIO')) define('STATUS_RESP_VAZIO', -2);

// O status (de conteúdo) está marcado para marcar o cliente da marca?
// = existe a linha sentinela no vínculo perfil_status_responsavel.
function perfilStatusMarcaCliente($perfisID, $statusID){
    $perfisID = (int)$perfisID; $statusID = (int)$statusID;
    if($perfisID <= 0 || $statusID <= 0) return false;
    $x = pesquisarUmComBindParam(
        "SELECT 1 AS x FROM perfil_status_responsavel WHERE PerfisID = ? AND StatusID = ? AND UsuarioID = ? LIMIT 1",
        'iii', $perfisID, $statusID, STATUS_RESP_CLIENTE);
    return isset($x['x']);
}

// Entre vários responsáveis de um status, escolhe UM por balanceamento de carga:
// o que tem MENOS itens abertos no momento (desempate aleatório). Distribui o
// trabalho de forma justa — melhor que sorteio puro, que pode sobrecarregar
// alguém por azar. $tipo = 'conteudo' (perfis_posts) | 'tarefa' (tarefa).
function escolherResponsavelMenosCarregado($candidatos, $tipo){
    $candidatos = array_values(array_unique(array_map('intval', $candidatos)));
    $candidatos = array_values(array_filter($candidatos, function($v){ return $v > 0; }));
    if(count($candidatos) <= 1) return $candidatos[0] ?? 0;

    $ph    = implode(',', array_fill(0, count($candidatos), '?'));
    $tipos = str_repeat('i', count($candidatos));

    if($tipo === 'tarefa'){
        $sql = "SELECT tu.UsuarioID AS UID, COUNT(*) AS Carga
                FROM tarefa_usuario tu
                INNER JOIN tarefa t ON t.ID = tu.TarefaID AND t.Deletado = 0
                LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
                WHERE tu.UsuarioID IN ($ph)
                  AND ".sqlTarefaAberta('t', 'els')."
                GROUP BY tu.UsuarioID";
    } else {
        $sql = "SELECT ppu.UserID AS UID, COUNT(*) AS Carga
                FROM perfis_posts_usuarios ppu
                INNER JOIN perfis_posts pp ON pp.ID = ppu.PostID AND pp.Deletado = 0
                INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
                WHERE ppu.UserID IN ($ph)
                GROUP BY ppu.UserID";
    }
    $rows = pesquisarComBindParam($sql, $tipos, ...$candidatos) ?: [];

    $carga = [];
    foreach($candidatos as $c){ $carga[$c] = 0; }            // quem não tem item aberto = carga 0
    foreach($rows as $x){ $carga[(int)$x['UID']] = (int)$x['Carga']; }

    $min = min($carga);
    $empatados = [];
    foreach($carga as $c => $n){ if($n === $min) $empatados[] = $c; }
    return $empatados[array_rand($empatados)];               // desempate aleatório
}

// Rotaciona responsáveis de um CONTEÚDO ao mudar de status. Chamar ANTES do UPDATE.
// Com VÁRIOS responsáveis no status, atribui a UM só (o menos carregado).
function rotacionarResponsavelConteudo($perfisID, $postID, $novoStatusID, $userID, $autorExterno = ''){
    // $autorExterno: nome de quem disparou a rotação SEM cadastro (aprovação por link/convidado).
    // Repassado aos logs de "Membro" abaixo para não gravarem autor NULL (UserID=0).
    $perfisID = (int)$perfisID;
    $postID   = (int)$postID;
    $novoStatusID = (int)$novoStatusID;
    if($perfisID <= 0 || $postID <= 0) return;

    // TipoID/LocalID são o contexto que desempata as regras específicas do status
    // (ver getResponsaveisStatusConteudo). Vêm do próprio conteúdo — o tipo e o canal
    // que ele JÁ tem no momento da troca de status.
    $post = pesquisarUmComBindParam("SELECT StatusID, TipoID, LocalID FROM perfis_posts WHERE ID = ? LIMIT 1", 'i', $postID);
    $statusAntigo = (int)($post['StatusID'] ?? 0);
    $postTipoID   = (int)($post['TipoID'] ?? 0);
    $postLocalID  = (int)($post['LocalID'] ?? 0);
    if($statusAntigo == $novoStatusID) return;

    // Marcação automática de CLIENTE por status (independente de responsável): adiciona
    // o(s) cliente(s) da marca ao ENTRAR num status marcado; remove ao SAIR dele para um
    // status não marcado. Roda antes do early-return de responsáveis (status pode marcar
    // cliente sem ter responsável).
    $cliNovo = perfilStatusMarcaCliente($perfisID, $novoStatusID);
    $cliAnt  = perfilStatusMarcaCliente($perfisID, $statusAntigo);
    if($cliNovo !== $cliAnt){
        foreach(perfilClientes($perfisID) as $cuid){
            $cuid = (int)$cuid; if($cuid <= 0) continue;
            if($cliNovo){
                $existe = pesquisarUmComBindParam("SELECT 1 AS x FROM perfis_posts_usuarios WHERE PostID = ? AND UserID = ? LIMIT 1", 'ii', $postID, $cuid);
                if(!isset($existe['x'])){
                    insertComBindParam("INSERT INTO perfis_posts_usuarios (PerfisID, PostID, UserID) VALUES (?, ?, ?)", 'iii', $perfisID, $postID, $cuid);
                    saveLogPerfis($perfisID, $userID, $postID, 15, $cuid, 'Membro', false, false, false, $autorExterno);
                }
            } else {
                deleteComBindParam("DELETE FROM perfis_posts_usuarios WHERE PostID = ? AND UserID = ? AND PerfisID = ?", 'iii', $postID, $cuid, $perfisID);
                saveLogPerfis($perfisID, $userID, $postID, 16, $cuid, 'Membro', false, false, false, $autorExterno);
            }
        }
    }

    $novos = getResponsaveisStatusConteudo($perfisID, $novoStatusID, $postTipoID, $postLocalID);
    if(count($novos) == 0) return; // status sem responsável -> mantém os atuais
    if(count($novos) > 1){
        $escolhido = escolherResponsavelMenosCarregado($novos, 'conteudo');
        if($escolhido > 0) $novos = [$escolhido];
    }

    // O status antigo é resolvido com o MESMO contexto: quem saiu tem de ser quem a
    // regra teria colocado ali, senão a saída não bate com a entrada.
    $antigos = getResponsaveisStatusConteudo($perfisID, $statusAntigo, $postTipoID, $postLocalID);

    // Saem os responsáveis do status antigo que não respondem pelo novo.
    foreach(array_diff($antigos, $novos) as $uid){
        $uid = (int)$uid; if($uid <= 0) continue;
        deleteComBindParam("DELETE FROM perfis_posts_usuarios WHERE PostID = ? AND UserID = ? AND PerfisID = ?", 'iii', $postID, $uid, $perfisID);
        saveLogPerfis($perfisID, $userID, $postID, 16, $uid, 'Membro', false, false, false, $autorExterno);
    }
    // Entram os responsáveis do novo status (se ainda não forem membros) + notifica.
    foreach($novos as $uid){
        $uid = (int)$uid; if($uid <= 0) continue;
        $existe = pesquisarUmComBindParam("SELECT 1 AS x FROM perfis_posts_usuarios WHERE PostID = ? AND UserID = ? LIMIT 1", 'ii', $postID, $uid);
        if(!isset($existe['x'])){
            insertComBindParam("INSERT INTO perfis_posts_usuarios (PerfisID, PostID, UserID) VALUES (?, ?, ?)", 'iii', $perfisID, $postID, $uid);
            saveLogPerfis($perfisID, $userID, $postID, 15, $uid, 'Membro', false, false, false, $autorExterno);
        }
    }
}

// Aplica o responsável do status em que um conteúdo ACABOU de nascer.
//
// rotacionarResponsavelConteudo não serve aqui: ela lê o StatusID do post no
// banco para descobrir de ONDE ele saiu, e um conteúdo recém-inserido já está
// gravado no status novo — a comparação daria "não mudou" e ela sairia sem fazer
// nada. Na criação só existe a metade de ENTRADA: ninguém sai, porque não havia
// status antes. O resto (níveis de configuração, desempate por tipo/canal,
// escolha do menos carregado) é o mesmo, via getResponsaveisStatusConteudo.
//
// $respManual = o usuário já escolheu responsável na hora de criar. Nesse caso a
// regra do status não entra por cima da escolha explícita — mas a marcação de
// cliente continua valendo, porque ela diz quem ACOMPANHA, não quem executa.
function aplicarResponsavelConteudoNovo($perfisID, $postID, $statusID, $tipoID, $localID, $userID, $respManual = false){
    $perfisID = (int)$perfisID;
    $postID   = (int)$postID;
    $statusID = (int)$statusID;
    if($perfisID <= 0 || $postID <= 0 || $statusID <= 0) return;

    // Vira membro do conteúdo quem ainda não é, com o mesmo log 15 ('Membro')
    // que a rotação grava — é o que alimenta a timeline e a notificação.
    $entrar = function($uid) use ($perfisID, $postID, $userID){
        $uid = (int)$uid; if($uid <= 0) return;
        $existe = pesquisarUmComBindParam("SELECT 1 AS x FROM perfis_posts_usuarios WHERE PostID = ? AND UserID = ? LIMIT 1", 'ii', $postID, $uid);
        if(isset($existe['x'])) return;
        insertComBindParam("INSERT INTO perfis_posts_usuarios (PerfisID, PostID, UserID) VALUES (?, ?, ?)", 'iii', $perfisID, $postID, $uid);
        saveLogPerfis($perfisID, $userID, $postID, 15, $uid, 'Membro');
    };

    // Status que marca o cliente: vale já na criação (dá para nascer direto em
    // aprovação) e é independente de haver responsável.
    if(perfilStatusMarcaCliente($perfisID, $statusID)){
        foreach(perfilClientes($perfisID) as $cuid) $entrar($cuid);
    }

    if($respManual) return;

    $novos = getResponsaveisStatusConteudo($perfisID, $statusID, $tipoID, $localID);
    if(count($novos) == 0) return;               // status sem responsável -> nasce sem dono
    if(count($novos) > 1){
        $escolhido = escolherResponsavelMenosCarregado($novos, 'conteudo');
        if($escolhido > 0) $novos = [$escolhido];
    }
    foreach($novos as $uid) $entrar($uid);
}

// Rotaciona responsáveis de uma TAREFA ao mudar de status. Chamar ANTES do UPDATE.
function rotacionarResponsavelTarefa($espacoID, $tarefaID, $novoStatusID, $userID){
    $espacoID = (int)$espacoID;
    $tarefaID = (int)$tarefaID;
    $novoStatusID = (int)$novoStatusID;
    if($tarefaID <= 0) return;

    $tarefa = pesquisarUmComBindParam("SELECT StatusID FROM tarefa WHERE ID = ? LIMIT 1", 'i', $tarefaID);
    $statusAntigo = (int)($tarefa['StatusID'] ?? 0);
    if($statusAntigo == $novoStatusID) return;

    // Regras da EQUIPE primeiro (mapeamento de status): elas aceitam dimensões
    // que espaco_lista_status_responsavel não tem — marca, tipo de conteúdo e
    // canal. Sem regra que case, cai no comportamento de hoje. É aditivo: nada
    // da rotação existente muda quando a flag está desligada.
    $novos = equipeStatusResponsavelDaTarefa($tarefaID, $novoStatusID, $userID);
    if(count($novos) == 0) $novos = getResponsaveisStatusTarefa($novoStatusID);

    if(count($novos) == 0) return; // status sem responsável -> mantém os atuais
    if(count($novos) > 1){
        $escolhido = escolherResponsavelMenosCarregado($novos, 'tarefa');
        if($escolhido > 0) $novos = [$escolhido];
    }

    $antigos = getResponsaveisStatusTarefa($statusAntigo);

    foreach(array_diff($antigos, $novos) as $uid){
        $uid = (int)$uid; if($uid <= 0) continue;
        deleteComBindParam("DELETE FROM tarefa_usuario WHERE TarefaID = ? AND UsuarioID = ?", 'ii', $tarefaID, $uid);
        saveLogPerfis($espacoID, $userID, $tarefaID, 16, $uid, 'Membro', true);
    }
    foreach($novos as $uid){
        $uid = (int)$uid; if($uid <= 0) continue;
        $existe = pesquisarUmComBindParam("SELECT 1 AS x FROM tarefa_usuario WHERE TarefaID = ? AND UsuarioID = ? LIMIT 1", 'ii', $tarefaID, $uid);
        if(!isset($existe['x'])){
            insertComBindParam("INSERT INTO tarefa_usuario (TarefaID, UsuarioID) VALUES (?, ?)", 'ii', $tarefaID, $uid);
            saveLogPerfis($espacoID, $userID, $tarefaID, 15, $uid, 'Membro', true);
        }
    }
}

// MENÇÃO (@usuário) em comentários: lê os chips <span data-mencao="ID"> do HTML
// do comentário e notifica cada usuário mencionado (push + sininho), desde que
// ele tenha acesso ao contexto e CONSIGA realmente abrir a tarefa/conteúdo
// (caso contrário o clique no link cairia em "SEM PERMISSAO" e o popup ficaria
// em branco). Para tarefas isso significa: membro do espaço + acesso à lista
// da tarefa (admin/proprietário ou sem restrição em espaco_usuario_lista, ou
// com a lista da tarefa expressamente liberada).
// Não re-notifica quem já foi avisado como membro da tarefa/conteúdo.
// $idsJaMencionados: lista explícita de IDs que JÁ estavam marcados antes desta
//   gravação (usada p/ texto da tarefa, cujo "antes" vem da própria coluna Texto).
//   Quando fornecida, só notifica os @ realmente NOVOS — assim editar um texto que
//   já tinha alguém marcado não re-dispara notificação para essa pessoa.
// $fraseAcao: verbo da mensagem ("marcou você em um comentário" / "...no texto da tarefa").
/**
 * Contexto de notificacao de um comentario: quem pode receber, quem ja e avisado
 * por outra via, o titulo do push e a URL de volta.
 *
 * Extraido porque duas notificacoes diferentes (mencao e resposta) precisam
 * EXATAMENTE do mesmo recorte — e "quem pode ver este item" e a parte que nao
 * pode divergir entre elas: uma copia desatualizada aqui viraria push de um
 * comentario para quem perdeu o acesso ao espaco ou a marca.
 */
function comentarioContextoNotificacao($itemID, $perfisID, $isTarefa, $comentID = 0){
    if($isTarefa){
        $ctx = [
            'permitidos'    => getUsuariosAcessoEspacoPorTarefa($itemID),
            'jaNotificados' => array_map('intval', array_column(getTarefaMembros($itemID), 'ID')),
            'titulo'        => limitarTexto(getTarefaTitulo($itemID)),
            'url'           => '?tid='.$itemID,
        ];
    } else {
        $ctx = [
            'permitidos'    => getUsuariosAcessoPerfil($perfisID),
            'jaNotificados' => array_map('intval', array_column(getPostMembros($itemID), 'ID')),
            'titulo'        => limitarTexto(getPostPerfilNome($perfisID)).' #'.$itemID,
            'url'           => '?pid='.$perfisID.'&cid='.$itemID,
        ];
    }

    // Param coment: faz a UI focar/destacar o comentario ao abrir a tarefa/conteudo
    if($comentID > 0)
        $ctx['url'] .= '&coment='.intval($comentID);

    return $ctx;
}

/**
 * RESPOSTA a um comentario avisa o autor do comentario respondido.
 *
 * Antes, responder so notificava se o autor da resposta tambem marcasse a pessoa
 * com @ — ou seja, o fluxo mais natural da conversa (clicar em "Responder" e
 * escrever) nao avisava ninguem, e a resposta ficava esperando a outra pessoa
 * reabrir o item por conta propria.
 *
 * Diferente da mencao, esta notificacao NAO pula quem e membro da tarefa/post.
 * La o filtro existe porque o membro ja foi avisado quando entrou no item; aqui
 * o gesto e dirigido a UMA pessoa, e "voce ja e membro" nao substitui "alguem
 * respondeu voce".
 */
function notificarRespostaComentario($responderID, $textoHtml, $logID, $itemID, $perfisID, $isTarefa, $comentID = 0){
    $responderID = (int)$responderID;
    if($responderID <= 0 || $logID <= 0) return;

    $pai = pesquisarUmComBindParam(
        "SELECT UserID FROM perfis_posts_comentarios WHERE ID = ?", 'i', $responderID);

    $destino = (int)($pai['UserID'] ?? 0);
    $autorID = (int)$_SESSION["UserID"];

    // Responder a si mesmo nao notifica.
    if($destino <= 0 || $destino === $autorID) return;

    $ctx = comentarioContextoNotificacao($itemID, $perfisID, $isTarefa, $comentID);

    // Perdeu o acesso ao espaco/marca depois de comentar: nao recebe.
    if(!in_array($destino, $ctx['permitidos'])) return;

    /* Evita o push DUPLICADO quando a resposta tambem marca a pessoa com @.
       Nao basta "foi mencionado": a notificacao de mencao tem filtros proprios
       (precisa ter acesso E nao ser membro do item), entao ela pode ter DEIXADO
       de enviar. Repetir a mesma decisao aqui e o que garante exatamente um
       aviso — nem dois, nem nenhum. */
    $mencionados = [];
    if(preg_match_all('/data-mencao="(\d+)"/', (string)$textoHtml, $m))
        $mencionados = array_map('intval', $m[1]);

    $jaAvisadoPelaMencao = in_array($destino, $mencionados, true)
        && !in_array($destino, $ctx['jaNotificados']);
    if($jaAvisadoPelaMencao) return;

    $snippet = tratarTextoHtml($textoHtml);
    $snippet = trim(preg_replace('/\s+/u', ' ', $snippet));
    $snippet = limitarTexto($snippet, 140);

    $mensagem = $_SESSION["UserNome"].' respondeu seu comentário';
    if($snippet !== '')
        $mensagem .= ': "'.$snippet.'"';

    enviarNotificacaoOneSignal($destino, $ctx['titulo'], $mensagem, $ctx['url']);
    salvaNotificacaoUsuario($destino, $logID, $ctx['titulo'], $mensagem, $autorID);
}

function notificarMencoesComentario($textoHtml, $logID, $itemID, $perfisID, $isTarefa, $comentID = 0, $apenasNovosUsuarios = false, $idsJaMencionados = null, $fraseAcao = 'marcou você em um comentário'){
    if($logID <= 0) return;
    if(!preg_match_all('/data-mencao="(\d+)"/', $textoHtml, $m)) return;

    $ids = array_values(array_unique(array_map('intval', $m[1])));
    if(empty($ids)) return;

    // Filtra para notificar apenas IDs novos (não estavam no texto original).
    // Evita re-spammar quem já foi avisado antes (edição de comentário OU
    // edição/autosave do texto da tarefa, onde o "antes" chega em $idsJaMencionados).
    if($apenasNovosUsuarios){
        if($idsJaMencionados !== null)
            $jaNotificadosBefore = array_map('intval', $idsJaMencionados);
        else if($comentID > 0)
            $jaNotificadosBefore = getComentMencoesAntigas($comentID);
        else
            $jaNotificadosBefore = [];

        $ids = array_values(array_diff($ids, $jaNotificadosBefore));
        if(empty($ids)) return;
    }

    $autorID = (int)$_SESSION["UserID"];
    $autorNome = $_SESSION["UserNome"];

    $ctx           = comentarioContextoNotificacao($itemID, $perfisID, $isTarefa, $comentID);
    $permitidos    = $ctx['permitidos'];
    $jaNotificados = $ctx['jaNotificados'];
    $ttl           = $ctx['titulo'];
    $caminhoUrl    = $ctx['url'];

    // Trecho do comentário (texto puro, sem chips/tags) para a notificação
    $snippet = tratarTextoHtml($textoHtml);
    $snippet = trim(preg_replace('/\s+/u', ' ', $snippet));
    $snippet = limitarTexto($snippet, 140);

    $mensagem = $autorNome.' marcou você em um comentário';
    if($snippet !== '')
        $mensagem .= ': "'.$snippet.'"';

    foreach($ids as $uid){
        if($uid <= 0 || $uid == $autorID) continue;
        if(!in_array($uid, $permitidos)) continue;   // só quem tem acesso ao contexto
        if(in_array($uid, $jaNotificados) && !$apenasNovosUsuarios) continue; // já avisado como membro (no insert)
        enviarNotificacaoOneSignal($uid, $ttl, $mensagem, $caminhoUrl);
        salvaNotificacaoUsuario($uid, $logID, $ttl, $mensagem, $autorID);
    }
}

// IDs já mencionados no texto atual do comentário no banco — usado para evitar
// re-notificar usuários quando o autor edita o comentário e mantém menções.
function getComentMencoesAntigas($comentID){
    $row = pesquisarUmComBindParam("SELECT Texto FROM perfis_posts_comentarios WHERE ID = ?", 'i', $comentID);
    if(!$row || empty($row['Texto'])) return [];
    if(!preg_match_all('/data-mencao="(\d+)"/', $row['Texto'], $m)) return [];
    return array_values(array_unique(array_map('intval', $m[1])));
}

// Liga anexos recém-upados (com TipoID=5 ou referenciados no HTML via data-anexoid)
// ao comentário recém-criado. Lê os IDs do próprio HTML do comentário.
function vincularAnexosAoComentario($textoHtml, $comentID){
    if($comentID <= 0 || empty($textoHtml)) return;
    if(!preg_match_all('/data-anexoid="(\d+)"/', $textoHtml, $m)) return;
    $ids = array_values(array_unique(array_map('intval', $m[1])));
    if(empty($ids)) return;
    foreach($ids as $aid){
        if($aid <= 0) continue;
        // Sem OR: a coluna é NULLABLE, então apenas IS NULL — vincula só quando ainda
        // não foi associado a outro comentário. Se aparecer um valor 0 explícito,
        // tratamos numa migration, não em runtime.
        $sql = "UPDATE perfis_posts_upload SET ComentarioID = ? WHERE ID = ? AND ComentarioID IS NULL";
        updateComBindParam($sql, 'ii', $comentID, $aid);
    }
}

// Edição de comentário: quando o usuário REMOVE um chip de anexo, o upload
// continua vinculado (ComentarioID = X). Esta função compara o conjunto
// de data-anexoid presentes no NOVO HTML com o que está no banco e marca
// como Deletado=1 os que sumiram. Marcar como deletado (em vez de só
// limpar ComentarioID) também esconde o arquivo da lista de anexos da
// tarefa — coerente com a expectativa "removi o áudio do comentário".
function desvincularAnexosRemovidosDoComentario($textoHtml, $comentID){
    if($comentID <= 0) return;
    $idsNoTexto = [];
    if(!empty($textoHtml) && preg_match_all('/data-anexoid="(\d+)"/', $textoHtml, $m)){
        $idsNoTexto = array_map('intval', $m[1]);
    }
    $atuais = pesquisarComBindParam(
        "SELECT ID FROM perfis_posts_upload WHERE ComentarioID = ? AND Deletado = 0",
        'i', $comentID
    );
    if(!$atuais) return;
    foreach($atuais as $row){
        $aid = (int)$row['ID'];
        if($aid > 0 && !in_array($aid, $idsNoTexto, true)){
            updateComBindParam(
                "UPDATE perfis_posts_upload SET Deletado = 1 WHERE ID = ?",
                'i', $aid
            );
        }
    }
}

// Busca tarefas/conteúdos por substring p/ o picker `#hashtag` nos comentários.
// Restringe ao contexto do usuário (perfis/espaços que ele acessa).
// Query vazia = retorna os mais recentes (sem filtro de texto).
// NUNCA usa OR no WHERE — cada condição alternativa vira um SELECT em UNION
// (assim cada ramo usa índice próprio em vez de degradar pra full scan).
function buscarReferenciasParaComentario($query, $userID, $contexto = 'tudo', $limite = 8){
    $query    = trim($query);
    $temQuery = mb_strlen($query, 'UTF-8') > 0;
    $like     = '%'.$query.'%';
    $idNum    = is_numeric($query) ? intval($query) : 0;
    $out      = [];

    if($contexto !== 'conteudo'){
        if($temQuery){
            // Branch 1: match por Titulo (LIKE). Branch 2: match exato por ID.
            // UNION (sem DISTINCT) só serve se quisermos dedup; mantemos com
            // UNION padrão (= UNION DISTINCT) já que ID é único e LIKE pode pegar o mesmo.
            $sql = "
                (
                    SELECT t.ID, COALESCE(NULLIF(t.Titulo,''), CONCAT('#', t.ID)) AS Titulo, e.Titulo AS EspacoTitulo, 'tarefa' AS Tipo, t.DataAtualizado
                    FROM tarefa t
                    INNER JOIN espaco e ON e.ID = t.EspacoID
                    INNER JOIN espaco_usuario eu ON eu.EspacoID = t.EspacoID AND eu.UsuarioID = ?
                    WHERE t.Titulo LIKE ?
                    ORDER BY t.DataAtualizado DESC
                    LIMIT ?
                )
                UNION
                (
                    SELECT t.ID, COALESCE(NULLIF(t.Titulo,''), CONCAT('#', t.ID)) AS Titulo, e.Titulo AS EspacoTitulo, 'tarefa' AS Tipo, t.DataAtualizado
                    FROM tarefa t
                    INNER JOIN espaco e ON e.ID = t.EspacoID
                    INNER JOIN espaco_usuario eu ON eu.EspacoID = t.EspacoID AND eu.UsuarioID = ?
                    WHERE t.ID = ?
                )
                ORDER BY DataAtualizado DESC
                LIMIT ?
            ";
            $rs = pesquisarComBindParam($sql, 'isiiii', $userID, $like, $limite, $userID, $idNum, $limite);
        } 
        else {
            $sql = "
                SELECT t.ID, COALESCE(NULLIF(t.Titulo,''), CONCAT('#', t.ID)) AS Titulo, e.Titulo AS EspacoTitulo, 'tarefa' AS Tipo
                FROM tarefa t
                INNER JOIN espaco e ON e.ID = t.EspacoID
                INNER JOIN espaco_usuario eu ON eu.EspacoID = t.EspacoID AND eu.UsuarioID = ?
                ORDER BY t.DataAtualizado DESC
                LIMIT ?
            ";
            $rs = pesquisarComBindParam($sql, 'ii', $userID, $limite);
        }
        if($rs){
            // Remove a coluna auxiliar DataAtualizado quando veio do branch UNION
            foreach($rs as $k => $row){ unset($rs[$k]['DataAtualizado']); }
            $out = array_merge($out, $rs);
        }
    }
    if($contexto !== 'tarefa'){
        if($temQuery){
            // 3 branches: por Titulo, por Legenda, por ID — cada um usa o índice
            // próprio. UNION dedupa quando o mesmo post casa em mais de um critério.
            $sql = "
                (
                    SELECT pp.ID, COALESCE(NULLIF(pp.Titulo,''), CONCAT('#', pp.ID)) AS Titulo, p.Nome AS EspacoTitulo, pp.PerfisID, 'conteudo' AS Tipo, pp.DataAtualizado
                    FROM perfis_posts pp
                    INNER JOIN perfis p ON p.ID = pp.PerfisID
                    INNER JOIN perfis_usuario pu ON pu.PerfisID = p.ID AND pu.UsuarioID = ?
                    WHERE pp.Titulo LIKE ?
                    ORDER BY pp.DataAtualizado DESC
                    LIMIT ?
                )
                UNION
                (
                    SELECT pp.ID, COALESCE(NULLIF(pp.Titulo,''), CONCAT('#', pp.ID)) AS Titulo, p.Nome AS EspacoTitulo, pp.PerfisID, 'conteudo' AS Tipo, pp.DataAtualizado
                    FROM perfis_posts pp
                    INNER JOIN perfis p ON p.ID = pp.PerfisID
                    INNER JOIN perfis_usuario pu ON pu.PerfisID = p.ID AND pu.UsuarioID = ?
                    WHERE pp.Legenda LIKE ?
                    ORDER BY pp.DataAtualizado DESC
                    LIMIT ?
                )
                UNION
                (
                    SELECT pp.ID, COALESCE(NULLIF(pp.Titulo,''), CONCAT('#', pp.ID)) AS Titulo, p.Nome AS EspacoTitulo, pp.PerfisID, 'conteudo' AS Tipo, pp.DataAtualizado
                    FROM perfis_posts pp
                    INNER JOIN perfis p ON p.ID = pp.PerfisID
                    INNER JOIN perfis_usuario pu ON pu.PerfisID = p.ID AND pu.UsuarioID = ?
                    WHERE pp.ID = ?
                )
                ORDER BY DataAtualizado DESC
                LIMIT ?
            ";
            $rs = pesquisarComBindParam($sql, 'isiisiiii', $userID, $like, $limite, $userID, $like, $limite, $userID, $idNum, $limite);
        }
        else {
            $sql = "
                SELECT pp.ID, COALESCE(NULLIF(pp.Titulo,''), CONCAT('#', pp.ID)) AS Titulo, p.Nome AS EspacoTitulo, pp.PerfisID, 'conteudo' AS Tipo
                FROM perfis_posts pp
                INNER JOIN perfis p ON p.ID = pp.PerfisID
                INNER JOIN perfis_usuario pu ON pu.PerfisID = p.ID AND pu.UsuarioID = ?
                ORDER BY pp.DataAtualizado DESC
                LIMIT ?
            ";
            $rs = pesquisarComBindParam($sql, 'ii', $userID, $limite);
        }
        if($rs){
            foreach($rs as $k => $row){ unset($rs[$k]['DataAtualizado']); }
            $out = array_merge($out, $rs);
        }
    }
    return $out;
}

// Reações: exatamente 1 (Coment, User) — cada usuário tem uma reação por comentário.
// Cliques sucessivos no MESMO emoji removem; clicar em outro emoji TROCA.
// Retorna ['acao' => added|removed|changed, 'emojiAntigo' => ?, 'emojiNovo' => ?].
function toggleReacaoComentario($comentID, $userID, $emoji){
    $atual = pesquisarUmComBindParam(
        "SELECT ID, Emoji FROM perfis_posts_comentarios_reacoes WHERE ComentarioID = ? AND UserID = ? LIMIT 1",
        'ii', $comentID, $userID
    );
    if($atual && isset($atual['ID'])){
        if($atual['Emoji'] === $emoji){
            // Toggle off — clicou no mesmo emoji que já tinha
            deleteComBindParam("DELETE FROM perfis_posts_comentarios_reacoes WHERE ID = ?", 'i', $atual['ID']);
            return ['acao' => 'removed', 'emojiAntigo' => $emoji, 'emojiNovo' => null];
        }
        // Trocou de emoji — atualiza a linha existente
        updateComBindParam("UPDATE perfis_posts_comentarios_reacoes SET Emoji = ?, Data = NOW() WHERE ID = ?", 'si', $emoji, $atual['ID']);
        return ['acao' => 'changed', 'emojiAntigo' => $atual['Emoji'], 'emojiNovo' => $emoji];
    }
    // Primeira reação
    insertComBindParam("INSERT INTO perfis_posts_comentarios_reacoes (ComentarioID, UserID, Emoji) VALUES (?, ?, ?)", 'iis', $comentID, $userID, $emoji);
    return ['acao' => 'added', 'emojiAntigo' => null, 'emojiNovo' => $emoji];
}

// Bloqueia auto-reação: verifica se o usuário é o autor do comentário.
function usuarioEhAutorDoComentario($comentID, $userID){
    $row = pesquisarUmComBindParam("SELECT UserID FROM perfis_posts_comentarios WHERE ID = ?", 'i', $comentID);
    return $row && (int)$row['UserID'] === (int)$userID;
}

// Resumo de reações de um conjunto de comentários: agrupa por (ComentID, Emoji)
// com total e flag se o usuário atual reagiu.
function getReacoesParaComentarios($comentIDs, $userID){
    $ids = array_values(array_filter(array_map('intval', $comentIDs), function($x){ return $x > 0; }));
    if(empty($ids)) return [];
    // Bind dinâmico para o IN(...): tantos `?` quantos IDs + 1 para o userID
    $place = implode(',', array_fill(0, count($ids), '?'));
    $sql = "
        SELECT
            r.ComentarioID,
            r.Emoji,
            COUNT(*) AS Total,
            MAX(CASE WHEN r.UserID = ? THEN 1 ELSE 0 END) AS Eu
        FROM perfis_posts_comentarios_reacoes r
        WHERE r.ComentarioID IN ($place)
        GROUP BY r.ComentarioID, r.Emoji
        ORDER BY Total DESC, r.Emoji ASC
    ";
    $types  = 'i' . str_repeat('i', count($ids));
    $params = array_merge([$userID], $ids);
    return pesquisarComBindParam($sql, $types, $params);
}

// Anexos ligados a uma lista de comentários (uploads com ComentarioID setado).
function getAnexosParaComentarios($comentIDs){
    $ids = array_values(array_filter(array_map('intval', $comentIDs), function($x){ return $x > 0; }));
    if(empty($ids)) return [];
    $place = implode(',', array_fill(0, count($ids), '?'));
    $sql = "
        SELECT
            ppu.ID,
            ppu.ComentarioID,
            CONCAT('https://', s.Dominio, '', ppu.Arquivo) AS Arquivo,
            ppu.Extensao,
            ppu.Peso,
            ppu.Versao,
            ppu.UserID,
            ppu.Data
        FROM perfis_posts_upload ppu
        LEFT JOIN servers s ON s.ID = ppu.ServerID
        WHERE ppu.ComentarioID IN ($place) AND ppu.Deletado = 0
        ORDER BY ppu.ID ASC
    ";
    $types = str_repeat('i', count($ids));
    return pesquisarComBindParam($sql, $types, $ids);
}

// Para uma lista de "atividades" retornadas ao front (que já tem ComentarioID),
// adiciona Reacoes[], AnexosComent[] e dados do comentário respondido (se houver).
function decorarComentariosNasAtividades(&$lstAtividades, $userID){
    if(empty($lstAtividades)) return;
    $comentIDs = [];
    foreach($lstAtividades as $a){
        if(!empty($a['ComentarioID'])) $comentIDs[] = (int)$a['ComentarioID'];
    }
    if(empty($comentIDs)) return;

    $reacoes = getReacoesParaComentarios($comentIDs, $userID);
    $rByCom  = [];
    if($reacoes) foreach($reacoes as $r){
        $cid = (int)$r['ComentarioID'];
        if(!isset($rByCom[$cid])) $rByCom[$cid] = [];
        $rByCom[$cid][] = ['Emoji'=>$r['Emoji'], 'Total'=>(int)$r['Total'], 'Eu'=>(int)$r['Eu']];
    }

    $anexos = getAnexosParaComentarios($comentIDs);
    $aByCom = [];
    if($anexos) foreach($anexos as $a){
        $cid = (int)$a['ComentarioID'];
        if(!isset($aByCom[$cid])) $aByCom[$cid] = [];
        $aByCom[$cid][] = $a;
    }

    // Respostas: para cada comentário com ResponderID, pega snippet/autor do alvo
    $sqlR = "
        SELECT c.ID, c.ResponderID, c2.Texto AS RespTexto, u.Nome AS RespUsuario, u.ID AS RespUserID
        FROM perfis_posts_comentarios c
        LEFT JOIN perfis_posts_comentarios c2 ON c2.ID = c.ResponderID
        LEFT JOIN usuario u ON u.ID = c2.UserID
        WHERE c.ID IN (".implode(',', array_fill(0, count($comentIDs), '?')).") AND c.ResponderID IS NOT NULL AND c.ResponderID > 0
    ";
    $resp = pesquisarComBindParam($sqlR, str_repeat('i', count($comentIDs)), $comentIDs);
    $respByCom = [];
    if($resp) foreach($resp as $rr){
        $respByCom[(int)$rr['ID']] = $rr;
    }

    foreach($lstAtividades as $k => $a){
        $cid = (int)$a['ComentarioID'];
        $lstAtividades[$k]['Reacoes'] = isset($rByCom[$cid]) ? $rByCom[$cid] : [];
        $lstAtividades[$k]['AnexosComent'] = isset($aByCom[$cid]) ? $aByCom[$cid] : [];
        if(isset($respByCom[$cid])){
            $lstAtividades[$k]['ResponderID']  = (int)$respByCom[$cid]['ResponderID'];
            $lstAtividades[$k]['RespUsuario']  = $respByCom[$cid]['RespUsuario'];
            $lstAtividades[$k]['RespUserID']   = (int)$respByCom[$cid]['RespUserID'];
            // snippet curto, texto puro
            $lstAtividades[$k]['RespTrecho']   = limitarTexto(trim(preg_replace('/\s+/u', ' ', tratarTextoHtml($respByCom[$cid]['RespTexto']))), 80);
        }
    }
}

// Faz GET simples de uma URL para extrair OG tags. Bloqueia IPs internos.
function buscarOpenGraph($url){
    $url = trim($url);
    if(!preg_match('#^https?://#i', $url)) return null;
    $host = parse_url($url, PHP_URL_HOST);
    if(!$host) return null;
    // bloqueio simples de SSRF — recusa hosts privados óbvios
    $ip = gethostbyname($host);
    if($ip === $host){
        return null;
    }
    if(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false){
        return null;
    }

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_MAXREDIRS, 3);
    curl_setopt($ch, CURLOPT_TIMEOUT, 6);
    curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 AirpostBot');
    curl_setopt($ch, CURLOPT_RANGE, '0-65535'); // só os primeiros 64KB
    $body = curl_exec($ch);
    curl_close($ch);
    if(!$body) return null;

    $get = function($tag) use ($body){
        if(preg_match('#<meta[^>]+property=["\']'.$tag.'["\'][^>]+content=["\']([^"\']+)#i', $body, $m)) return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
        if(preg_match('#<meta[^>]+name=["\']'.$tag.'["\'][^>]+content=["\']([^"\']+)#i', $body, $m)) return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
        return null;
    };
    $titulo = $get('og:title');
    if(!$titulo && preg_match('#<title[^>]*>([^<]+)</title>#i', $body, $m))
        $titulo = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
    return [
        'titulo'    => $titulo ? mb_substr($titulo, 0, 140, 'UTF-8') : '',
        'descricao' => mb_substr((string)($get('og:description') ?: $get('description') ?: ''), 0, 200, 'UTF-8'),
        'imagem'    => $get('og:image') ?: '',
        'url'       => $url
    ];
}

// Usuários que conseguem realmente abrir a tarefa: membros do espaço,
// filtrados pelo acesso por lista (mesma regra de gateAcessoLista() em
// seguranca.php — admin/proprietário ou sem restrição ou com a lista liberada).
function getUsuariosAcessoEspacoPorTarefa($tarefaID){
    $sql = "
        SELECT eu.UsuarioID AS ID
        FROM tarefa t
        INNER JOIN espaco_usuario eu ON eu.EspacoID = t.EspacoID
        WHERE t.ID = ?
        AND (
            eu.PermissaoID IN (0,1)
            OR NOT EXISTS (
                SELECT 1 FROM espaco_usuario_lista eul
                WHERE eul.UsuarioID = eu.UsuarioID AND eul.EspacoID = eu.EspacoID
            )
            OR EXISTS (
                SELECT 1 FROM espaco_usuario_lista eul2
                WHERE eul2.UsuarioID = eu.UsuarioID AND eul2.EspacoID = eu.EspacoID
                  AND eul2.ListaID = t.ListaID
            )
        )
    ";
    $r = pesquisarComBindParam($sql, 'i', $tarefaID);
    return array_map(function($x){ return (int)$x['ID']; }, $r ?: []);
}

function getUsuariosAcessoPerfil($perfisID){
    $sql = "
        SELECT pu.UsuarioID AS ID FROM perfis_usuario pu WHERE pu.PerfisID = ?
        UNION
        SELECT p.UsuarioID AS ID FROM perfis p WHERE p.ID = ?
    ";
    $r = pesquisarComBindParam($sql, 'ii', $perfisID, $perfisID);
    return array_map(function($x){ return (int)$x['ID']; }, $r ?: []);
}

function limitarTexto($texto, $limite = 40) {
    if (mb_strlen($texto, 'UTF-8') > $limite) {
        return mb_substr($texto, 0, $limite, 'UTF-8') . '...';
    } else {
        return $texto;
    }
}
function getTarefaMembros($postID){
    $sql = "
    SELECT tu.UsuarioID ID
    FROM tarefa_usuario tu
    WHERE tu.TarefaID = ? 
    ";
    $retorno = pesquisarComBindParam($sql, 'i', $postID);

    return $retorno;
}
function getPostMembros($postID){
    $sql = "
    SELECT ppu.UserID ID
    FROM perfis_posts_usuarios ppu
    WHERE ppu.PostID = ?
    ";
    $retorno = pesquisarComBindParam($sql, 'i', $postID);

    return $retorno;
}

function getPostPerfilNome($perfisID){
    $sql = "
    SELECT p.Nome
    FROM perfis p
    WHERE p.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $perfisID);

    return $retorno['Nome'];
}

function getPostSatus($postID){
    $sql = "
    SELECT DISTINCT pp.ID, sp.Titulo Status 
    FROM statusPost sp
    INNER JOIN perfis_posts pp ON sp.ID = pp.StatusID
    WHERE pp.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $postID);

    return $retorno['Status'];
}
function getTarefaSatus($tarefaID){
    $sql = "
    SELECT s.Titulo, el.StatusInicioTitulo, el.StatusFimTitulo, t.StatusID
    FROM tarefa t
    LEFT JOIN espaco_lista_status s ON t.StatusID = s.ID
    INNER JOIN espaco_lista el ON el.ID = t.ListaID
    WHERE t.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $tarefaID);

    $r = '';
    if(strlen($retorno['Titulo']) > 0)
        $r = $retorno['Titulo'];
    else{
        if($retorno['StatusID'] == 0)
            $r = (strlen( $retorno['StatusInicioTitulo'] > 0) ? $retorno['StatusInicioTitulo'] : 'Pendente' ) ;
        else if($retorno['StatusID'] == 1)
            $r = (strlen( $retorno['StatusFimTitulo'] > 0) ? $retorno['StatusFimTitulo'] : 'Finalizado' ) ;
    }
    
    return $r;
}
// ===========================================================================
// STATUS DE TAREFA — as duas SENTINELAS (leia antes de escrever query de tarefa)
// ---------------------------------------------------------------------------
// `tarefa.StatusID` NÃO é só um FK para `espaco_lista_status`. Ele tem dois
// valores RESERVADOS, que não existem como linha em lugar nenhum:
//
//     0  = status INICIAL da lista  (título em espaco_lista.StatusInicioTitulo)
//     1  = status FINAL   da lista  (título em espaco_lista.StatusFimTitulo)
//   > 1  = linha real em espaco_lista_status (os intermediários do fluxo)
//
// Provas no código: getTarefaSatus() (acima) cai nos títulos da própria lista
// quando o LEFT JOIN não resolve; o editor de status em save-page.php grava
// StatusInicioTitulo quando o id do status é 0 e StatusFimTitulo quando é 1; e
// tarefaBtnEmDestaque() (footer.php) desenha "Finalizado" para statusID == 1.
//
// Consequência que já custou bug em produção: testar conclusão SÓ por
// `espaco_lista_status.TipoID = 2` deixa passar TODA tarefa finalizada pela
// sentinela — que é o caminho NORMAL, porque os templates de lista só criam
// status intermediários (TipoID = 1). Era por isso que tarefa finalizada
// aparecia em "Precisa de você agora", na Agenda e nos totais de carga.
//
// Use SEMPRE estes fragmentos em vez de escrever a condição na mão.
// $t = alias de `tarefa`; $els = alias de `espaco_lista_status` (LEFT JOIN por
// `els.ID = t.StatusID`).
function sqlTarefaAberta($t = 't', $els = 'els'){
    return "({$t}.StatusID <> 1 AND COALESCE({$els}.TipoID, 0) <> 2)";
}

function sqlTarefaConcluida($t = 't', $els = 'els'){
    return "({$t}.StatusID = 1 OR COALESCE({$els}.TipoID, 0) = 2)";
}

// Conclusão detectada pelo LOG (perfis_log grava o TÍTULO do status em Para,
// não o ID). Para a sentinela não existe linha em espaco_lista_status, então a
// comparação tem de ser contra o título final da própria lista.
// $el = alias de `espaco_lista`; $pl = alias de `perfis_log`.
// O OR aqui é predicado sobre linha JÁ filtrada (Campo='Status' + janela de
// data + TarefaID > 0), não fonte alternativa de linha — não é o caso que a
// regra "nunca OR no WHERE" existe para evitar.
function sqlTarefaLogConcluida($els = 'els', $el = 'el', $pl = 'pl'){
    return "(COALESCE({$els}.TipoID, 0) = 2
             OR UPPER({$pl}.Para) = UPPER(COALESCE(NULLIF({$el}.StatusFimTitulo, ''), 'Finalizado')))";
}

// Nome e cor do status da tarefa PARA EXIBIÇÃO, cobrindo as sentinelas: sem
// linha em espaco_lista_status, o nome vem dos títulos da lista e a cor dos
// valores que o front usa em tarefaBtnEmDestaque (#272727 pendente,
// #2c8c5e finalizado). Sem isto, tarefa em status inicial/final aparecia com o
// status vazio ("—") em toda listagem.
// Exige `espaco_lista` no FROM com o alias $el.
function sqlTarefaStatusNome($t = 't', $els = 'els', $el = 'el'){
    return "COALESCE(NULLIF({$els}.Titulo, ''),
              CASE WHEN {$t}.StatusID = 1
                   THEN COALESCE(NULLIF({$el}.StatusFimTitulo, ''), 'Finalizado')
                   ELSE COALESCE(NULLIF({$el}.StatusInicioTitulo, ''), 'Pendente')
              END)";
}

function sqlTarefaStatusCor($t = 't', $els = 'els'){
    return "COALESCE(NULLIF({$els}.Cor, ''),
              CASE WHEN {$t}.StatusID = 1 THEN '#2c8c5e' ELSE '#272727' END)";
}

function getPostTipo($postID){
    $sql = "
    SELECT DISTINCT pp.ID, tp.Titulo Tipo
    FROM tipoPost tp
    INNER JOIN perfis_posts pp ON tp.ID = pp.TipoID
    WHERE pp.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $postID);

    return $retorno['Tipo'];
}
function getPostLocal($postID){
    $sql = "
    SELECT DISTINCT pp.ID, lp.Titulo Local 
    FROM localPost lp
    INNER JOIN perfis_posts pp ON lp.ID = pp.LocalID
    WHERE pp.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $postID);

    return $retorno['Local'];
}
function getPostData($postID){
    $sql = "
    SELECT pp.ID, pp.DataAgendado
    FROM perfis_posts pp
    WHERE pp.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $postID);

    return $retorno['DataAgendado'];
}
/* Data de ENTREGA (prazo interno). Coluna `DataEntrega`, nao `DataEntrega` —
   esse nome ja e alias de saida para "prazo do item" e viraria chave duplicada.
   Ver BIN/migrations/2026-08-17_conteudo_prazo_entrega.sql. */
function getPostDataEntrega($postID){
    $sql = "
    SELECT pp.ID, pp.DataEntrega
    FROM perfis_posts pp
    WHERE pp.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $postID);

    return $retorno ? $retorno['DataEntrega'] : null;
}
function getTarefaDataInicio($tarefaID){
    $sql = "
    SELECT DataInicio
    FROM tarefa
    WHERE ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $tarefaID);

    return $retorno['DataInicio'];
}
function getTarefaDataFim($tarefaID){
    $sql = "
    SELECT DataFim
    FROM tarefa
    WHERE ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $tarefaID);

    return $retorno['DataFim'];
}
function getTarefaPrioridade($tarefaID){
    $sql = "
    SELECT PrioridadeID
    FROM tarefa
    WHERE ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $tarefaID);

    if($retorno['PrioridadeID'] == 4)
        return 'Baixa';
    else if($retorno['PrioridadeID'] == 3)
        return 'Normal';
    else if($retorno['PrioridadeID'] == 2)
        return 'Alta';
    else if($retorno['PrioridadeID'] == 1)
        return 'Urgente';
}
function getPostLegenda($postID){
    $sql = "
    SELECT DISTINCT pp.ID, pp.Legenda
    FROM perfis_posts pp
    WHERE pp.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $postID);

    return $retorno['Legenda'];
}
function getPostObservacao($postID){
    $sql = "
    SELECT DISTINCT pp.ID, pp.Observacoes
    FROM perfis_posts pp
    WHERE pp.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $postID);
    return $retorno['Observacoes'];
}
function getTarefaTitulo($id){
    $sql = "
    SELECT t.Titulo
    FROM tarefa t
    WHERE t.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $id);
    return $retorno['Titulo'];
}
function getTarefaTexto($id){
    $sql = "
    SELECT t.Texto
    FROM tarefa t 
    WHERE t.ID = ?
    ";
    $retorno = pesquisarUmComBindParam($sql, 'i', $id);
    return $retorno['Texto'];
}


// ============ DOCUMENTOS (estilo ClickUp Docs) ============
//
// Doc = container (espaco_documento) ancorado a um Espaco (sempre), e
// opcionalmente a uma Lista ou Tarefa. Cada Doc tem uma arvore de Paginas
// (espaco_documento_pagina), aninhadas via PaginaPaiID + Ordem.
// Acesso e sempre gateado pelo Espaco (espaco_usuario) -> as actions postam
// espacoID/listaID/tarefaID e o seguranca.php valida de graca; aqui so
// reforcamos que o doc/pagina pertence ao escopo informado (defesa em profundidade).

// Retorna o doc se o usuario acessa o espaco dele; se o doc for de uma LISTA,
// reforca o gate de acesso por lista (usuario restrito nao ve doc de lista
// bloqueada). Lookup por PK (1 linha) -> nao precisa de UNION; gate em passo
// separado para nao usar OR no WHERE (regra do projeto). Retorna null se negar.
function getDocumentoSeAcesso($documentoID, $userID){
    // Busca por PK; o acesso e decidido em passos separados (sem OR): membro do
    // espaco/perfil da ancora OU usuario compartilhado (espaco_documento_usuario).
    $doc = pesquisarUmComBindParam("
        SELECT d.ID, d.EspacoID, d.PerfisID, d.ListaID, d.TarefaID, d.PastaID, d.Titulo, d.Icone, d.Capa,
               d.WikiMode, d.ShareToken, d.ShareAtivo, d.Arquivado, d.UsuarioID, d.Responsavel
        FROM espaco_documento d
        WHERE d.ID = ? AND d.Deletado = 0
    ", 'i', $documentoID);
    if(!$doc) return null;

    // Ancora ESPACO (espaco / lista / tarefa).
    if((int)$doc['EspacoID'] > 0){
        $emEspaco = pesquisarUmComBindParam(
            "SELECT ID FROM espaco_usuario WHERE EspacoID = ? AND UsuarioID = ?",
            'ii', (int)$doc['EspacoID'], $userID
        );
        if($emEspaco && (int)$emEspaco['ID'] > 0){
            // Membro do espaco: aplica gate de lista/tarefa (defesa em profundidade).
            if($doc['ListaID'] !== null && (int)$doc['ListaID'] > 0){
                $g = pesquisarUmComBindParam(
                    "SELECT el.ID FROM espaco_lista el WHERE el.ID = ? AND ".gateAcessoLista('el.ID', 'el.EspacoID'),
                    'i', (int)$doc['ListaID']
                );
                if(!($g && $g['ID'] > 0)) return null;
            }
            if($doc['TarefaID'] !== null && (int)$doc['TarefaID'] > 0){
                $gt = pesquisarUmComBindParam(
                    "SELECT t.ID FROM tarefa t
                     WHERE t.ID = ? AND t.Deletado = 0
                     AND ".gateAcessoLista('t.ListaID', 't.EspacoID'),
                    'i', (int)$doc['TarefaID']
                );
                if(!($gt && $gt['ID'] > 0)) return null;
            }
            return $doc;
        }
    }
    // Ancora PERFIL (drive da marca): dono da marca OU membro (perfis_usuario).
    else if((int)$doc['PerfisID'] > 0){
        $dono = pesquisarUmComBindParam("SELECT ID FROM perfis WHERE ID = ? AND UsuarioID = ?", 'ii', (int)$doc['PerfisID'], $userID);
        if($dono && (int)$dono['ID'] > 0) return $doc;
        $membro = pesquisarUmComBindParam("SELECT ID FROM perfis_usuario WHERE PerfisID = ? AND UsuarioID = ?", 'ii', (int)$doc['PerfisID'], $userID);
        if($membro && (int)$membro['ID'] > 0) return $doc;
    }

    // Nao e da ancora: so acessa se compartilhado explicitamente com ele.
    $compart = pesquisarUmComBindParam(
        "SELECT ID FROM espaco_documento_usuario WHERE DocumentoID = ? AND UsuarioID = ? AND Deletado = 0",
        'ii', $documentoID, $userID
    );
    if($compart && (int)$compart['ID'] > 0) return $doc;

    return null;
}

// Membros da marca do doc (dono + perfis_usuario) para picker de responsavel/compartilhar.
function getDocumentoMembrosPerfil($perfisID){
    $sql = "
        SELECT x.UsuarioID, x.Nome, x.Email, x.ImagemPerfil FROM (
            SELECT u.ID AS UsuarioID, u.Nome, u.Email, u.ImagemPerfil
            FROM perfis_usuario pu
            INNER JOIN usuario u ON u.ID = pu.UsuarioID
            WHERE pu.PerfisID = ?

            UNION

            SELECT u.ID AS UsuarioID, u.Nome, u.Email, u.ImagemPerfil
            FROM perfis p
            INNER JOIN usuario u ON u.ID = p.UsuarioID
            WHERE p.ID = ?
        ) x
        GROUP BY x.UsuarioID
        ORDER BY x.Nome ASC
    ";
    $r = pesquisarComBindParam($sql, 'ii', $perfisID, $perfisID);
    return $r ? $r : [];
}

// Usuarios com acesso explicito ao doc (para o painel Compartilhar).
function getDocumentoUsuarios($documentoID){
    $sql = "
        SELECT du.UsuarioID, du.PermissaoID, u.Nome, u.Email, u.ImagemPerfil
        FROM espaco_documento_usuario du
        INNER JOIN usuario u ON u.ID = du.UsuarioID
        WHERE du.DocumentoID = ? AND du.Deletado = 0
        ORDER BY u.Nome ASC
    ";
    $r = pesquisarComBindParam($sql, 'i', $documentoID);
    return $r ? $r : [];
}

// Membros do espaco do doc (para o picker de adicionar usuario / responsavel).
function getDocumentoMembrosEspaco($espacoID){
    $sql = "
        SELECT u.ID AS UsuarioID, u.Nome, u.Email, u.ImagemPerfil
        FROM espaco_usuario eu
        INNER JOIN usuario u ON u.ID = eu.UsuarioID
        WHERE eu.EspacoID = ?
        GROUP BY u.ID
        ORDER BY u.Nome ASC
    ";
    $r = pesquisarComBindParam($sql, 'i', $espacoID);
    return $r ? $r : [];
}

// Comentarios do doc na TABELA PRINCIPAL (perfis_posts_comentarios, coluna
// DocumentoID). Retorna no MESMO formato das atividades da tarefa/conteudo,
// para reusar o render (estruturaAtividades) e o decorador de reacoes/anexos.
function getDocumentoComentariosAtividade($documentoID){
    $sql = "
        SELECT
            ppc.ID AS ComentarioID,
            2 AS AcaoID,
            'comentou' AS Acao,
            ppc.DataCriado AS Data,
            u.Nome AS UsuarioNome,
            u.ID AS UsuarioID,
            u.ImagemPerfil AS UsuarioImagemPerfil,
            'Comentario' AS Campo,
            '' AS De,
            '' AS Para,
            ppc.Texto AS Comentario,
            ppc.Editado AS ComentarioEditado,
            ppc.DataAtualizado AS ComentarioDataAtualizado,
            ppc.ResponderID AS ResponderID
        FROM perfis_posts_comentarios ppc
        INNER JOIN usuario u ON u.ID = ppc.UserID
        WHERE ppc.DocumentoID = ?
        ORDER BY ppc.DataCriado ASC, ppc.ID ASC
    ";
    $r = pesquisarComBindParam($sql, 'i', $documentoID);
    return $r ? $r : [];
}
function getDocumentoComentariosTotal($documentoID){
    $r = pesquisarUmComBindParam("SELECT COUNT(*) AS Total FROM perfis_posts_comentarios WHERE DocumentoID = ?", 'i', $documentoID);
    return $r ? (int)$r['Total'] : 0;
}

// Docs de uma tarefa: ancorados (criados na tarefa) + vinculados (relacao N:N).
// UNION sem OR; vinculados ganham gate da lista do doc (um doc de lista
// restrita nao aparece para usuario bloqueado). Acesso a tarefa em si ja foi
// validado pelo seguranca.php (tarefaID no POST).
function getDocumentosDaTarefa($tarefaID, $userID){
    $selPag = "(SELECT COUNT(*) FROM espaco_documento_pagina p WHERE p.DocumentoID = d.ID AND p.Deletado = 0) AS TotalPaginas";
    $sql = "
        SELECT x.* FROM (
            SELECT d.ID, d.EspacoID, d.Titulo, d.Icone, d.DataAtualizado, 0 AS Vinculado, ".$selPag."
            FROM espaco_documento d
            INNER JOIN espaco_usuario eu ON eu.EspacoID = d.EspacoID AND eu.UsuarioID = ?
            WHERE d.TarefaID = ? AND d.Deletado = 0 AND d.Arquivado = 0

            UNION

            SELECT d.ID, d.EspacoID, d.Titulo, d.Icone, d.DataAtualizado, 1 AS Vinculado, ".$selPag."
            FROM espaco_documento_relacao rel
            INNER JOIN espaco_documento d ON d.ID = rel.DocumentoID AND d.Deletado = 0 AND d.Arquivado = 0
            INNER JOIN espaco_usuario eu ON eu.EspacoID = d.EspacoID AND eu.UsuarioID = ?
            WHERE rel.TarefaID = ? AND rel.Deletado = 0 AND d.ListaID IS NULL

            UNION

            SELECT d.ID, d.EspacoID, d.Titulo, d.Icone, d.DataAtualizado, 1 AS Vinculado, ".$selPag."
            FROM espaco_documento_relacao rel
            INNER JOIN espaco_documento d ON d.ID = rel.DocumentoID AND d.Deletado = 0 AND d.Arquivado = 0
            INNER JOIN espaco_usuario eu ON eu.EspacoID = d.EspacoID AND eu.UsuarioID = ?
            WHERE rel.TarefaID = ? AND rel.Deletado = 0 AND d.ListaID IS NOT NULL
            AND ".gateAcessoLista('d.ListaID', 'd.EspacoID')."
        ) x
        ORDER BY x.DataAtualizado DESC, x.ID DESC
    ";
    $r = pesquisarComBindParam($sql, 'iiiiii', $userID, $tarefaID, $userID, $tarefaID, $userID, $tarefaID);
    return $r ? $r : [];
}

// Arvore de paginas de um doc (ordem hierarquica resolvida no front pelo PaginaPaiID).
function getDocumentoPaginas($documentoID){
    $sql = "
        SELECT ID, DocumentoID, PaginaPaiID, Titulo, Icone, Ordem
        FROM espaco_documento_pagina
        WHERE DocumentoID = ? AND Deletado = 0
        ORDER BY Ordem ASC, ID ASC
    ";
    $r = pesquisarComBindParam($sql, 'i', $documentoID);
    return $r ? $r : [];
}

// Conteudo completo de uma pagina (valida que pertence ao doc informado).
// Traz tambem criador + datas para a linha de meta da pagina (estilo ClickUp).
function getDocumentoPagina($paginaID, $documentoID){
    $sql = "
        SELECT p.ID, p.DocumentoID, p.PaginaPaiID, p.Titulo, p.Icone, p.Capa, p.Conteudo, p.Ordem,
               p.UsuarioID, p.DataCriado, p.DataAtualizado,
               us.Nome AS CriadorNome, us.ImagemPerfil AS CriadorImagem
        FROM espaco_documento_pagina p
        LEFT JOIN usuario us ON us.ID = p.UsuarioID
        WHERE p.ID = ? AND p.DocumentoID = ? AND p.Deletado = 0
    ";
    return pesquisarUmComBindParam($sql, 'ii', $paginaID, $documentoID);
}

// Primeira pagina raiz de um doc (usada ao abrir).
function getDocumentoPrimeiraPagina($documentoID){
    $sql = "
        SELECT ID
        FROM espaco_documento_pagina
        WHERE DocumentoID = ? AND Deletado = 0
        ORDER BY (PaginaPaiID IS NULL) DESC, Ordem ASC, ID ASC
        LIMIT 1
    ";
    $r = pesquisarUmComBindParam($sql, 'i', $documentoID);
    return $r ? (int)$r['ID'] : 0;
}


// ============ SUBTAREFAS ============

// Contexto mínimo da tarefa: espaço, lista, profundidade e pai.
// Usado em todas as actions de subtarefa para validar herança e profundidade.
function getTarefaContexto($tarefaID){
    $sql = "
        SELECT t.ID, t.EspacoID, t.ListaID, t.Profundidade, t.TarefaPaiID
        FROM tarefa t
        WHERE t.ID = ?
    ";
    return pesquisarUmComBindParam($sql, 'i', $tarefaID);
}

// Progresso (X de Y) das subtarefas diretas (1 nível) de uma tarefa.
// Conta apenas filhas ativas (não excluídas, não arquivadas).
// Concluída = status cujo TipoID == 2 (status final).
function getProgressoSubtarefas($tarefaPaiID){
    $sql = "
        SELECT
            COUNT(*) AS Total,
            SUM(CASE WHEN ".sqlTarefaConcluida('t', 'els')." THEN 1 ELSE 0 END) AS Concluidas
        FROM tarefa t
        LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
        WHERE t.TarefaPaiID = ?
          AND t.Deletado = 0
          AND t.Arquivado = 0
    ";
    $r = pesquisarUmComBindParam($sql, 'i', $tarefaPaiID);
    return [
        'total'      => (int)($r['Total'] ?? 0),
        'concluidas' => (int)($r['Concluidas'] ?? 0),
    ];
}

// Descendentes (filhas + netas) de uma tarefa, em lista plana.
// Dois SELECTs separados por nível para evitar OR no WHERE.
function getSubtarefasDescendentes($tarefaPaiID){
    $sqlN1 = "
        SELECT t.ID, t.TarefaPaiID, t.Profundidade, t.EspacoID, t.ListaID
        FROM tarefa t
        WHERE t.TarefaPaiID = ? AND t.Deletado = 0
    ";
    $filhas = pesquisarComBindParam($sqlN1, 'i', $tarefaPaiID);

    $descendentes = $filhas;

    if (is_array($filhas) && count($filhas) > 0) {
        $sqlN2 = "
            SELECT t.ID, t.TarefaPaiID, t.Profundidade, t.EspacoID, t.ListaID
            FROM tarefa t
            WHERE t.TarefaPaiID = ? AND t.Deletado = 0
        ";
        foreach ($filhas as $f) {
            $netas = pesquisarComBindParam($sqlN2, 'i', $f['ID']);
            if (is_array($netas) && count($netas) > 0) {
                foreach ($netas as $n) {
                    $descendentes[] = $n;
                }
            }
        }
    }

    return $descendentes;
}

// Itera descendentes (filhas + netas) chamando $callback($id, $row) por cada um.
// Usado em cascades (excluir, arquivar, mover, concluir).
function cascataSubtarefas($tarefaPaiID, $callback){
    $descendentes = getSubtarefasDescendentes($tarefaPaiID);
    foreach ($descendentes as $row) {
        $callback((int)$row['ID'], $row);
    }
}

// Verifica se "candidatoPaiID" é descendente de "tarefaID".
// Usado em moverSubtarefaParaOutroPai para evitar ciclos.
// Como a árvore é limitada a 3 níveis, sobe no máximo 2 hops.
function ehDescendenteDe($candidatoPaiID, $tarefaID){
    $atualID = (int)$candidatoPaiID;
    for ($i = 0; $i < 3 && $atualID > 0; $i++) {
        if ($atualID === (int)$tarefaID) return true;
        $ctx = getTarefaContexto($atualID);
        $atualID = (int)($ctx['TarefaPaiID'] ?? 0);
    }
    return false;
}

// Árvore aninhada da raiz: { ID, Titulo, StatusCor, StatusTipoID, filhas: [...] }
// Usa duas queries planas (raiz/filhas + netos) e monta a árvore em PHP.
// Não usa OR no WHERE nem CTE recursivo no MySQL.
function getSubarvoreCompleta($raizID){
    $sqlNo = "
        SELECT t.ID, t.Titulo, t.TarefaPaiID, t.Profundidade, t.StatusID,
               els.Cor StatusCor, els.Titulo StatusTitulo, els.TipoID StatusTipoID
        FROM tarefa t
        LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
        WHERE t.ID = ? AND t.Deletado = 0
    ";
    $raiz = pesquisarUmComBindParam($sqlNo, 'i', $raizID);
    if(!$raiz) return null;

    $sqlFilhas = "
        SELECT t.ID, t.Titulo, t.TarefaPaiID, t.Profundidade, t.StatusID,
               els.Cor StatusCor, els.Titulo StatusTitulo, els.TipoID StatusTipoID
        FROM tarefa t
        LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
        WHERE t.TarefaPaiID = ? AND t.Deletado = 0 AND t.Arquivado = 0
        ORDER BY t.Ordem ASC, t.ID ASC
    ";
    $filhas = pesquisarComBindParam($sqlFilhas, 'i', $raizID);
    if(!is_array($filhas)) $filhas = [];

    foreach($filhas as &$f){
        $netos = pesquisarComBindParam($sqlFilhas, 'i', $f['ID']);
        $f['filhas'] = is_array($netos) ? $netos : [];
        foreach($f['filhas'] as &$n){ $n['filhas'] = []; } // garante chave
        unset($n);
    }
    unset($f);

    $raiz['filhas'] = $filhas;
    return $raiz;
}


 function PageAcessoPessoas($paginaID, $userID){


    $sql = "
        SELECT DISTINCT
        u.ID, u.Email, u.Nome, u.ImagemPerfil, pu.ID AcessoID, pu.PerfisID, p.ID PageID, pu.PermissaoID, p.UsuarioID MasterID
        , um.ImagemPerfil umImagemPerfil
        , um.Nome umNome
        , um.Email umEmail
        FROM usuario u
        LEFT JOIN perfis_usuario pu ON pu.UsuarioID = u.ID AND pu.PerfisID = ?
        INNER JOIN perfis p ON p.ID = ?
        INNER JOIN usuario um ON um.ID = p.UsuarioID
        WHERE u.ID = '".$userID."'
        ORDER BY pu.PermissaoID ASC
    ";

    $pessoa = pesquisarUmComBindParam($sql, 'ii', $paginaID, $paginaID);

    $master = 0;
    $podeSair = 0;
    if($pessoa['MasterID'] == $userID || $pessoa['PermissaoID'] == 2)
        $master = 1;

    

	$sql = "
        SELECT DISTINCT
        pu.ID, u.Nome, u.ImagemPerfil, u.Email, u.ID UserID, pu.PerfisID, pu.PermissaoID
        FROM perfis_usuario pu
        INNER JOIN usuario u ON u.ID = pu.UsuarioID
        WHERE pu.PerfisID = ?
        ORDER BY u.Nome ASC
    ";

    $lst = pesquisarComBindParam($sql, 'i', $paginaID);

    
    $r = '';
    $r .= '<div class="edicao-popup edicao-page-pessoas" data-id="'.$paginaID.'">';
    $r .= '
            <div class="topo">
              <h1>Contas com acesso</h1>
            </div>
            <div class="clear"></div>';

    $proprietarioR = itemPessoa($pessoa['umImagemPerfil'], $pessoa['MasterID'], $pessoa['umNome'], $pessoa['umEmail'], 1, 0);

    //echo arrayCount($lst);
    if(arrayCount($lst) > 0){
        //print_r($lst);

        $r .= ' <div class="lst-pessoas">';
       // $r .= $proprietarioR;
        foreach($lst as $item){
            $r .= itemPessoa($item['ImagemPerfil'], $item['UserID'], $item['Nome'], $item['Email'], $master, $item['PermissaoID']);
        }
        $r .= ' </div>';
    }
    else{
        $r .= ' <div class="lst-pessoas">';
       // $r .= $proprietarioR;
        $r .= ' </div>';
        // $r .= '<div style="color: #666; font-size: 13px;">Nenhuma conta encontrada.</div>';
    }

    $r .= ' <div class="bloco-compartilhar-link">';
    $r .= '     <div class="text-title">Compartilhe o link do planejamento</div>';
    $r .= '     <div class="btn-copiar-link-url">Copiar link</div>';
    $r .= ' </div>';

 
    if($master){
        $r .= ' <div class="container-pessoas-inserir">';
        $r .= '     <div class="add-user-ttl">Adicionar conta</div>';
        $r .= '   <input class="picker-input" data-picker="membros-email" placeholder="E-mail" autocomplete="new-password" readonly />';
        // $r .= '   <div class="btn-conta-externa">
        //             <div class="ck"><div></div></div>
        //             <div class="txt">Conta externa</div>
        //         </div>';

        $r .= '
             
                <div class="custom-select-wrapper">
                    <select class="custom-select">
                        <option value="'.(1).'">Editor</option>
                        <option value="'.(2).'">Editor Admin</option>
                        <option value="'.(3).'">Visualizador</option>
                        <option value="'.(4).'">Cliente</option>
                    </select>
                </div>
                ';

        $r .= '   <div class="btn-nova-pessoa btn-01">+ Adicionar</div>';
        $r .= '   <div class="clear"></div>';
        $r .= ' </div>';
    }


  $r .= '</div>';
  return $r;
}

function itemPessoa($ImagemPerfil, $UserID, $Nome, $Email, $master, $perfilID){

    $podeSair = 0;
    if($perfilID == 1 && $UserID == $_SESSION["UserID"])
        $podeSair = 1;


    $img = '';
    if(strlen($ImagemPerfil) > 0){
        $img = '/upload/'.$ImagemPerfil;
        $img = '<div class="img" style="background-image: url(' . $img . ')"></div>';
    }
    else{
        $iniciais = substr($Nome, 0, 2);
        $img = '<div class="img">'.$iniciais.'</div>';
    }


    $r = '<div class="item" data-id="' . base64_encode($UserID) . '">';
    $r .=  $img;
    $r .= ' <div class="nome">';
    $r .= '     <p>' . $Nome . '</p>';
    $r .= '     <span>' . $Email . '</span>';
    $r .= ' </div>';

    $perfilDescricao = '';
    if($perfilID == 1){
        $perfilDescricao = 'Editor';
    } 
    else if($perfilID == 2){
        $perfilDescricao = 'Editor Admin';
    }
    else if($perfilID == 3){
        $perfilDescricao = 'Visualizador';
    }
    else if($perfilID == 4){
        $perfilDescricao = 'Cliente';
    }
    

    if($podeSair == 1){
        $r .= ' <div class="btn-sair">Sair</div>';
    }
    if($master && $perfilID != 0){
        $r .= '
        <div class="dropdown dropleft">
            <button class="dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
            '.$perfilDescricao.' <svg xmlns="http://www.w3.org/2000/svg" id="Outline" viewBox="0 0 24 24"><path d="M6.41,9H17.59a1,1,0,0,1,.7,1.71l-5.58,5.58a1,1,0,0,1-1.42,0L5.71,10.71A1,1,0,0,1,6.41,9Z"/></svg>
            </button>
            <div class="dropdown-menu">
                <a class="dropdown-item btn-alterar-perfil '.($perfilID == 1 ? 'atv' : '').'" data-id="'.base64_encode(1).'"><span>✓</span> Editor</a>
                <a class="dropdown-item btn-alterar-perfil '.($perfilID == 2 ? 'atv' : '').'" data-id="'.base64_encode(2).'"><span>✓</span> Editor Admin</a>
                <a class="dropdown-item btn-alterar-perfil '.($perfilID == 3 ? 'atv' : '').'" data-id="'.base64_encode(3).'"><span>✓</span> Visualizador</a>
                <a class="dropdown-item btn-alterar-perfil '.($perfilID == 4 ? 'atv' : '').'" data-id="'.base64_encode(4).'"><span>✓</span> Cliente</a>
                <div class="dropdown-divider"></div>
                <a class="dropdown-item btn-copiar-link">Copiar link</a>
                <a class="dropdown-item btn-remover">Remover</a>
            </div>
        </div>
        ';
    }
    else if ($perfilID == 0){
        $r .= ' <div class="info">Proprietário</div>';
    }
    else if(!$master){
        $r .= ' <div class="info">'.$perfilDescricao.'</div>';
    }
    
    $r .= ' <div class="clear"></div>';
    $r .= '</div>';

    return $r;
}


// Matriz de permissões por tipo (aba "Permissões" da edição da marca).
// Linhas = funcionalidades (agrupadas em permissoes.php); colunas = tipos
// restringíveis: Editor (1), Visualizador (3), Cliente (4). Proprietário (0) e
// Editor Admin (2) têm acesso total e não aparecem (informado no texto).
// Os checkboxes vêm pré-marcados pela permissão EFETIVA (padrão + overrides).
function renderPermissoesPerfilMatriz($perfisID){
    if(!function_exists('permissoesFuncionalidades')) return '';

    $perfisID = (int)$perfisID;
    $tipos = [
        1 => ['nome' => 'Editor',       'cls' => 'editor',  'desc' => 'Cria e edita'],
        3 => ['nome' => 'Visualizador', 'cls' => 'viewer',  'desc' => 'Só leitura'],
        4 => ['nome' => 'Cliente',      'cls' => 'cliente', 'desc' => 'Aprova e comenta'],
    ];

    // Ícone (feather, stroke) por funcionalidade — herda a cor via currentColor.
    $sv = function($p){ return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'.$p.'</svg>'; };
    $icones = [
        'conteudo_criar'    => $sv('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/>'),
        'conteudo_editar'   => $sv('<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>'),
        'conteudo_status'   => $sv('<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>'),
        'conteudo_excluir'  => $sv('<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>'),
        'conteudo_publicar' => $sv('<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>'),
        'comentar'          => $sv('<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>'),
        'gerenciar_membros' => $sv('<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
        'gerenciar_perfil'  => $sv('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>'),
        'excluir_perfil'    => $sv('<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>'),
    ];

    $efetivas = [];
    foreach($tipos as $t => $info){ $efetivas[$t] = permissoesEfetivasPerfil($perfisID, $t); }

    $r  = '<style>'.permissoesMatrizCss().'</style>';
    $r .= '<div class="perfil-permissoes" data-perfil-id="'.$perfisID.'">';

    $r .= '<div class="pp-intro"><span class="pp-desc">Defina o que cada tipo de acesso pode fazer nesta marca.</span>';
    $r .= '<span class="pp-note">Proprietário e Editor Admin têm acesso total</span></div>';

    $r .= '<div class="pp-table">';

    // Cabeçalho com os tipos (clicar no chip marca/desmarca a coluna toda).
    $r .= '<div class="pp-row pp-head"><div class="pp-func"></div>';
    foreach($tipos as $t => $info){
        $r .= '<div class="pp-cell"><span class="pp-chip pp-chip-'.$info['cls'].'" data-tipo="'.$t.'" title="Marcar / desmarcar todos">'.$info['nome'].'</span></div>';
    }
    $r .= '</div>';

    foreach(permissoesFuncionalidades() as $grupo => $funcs){
        $r .= '<div class="pp-group">'.$grupo.'</div>';
        foreach($funcs as $chave => $label){
            $ic = isset($icones[$chave]) ? $icones[$chave] : '';
            $r .= '<div class="pp-row">';
            $r .= '<div class="pp-func"><span class="pp-ic">'.$ic.'</span><span class="pp-lbl">'.$label.'</span></div>';
            foreach($tipos as $t => $info){
                $on = (!empty($efetivas[$t][$chave])) ? ' checked' : '';
                $r .= '<div class="pp-cell"><label class="pp-toggle"><input type="checkbox" class="chk-permissao" data-tipo="'.$t.'" data-func="'.$chave.'"'.$on.'><span class="pp-track"><span class="pp-thumb"></span></span></label></div>';
            }
            $r .= '</div>';
        }
    }

    $r .= '</div>'; // pp-table

    $r .= '<div class="pp-foot"><div class="pp-save btn-salvar-permissoes" data-id="'.$perfisID.'">Salvar permissões</div></div>';
    $r .= '</div>';

    return $r;
}

// CSS (escopado em .perfil-permissoes) da matriz de permissões. Usa as variáveis
// de tema do app (--color-*) para adaptar ao dark mode automaticamente.
function permissoesMatrizCss(){
    return '
    .perfil-permissoes{font-size:13px;color:var(--color-text-01);padding:2px}
    .perfil-permissoes .pp-intro{font-size:12.5px;line-height:1.5;margin:0 2px 16px;display:flex;align-items:center;gap:9px;flex-wrap:wrap}
    .perfil-permissoes .pp-desc{opacity:.55}
    .perfil-permissoes .pp-note{display:inline-flex;align-items:center;gap:5px;background:var(--color-bg-01);padding:3px 10px;border-radius:20px;font-size:11px;opacity:.85;white-space:nowrap}
    .perfil-permissoes .pp-note::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--color-text-01);flex:none}
    .perfil-permissoes .pp-table{border:1px solid var(--color-border);border-radius:14px;overflow:hidden}
    .perfil-permissoes .pp-row{display:flex;align-items:center;gap:6px;padding:0 8px 0 16px;min-height:48px;border-top:1px solid var(--color-border);transition:background .12s}
    .perfil-permissoes .pp-row:first-child{border-top:none}
    .perfil-permissoes .pp-table .pp-row:not(.pp-head):hover{background:var(--color-hover-01)}
    .perfil-permissoes .pp-head{min-height:44px;background:var(--color-bg-01)}
    .perfil-permissoes .pp-func{flex:1;min-width:0;display:flex;align-items:center;gap:11px}
    .perfil-permissoes .pp-cell{width:98px;flex:none;display:flex;justify-content:center}
    .perfil-permissoes .pp-ic{width:18px;height:18px;flex:none;display:inline-flex;opacity:.45}
    .perfil-permissoes .pp-ic svg{width:18px;height:18px}
    .perfil-permissoes .pp-lbl{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    .perfil-permissoes .pp-group{font-size:10.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;opacity:.4;padding:16px 6px 7px}
    .perfil-permissoes .pp-chip{font-size:11.5px;font-weight:600;padding:4px 11px;border-radius:20px;cursor:pointer;user-select:none;transition:transform .1s,filter .12s;white-space:nowrap}
    .perfil-permissoes .pp-chip:hover{filter:brightness(.97)}
    .perfil-permissoes .pp-chip:active{transform:scale(.95)}
    .perfil-permissoes .pp-chip-editor,
    .perfil-permissoes .pp-chip-viewer,
    .perfil-permissoes .pp-chip-cliente{background:var(--color-bg-01);color:var(--color-text-01)}
    .perfil-permissoes .pp-toggle{position:relative;display:inline-flex;cursor:pointer}
    .perfil-permissoes .pp-toggle input{position:absolute;opacity:0;width:0;height:0}
    .perfil-permissoes .pp-track{width:36px;height:21px;border-radius:20px;background:rgba(128,128,128,.38);position:relative;transition:background .18s}
    .perfil-permissoes .pp-thumb{position:absolute;top:2.5px;left:2.5px;width:16px;height:16px;border-radius:50%;background:var(--color-btn-text-01);box-shadow:0 1px 3px rgba(0,0,0,.3);transition:transform .18s}
    .perfil-permissoes .pp-toggle input:checked + .pp-track{background:var(--color-btn-bg-01)}
    .perfil-permissoes .pp-toggle input:checked + .pp-track .pp-thumb{transform:translateX(15px)}
    .perfil-permissoes .pp-toggle input:focus-visible + .pp-track{box-shadow:0 0 0 3px rgba(128,128,128,.3)}
    .perfil-permissoes .pp-foot{position:sticky;bottom:0;z-index:2;display:flex;justify-content:flex-end;gap:10px;margin:18px -2px 0;padding:14px 2px;background:var(--color-bg);border-top:1px solid var(--color-border)}
    .perfil-permissoes .pp-save{cursor:pointer;font-size:13px;font-weight:600;color:var(--color-btn-text-01);background:var(--color-btn-bg-01);padding:10px 20px;border-radius:11px;transition:opacity .15s,transform .08s}
    .perfil-permissoes .pp-save:hover{opacity:.88}
    .perfil-permissoes .pp-save:active{transform:translateY(1px)}
    .editarPerfis.na-aba-permissoes .btn-save-perfil{display:none !important}
    ';
}



use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

function enviaEmail($emailDestinatario, $remetenteEmail, $remetenteNome, $EmailQueRecebeResposta, $titulo, $texto, $EmailsObservadores, $emailDoSite){

	
	//Load composer's autoloader
    //require(__DIR__.'/../vendor/autoload.php');
    require('/var/www/html/vendor/autoload.php');

	$mail = new PHPMailer(); // instancia a classe PHPMailer

	$mail->IsSMTP();

	//configuração do gmail
	$mail->Host = 'mail.airpage.com.br'; 
	$mail->IsHTML(true); 
	$mail->SMTPKeepAlive = true;  
	$mail->Mailer = 'smtp'; 

	$mail->SMTPSecure = 'tls';
	$mail->Port = '587'; //porta usada pelo gmail.

    //$mail->Port = 465;
    //$mail->SMTPSecure = 'ssl';

    $mail->SMTPDebug = 2;
    $mail->Debugoutput = 'error_log';

	$mail->CharSet = 'UTF-8';
	//$mail->SMTPDebug = 1; // debugging: 1 = errors and messages, 2 = messages only

	//configuração do usuário do gmail
	$mail->SMTPAuth = true; 
	$mail->Username = 'contato@airpost.com.br'; // usuario gmail.   
	$mail->Password = 'bygxe0-kisquv-zoqhyP'; // senha do email.

	$mail->SingleTo = true; 

	// configuração do email a ver enviado.
	$mail->From = "contato@airpost.com.br"; 
	$mail->FromName = "Airpost"; 

	$mail->addAddress($emailDestinatario); // email do destinatario.

	$mail->Subject = $titulo; 
	$mail->Body = $texto;

	if(!$mail->Send())
    	echo "Erro ao enviar Email:" . $mail->ErrorInfo;
}





#PESQUISAR GERAL
// Função para executar consulta preparada com bind_param
function pesquisarUmComBindParam($sql, $tipo, ...$parametros) {
    $conexao = conexaoBD();
    
    // Preparar a consulta
    $stmt = $conexao->prepare($sql);

    if ($stmt) {
        // Vincular parâmetros
        $stmt->bind_param($tipo, ...$parametros);

        // Executar a consulta
        $stmt->execute();

        // Obter resultados
        $resultado = $stmt->get_result()->fetch_assoc();

        // Fechar a declaração preparada
        $stmt->close();

        return $resultado;
    } else {
        // Lidar com erros na preparação da consulta
        echo "Erro na preparação da consulta: " . $conexao->error;
        return null;
    }
}
// Função para executar consulta preparada com bind_param e retornar uma lista
function pesquisarComBindParam($sql, $tipos = '', ...$parametros) {
    $conexao = conexaoBD();
    $stmt = $conexao->prepare($sql);

    if (!$stmt) {
        echo "Erro na preparação da consulta: " . $conexao->error;
        return null;
    }

    // Se houver parâmetros a serem vinculados
    if (!empty($tipos) && !empty($parametros)) {
        // Caso os parâmetros tenham sido passados como um array único
        if (count($parametros) === 1 && is_array($parametros[0])) {
            $parametros = $parametros[0];
        }

        if (strlen($tipos) !== count($parametros)) {
            echo "Erro: número de tipos não corresponde ao número de parâmetros.";
            return null;
        }

        // Criar referências dos parâmetros
        $refs = [];
        foreach ($parametros as $i => $param) {
            $refs[$i] = &$parametros[$i];
        }

        $stmt->bind_param($tipos, ...$refs);
    }

    // Executar a consulta
    if (!$stmt->execute()) {
        echo "Erro na execução: " . $stmt->error;
        return null;
    }

    $resultado = $stmt->get_result();
    if ($resultado === false) {
        echo "Erro ao obter resultado: " . $stmt->error;
        return null;
    }

    $dados = $resultado->fetch_all(MYSQLI_ASSOC);
    $stmt->close();

    return $dados;
}
function deleteComBindParam($sql, $tipos, ...$parametros) {
    // Conectar ao banco de dados
    $conexao = conexaoBD();
    
    // Preparar a consulta SQL para deletar
    $stmt = $conexao->prepare($sql);

    if ($stmt) {
        // Caso o array de parâmetros tenha subarrays, ajusta a estrutura
        if (hasSubarray($parametros)) {
            $parametros = $parametros[0];
        }

        // Vincular os parâmetros à consulta
        $stmt->bind_param($tipos, ...$parametros);

        // Executar a consulta (DELETE)
        $stmt->execute();

        // Verifica se a operação foi bem-sucedida
        if ($stmt->affected_rows > 0) {
            $resultado = 1; // Sucesso
        } else {
            $resultado = 0; // Nenhuma linha afetada
        }

        // Fechar a declaração preparada
        $stmt->close();

        return $resultado;

    } else {
        // Lidar com erros na preparação da consulta
        echo "Erro na preparação da consulta: " . $conexao->error;
        return null;
    }
}
function updateComBindParam($sql, $tipos = null, ...$parametros) {
    $conexao = conexaoBD();
    
    // Preparar a consulta
    $stmt = $conexao->prepare($sql);

    if ($stmt) {
        // Verifica se há tipos e parâmetros para vincular
        if ($tipos != null && !empty($parametros)) {
            
            // Vincular parâmetros
            if (hasSubarray($parametros))
                $parametros = $parametros[0];

                $stmt->bind_param($tipos, ...$parametros);
            
        }

        // Executar a consulta
        $stmt->execute();

        // Fechar a declaração preparada
        $stmt->close();

        return 1;

    } else {
        // Lidar com erros na preparação da consulta
        error_log("Erro na preparação da consulta: " . $conexao->error);
        return null;
    }
}
function hasSubarray($array) {
    foreach ($array as $item) {
        if (is_array($item)) {
            return true;
        }
    }
    return false;
}
function insertComBindParam($sql, $tipos, ...$parametros) {
    $conexao = conexaoBD();
    
    // Preparar a consulta
    $stmt = $conexao->prepare($sql);

    if ($stmt) {
        // Vincular parâmetros
        $stmt->bind_param($tipos, ...$parametros);

        // Executar a consulta
        $stmt->execute();

        // Obter o ID primário gerado
        $id_primario = $conexao->insert_id;

        // Fechar a declaração preparada
        $stmt->close();

        return $id_primario;

    } else {
        // Lidar com erros na preparação da consulta
        echo "Erro na preparação da consulta: " . $conexao->error;
        return null;
    }
}

// ---------------------------------------------------------------------------
// CUPOM DE RENOVAÇÃO (centraliza a elegibilidade usada no robô, no admin e nos
// relatórios de receita). Antes cada ponto recalculava a diferença de MESES DE
// CALENDÁRIO entre DataAplicado e a renovação — frágil: desalinhava do número
// real de faturas e, dependendo do dia do mês / reaplicação do cupom, estendia
// o desconto para uma fatura a mais que QuantidadeMeses.
//
// Regra nova (determinística): pega o cupom MAIS RECENTE da assinatura e conta
// quantas faturas JÁ usaram esse cupom (CupomID marcado na fatura, canceladas
// StatusID=3 não contam). Enquanto FaturasUsadas < QuantidadeMeses o cupom está
// vigente; ao atingir, para. Imune a desalinhamento de data e a reaplicações.
//
// Retorna null se a assinatura não tem cupom; senão array com:
//   CupomID, Titulo, Percentual, QuantidadeMeses, FaturasUsadas, Vigente(bool).
// ---------------------------------------------------------------------------
function cupomRenovacaoAssinatura($assinaturaID){
    $cupom = pesquisarUmComBindParam(
        "SELECT c.ID AS CupomID, c.Titulo, c.Percentual, c.QuantidadeMeses
         FROM usuario_assinatura_cupom uac
         INNER JOIN p_cupom c ON c.ID = uac.CupomID
         WHERE uac.AssinaturaID = ?
         ORDER BY uac.ID DESC LIMIT 1",
        'i', $assinaturaID
    );
    if (!$cupom) return null;

    $uso = pesquisarUmComBindParam(
        "SELECT COUNT(*) AS qtd
         FROM usuario_assinatura_fatura
         WHERE AssinaturaID = ? AND CupomID = ? AND StatusID <> 3",
        'ii', $assinaturaID, $cupom['CupomID']
    );
    $cupom['FaturasUsadas'] = (int)($uso['qtd'] ?? 0);
    $cupom['Vigente'] = ($cupom['FaturasUsadas'] < (int)$cupom['QuantidadeMeses']);
    return $cupom;
}

// ---------------------------------------------------------------------------
// STATUS DA ASSINATURA DO USUÁRIO (dono da conta) + fatura em aberto mais antiga.
// Fonte única usada por: (1) o gate de bloqueio em seguranca.php (StatusID) e
// (2) o polling carregaEspacosListas (avisos de vencimento no front, sem reload).
//
// StatusID (p_assinaturaStatus): 1=Ativa, 2=Desativada (inadimplência, o robô
// suspende na FASE 4), 5=Ativa (pagamento recebido), 6=Cancelada pelo usuário.
// Só o DONO tem linha em usuario_assinatura — membros de equipe retornam null
// (sem assinatura própria => sem aviso/bloqueio por este caminho).
//
// FaturaAberta = fatura StatusID=1 (pendente) de menor vencimento. Subquery
// indexada (AssinaturaID) — barata mesmo rodando a cada request/poll.
// ---------------------------------------------------------------------------
function assinaturaStatusUsuario($userID){
    $userID = (int)$userID;
    if ($userID <= 0) return null;

    $ass = pesquisarUmComBindParam("
        SELECT
            ua.ID AS AssinaturaID,
            ua.StatusID,
            ua.DataRenovacao,
            (7 + COALESCE(ua.DiasCarenciaExtra, 0)) AS DiasCarencia,
            (SELECT f.ID            FROM usuario_assinatura_fatura f
              WHERE f.AssinaturaID = ua.ID AND f.StatusID = 1
              ORDER BY f.DataVencimento ASC LIMIT 1) AS FaturaID,
            (SELECT f.ValorTotal    FROM usuario_assinatura_fatura f
              WHERE f.AssinaturaID = ua.ID AND f.StatusID = 1
              ORDER BY f.DataVencimento ASC LIMIT 1) AS FaturaValorTotal,
            (SELECT f.DataVencimento FROM usuario_assinatura_fatura f
              WHERE f.AssinaturaID = ua.ID AND f.StatusID = 1
              ORDER BY f.DataVencimento ASC LIMIT 1) AS FaturaDataVencimento,
            -- Forma de pagamento DA FATURA (1 Pix, 2 Boleto, 3 Cartão). É ela que a
            -- FASE 3 do robô usa para decidir o que é cobrado automaticamente, e é o
            -- que o aviso do menu precisa saber: no cartão não existe 'pague antes do
            -- vencimento', a cobrança sai sozinha no dia.
            (SELECT f.FormaPagamentoID FROM usuario_assinatura_fatura f
              WHERE f.AssinaturaID = ua.ID AND f.StatusID = 1
              ORDER BY f.DataVencimento ASC LIMIT 1) AS FaturaFormaPagamentoID
        FROM usuario_assinatura ua
        WHERE ua.UsuarioID = ?
        ORDER BY ua.ID DESC
        LIMIT 1
    ", 'i', $userID);

    // Bloqueada resolvido AQUI, pela mesma função do gate: o front nunca reimplementa
    // a regra (era só "StatusID == 2" no JS e ficou de fora do cancelamento vencido).
    if (!empty($ass))
        $ass['Bloqueada'] = assinaturaBloqueadaPorStatus($ass['StatusID'], $ass['DataRenovacao']) ? 1 : 0;

    return $ass;
}

// ---------------------------------------------------------------------------
// DOWNGRADE: O QUE O PLANO ESCOLHIDO NÃO COMPORTA.
// Fonte única usada pelo aviso no front (ação verificaLimitesPlano) e pelo
// bloqueio no fechamento do pedido (gerarPedido). Retorna [] quando cabe tudo.
//
// O uso é contado do jeito REAL (COUNT nas tabelas), nunca pelos contadores
// denormalizados PerfisQt_EmUso/EspacosQt_EmUso — eles podem estar defasados e
// aqui o número vira decisão de bloqueio. Mesmo critério do save-page.php:
// marca ativa = perfis.StatusID 1; espaço ativo = espaco.SituacaoID 1.
//
// $armazenamentoExtraGB:
//   null  -> não avalia armazenamento (na escolha do plano o extra ainda não foi
//            definido, e o usuário PODE compensar a base menor comprando extra).
//   >= 0  -> avalia base do plano + extra escolhido contra o que já está ocupado.
//
// Cada item: tipo, rotulo, emUso, limite, excedente, unidade.
// ---------------------------------------------------------------------------
function limitesPlanoExcedidos($userID, $planoID, $armazenamentoExtraGB = null){
    $userID  = (int)$userID;
    $planoID = (int)$planoID;
    if ($userID <= 0 || $planoID <= 0) return [];

    $plano = pesquisarUmComBindParam(
        "SELECT PerfisQtd, EspacosQtd, Armazenamento FROM p_planos WHERE ID = ?", 'i', $planoID
    );
    if (empty($plano)) return [];

    $bloqueios = [];

    $marcas = pesquisarUmComBindParam(
        "SELECT COUNT(*) AS n FROM perfis WHERE UsuarioID = ? AND StatusID = 1", 'i', $userID
    );
    $marcasEmUso = (int)($marcas['n'] ?? 0);
    if ($marcasEmUso > (int)$plano['PerfisQtd']) {
        $bloqueios[] = [
            'tipo'      => 'marcas',
            'rotulo'    => 'marcas',
            'emUso'     => $marcasEmUso,
            'limite'    => (int)$plano['PerfisQtd'],
            'excedente' => $marcasEmUso - (int)$plano['PerfisQtd'],
            'unidade'   => '',
        ];
    }

    $espacos = pesquisarUmComBindParam(
        "SELECT COUNT(*) AS n FROM espaco WHERE UsuarioID = ? AND SituacaoID = 1", 'i', $userID
    );
    $espacosEmUso = (int)($espacos['n'] ?? 0);
    if ($espacosEmUso > (int)$plano['EspacosQtd']) {
        $bloqueios[] = [
            'tipo'      => 'espacos',
            'rotulo'    => 'espaços',
            'emUso'     => $espacosEmUso,
            'limite'    => (int)$plano['EspacosQtd'],
            'excedente' => $espacosEmUso - (int)$plano['EspacosQtd'],
            'unidade'   => '',
        ];
    }

    if ($armazenamentoExtraGB !== null) {
        // ExtraArmazenamentoGB_EmUso guarda o TOTAL de GB ocupados (o nome engana).
        $ass = pesquisarUmComBindParam(
            "SELECT COALESCE(ExtraArmazenamentoGB_EmUso,0) AS EmUso FROM usuario_assinatura WHERE UsuarioID = ?", 'i', $userID
        );
        $gbEmUso    = (float)($ass['EmUso'] ?? 0);
        $gbDisponivel = (float)$plano['Armazenamento'] + (float)$armazenamentoExtraGB;
        if ($gbEmUso > $gbDisponivel) {
            $bloqueios[] = [
                'tipo'      => 'armazenamento',
                'rotulo'    => 'armazenamento',
                'emUso'     => $gbEmUso,
                'limite'    => $gbDisponivel,
                'excedente' => round($gbEmUso - $gbDisponivel, 2),
                'unidade'   => 'GB',
            ];
        }
    }

    return $bloqueios;
}

// Monta a frase de bloqueio a partir do retorno de limitesPlanoExcedidos().
function mensagemLimitesPlano($bloqueios){
    $partes = [];
    foreach ($bloqueios as $b) {
        $partes[] = $b['excedente'] . ($b['unidade'] !== '' ? $b['unidade'] : '') . ' de ' . $b['rotulo']
                  . ' (você usa ' . $b['emUso'] . $b['unidade'] . ' e o plano permite ' . $b['limite'] . $b['unidade'] . ')';
    }
    return 'Este plano é menor do que o que você já usa. Antes de trocar, você precisa liberar: '
         . implode('; ', $partes) . '.';
}

// ---------------------------------------------------------------------------
// A ASSINATURA ESTÁ BLOQUEADA? — REGRA ÚNICA de revogação de acesso.
// Usada pelo gate por contexto (seguranca.php), pelo aviso do menu e pelo worker
// de publicação. Quem decide NÃO é o StatusID sozinho, é o PERÍODO JÁ PAGO:
//
//   2 (desativada por inadimplência) -> bloqueia na hora. O cliente teve 7 dias
//       (+ carência) de tolerância antes de chegar aqui; não há período pago.
//   1 / 5 (ativa) -> libera.
//   qualquer outro (3 aguardando pagamento, 6 cancelada) -> libera SÓ enquanto
//       DataRenovacao ainda não passou, ou seja, até o fim do ciclo que o cliente
//       efetivamente pagou. Depois disso, bloqueia.
//
// O 6 estava de fora e era o furo: cancelar zerava as faturas abertas, tirava a
// assinatura da FASE 2 (não gera mais fatura) e da FASE 4 (não suspende, pois
// exige fatura aberta). Resultado: quem cancelava seguia usando tudo para sempre.
// Agora o cancelamento vale como deve valer — até o fim do que já foi pago.
//
// Ancorar em DataRenovacao também fecha o outro lado: gerar um pedido novo (que
// devolve a assinatura para "aguardando pagamento") NÃO devolve acesso, porque a
// data do ciclo pago só avança quando a fatura é de fato quitada.
// ---------------------------------------------------------------------------
function assinaturaBloqueadaPorStatus($statusID, $dataRenovacao){
    $statusID = (int)$statusID;

    if ($statusID === 2) return true;                       // inadimplente
    if ($statusID === 1 || $statusID === 5) return false;   // ativa

    if (empty($dataRenovacao)) return true;                 // sem ciclo pago conhecido
    return (strtotime($dataRenovacao) < time());            // vale até o fim do pago
}

// ---------------------------------------------------------------------------
// GATE DE ASSINATURA POR CONTEXTO — o bloqueio é POR PERFIL/ESPAÇO, não pelo
// usuário logado. Recebe os ids em contexto (o mesmo que o seguranca.php já
// resolve) e retorna true se o DONO daquele recurso está bloqueado pela regra
// acima. Assim um usuário membro de várias contas fica bloqueado só nos recursos
// das contas irregulares, e segue trabalhando normalmente nas marcas/espaços
// compartilhados de contas ATIVAS.
//
// Uma única consulta indexada (por PK do recurso) resolvendo dono -> assinatura.
// Precedência perfil > tarefa > lista > espaço (o mais específico presente).
// ---------------------------------------------------------------------------
function assinaturaDesativadaContexto($perfisID, $espacoID, $listaID, $tarefaID){
    $sql = null;
    $id  = 0;

    if ((int)$perfisID > 0) {
        $id  = (int)$perfisID;
        $sql = "SELECT ua.StatusID, ua.DataRenovacao
                FROM perfis p
                INNER JOIN usuario_assinatura ua ON ua.UsuarioID = p.UsuarioID
                WHERE p.ID = ? ORDER BY ua.ID DESC LIMIT 1";
    } elseif ((int)$tarefaID > 0) {
        $id  = (int)$tarefaID;
        $sql = "SELECT ua.StatusID, ua.DataRenovacao
                FROM tarefa t
                INNER JOIN espaco e ON e.ID = t.EspacoID
                INNER JOIN usuario_assinatura ua ON ua.UsuarioID = e.UsuarioID
                WHERE t.ID = ? ORDER BY ua.ID DESC LIMIT 1";
    } elseif ((int)$listaID > 0) {
        $id  = (int)$listaID;
        $sql = "SELECT ua.StatusID, ua.DataRenovacao
                FROM espaco_lista el
                INNER JOIN espaco e ON e.ID = el.EspacoID
                INNER JOIN usuario_assinatura ua ON ua.UsuarioID = e.UsuarioID
                WHERE el.ID = ? ORDER BY ua.ID DESC LIMIT 1";
    } elseif ((int)$espacoID > 0) {
        $id  = (int)$espacoID;
        $sql = "SELECT ua.StatusID, ua.DataRenovacao
                FROM espaco e
                INNER JOIN usuario_assinatura ua ON ua.UsuarioID = e.UsuarioID
                WHERE e.ID = ? ORDER BY ua.ID DESC LIMIT 1";
    }

    if ($sql === null) return false; // sem contexto de recurso => não bloqueia

    $row = pesquisarUmComBindParam($sql, 'i', $id);
    if (!$row) return false;         // dono sem assinatura => não bloqueia por aqui

    return assinaturaBloqueadaPorStatus($row['StatusID'], $row['DataRenovacao']);
}

// ---------------------------------------------------------------------------
// DATAS DO PLANEJAMENTO (centraliza a listagem usada em ~6 pontos).
// Une, num só resultado, as datas FIXAS da plataforma (catálogo global
// datasDestaque, vinculadas via perfis_datasDestaque) com as datas
// PERSONALIZADAS da marca (tabela própria perfis_datas).
// Colunas padronizadas: DataVinculoID, DataID, Titulo, Data, Personalizada, CustomID.
//  - Fixa:          Personalizada=0, CustomID=0  (DataVinculoID/DataID = datasDestaque)
//  - Personalizada: Personalizada=1, CustomID=perfis_datas.ID (DataVinculoID/DataID=0)
// UNION ALL (não OR) — mesma regra de performance do projeto.
// ---------------------------------------------------------------------------
function datasPlanejamentoPorPeriodo($perfisID, $dataDe, $dataAte){
    $sql = "
        SELECT ddd.ID DataVinculoID, d.ID DataID, d.Titulo, ddd.Data, 0 Personalizada, 0 CustomID
        FROM datasDestaque d
        INNER JOIN datasDestaque_datas ddd ON ddd.DataDestaqueID = d.ID
        INNER JOIN perfis_datasDestaque pdd ON pdd.DataDatasID = ddd.ID AND pdd.PerfisID = ?
        WHERE ddd.Data BETWEEN ? AND ?
        UNION ALL
        SELECT 0, 0, pd.Titulo, pd.Data, 1, pd.ID
        FROM perfis_datas pd
        WHERE pd.PerfisID = ? AND pd.Deletado = 0 AND pd.Data BETWEEN ? AND ?
        ORDER BY Data ASC
    ";
    return pesquisarComBindParam($sql, 'ississ', $perfisID, $dataDe, $dataAte, $perfisID, $dataDe, $dataAte);
}
function datasPlanejamentoPorAno($perfisID, $ano){
    $sql = "
        SELECT ddd.ID DataVinculoID, d.ID DataID, d.Titulo, ddd.Data, 0 Personalizada, 0 CustomID
        FROM datasDestaque d
        INNER JOIN datasDestaque_datas ddd ON ddd.DataDestaqueID = d.ID
        INNER JOIN perfis_datasDestaque pdd ON pdd.DataDatasID = ddd.ID AND pdd.PerfisID = ?
        WHERE YEAR(ddd.Data) = ?
        UNION ALL
        SELECT 0, 0, pd.Titulo, pd.Data, 1, pd.ID
        FROM perfis_datas pd
        WHERE pd.PerfisID = ? AND pd.Deletado = 0 AND YEAR(pd.Data) = ?
        ORDER BY Data ASC
    ";
    return pesquisarComBindParam($sql, 'iiii', $perfisID, $ano, $perfisID, $ano);
}

// ===========================================================================
// TEXTO DO EDITOR -> TEXTO PURO
//
// A legenda é digitada num contenteditable, então o banco guarda HTML
// ("<br>", "&nbsp;", "<b>"). Rede social não entende nada disso: publicar sem
// limpar manda "<br>" literal para o Instagram.
//
// Esta função era duplicada em worker_publicacao.php e executar_publicacao.php,
// e FALTAVA em meta.php — que é justamente o caminho do "Publicar agora". Agora
// é uma só, aqui, e os três usam esta.
//
// Também é a fronteira da IA: o que vai para o modelo e o que volta dele passa
// por aqui, para o modelo nunca ver nem devolver marcação.
// ===========================================================================
function tratarLegenda($html) {
    if (empty($html)) return '';

    // 1. Normaliza quebras de linha (Windows/Mac/Linux) para \n puro.
    $texto = str_replace(array("\r\n", "\r"), "\n", $html);

    // 2. Remove comentários HTML. O padrão antigo era '//s' — vazio, um no-op
    //    que nunca removeu nada (o "<!-- -->" do código sumiu em alguma colagem).
    $texto = preg_replace('/<!--.*?-->/s', '', $texto);

    // 3. Estrutura vira quebra de linha DE VERDADE, antes de matar as tags.
    //    Se strip_tags viesse primeiro, "linha1<br>linha2" viraria "linha1linha2".
    $texto = preg_replace('/<br\s*\/?>/i', "\n", $texto);
    $texto = preg_replace('/<\/div>/i', "\n", $texto);
    $texto = preg_replace('/<\/p>/i', "\n\n", $texto);
    $texto = preg_replace('/<\/li>/i', "\n", $texto);

    // 4. Mata o que sobrou (<div>, <span>, <b>, <strong>...).
    $texto = strip_tags($texto);

    // 5. Entidades: &nbsp; &amp; &quot; viram os caracteres de verdade.
    $texto = html_entity_decode($texto, ENT_QUOTES | ENT_HTML5, 'UTF-8');

    // 6. Faxina invisível — a API do Instagram rejeita alguns destes.
    $texto = str_replace("\xC2\xA0", ' ', $texto);              // nbsp do Word
    $texto = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $texto); // zero-width / BOM

    // 7. Espaços horizontais repetidos viram um só (não mexe em \n).
    $texto = preg_replace('/[ \t]+/', ' ', $texto);

    // 8. No máximo uma linha em branco entre blocos.
    $texto = preg_replace('/\n\s*\n+/', "\n\n", $texto);

    return trim($texto);
}

/**
 * Normaliza a resposta da IA MANTENDO o HTML — para os campos que são editor
 * rico (contenteditable), onde a formatação do usuário tem que sobreviver.
 *
 * Não confundir com tratarLegenda(), que é o oposto: mata todo o HTML. Aquela
 * é para a hora de PUBLICAR (rede social não entende tag) e para os campos que
 * são <textarea>. Esta é para o campo continuar guardando HTML, como sempre
 * guardou.
 *
 * O que ela resolve: o modelo às vezes devolve markdown (**), cerca de código
 * (```html) ou quebra de linha crua em vez de <br>. Tudo isso vira o HTML que
 * o editor espera. E, como a saída de um modelo não é confiável, só sobrevive
 * uma lista curta de tags — sem atributo nenhum, então não há onclick/onerror.
 */
function iaHtmlLegenda($texto){
    if($texto === null) return '';
    $t = (string)$texto;
    if(trim($t) === '') return '';

    // Cerca de código que o modelo às vezes embrulha na resposta.
    $t = preg_replace('/^\s*```(?:html)?\s*/i', '', $t);
    $t = preg_replace('/\s*```\s*$/', '', $t);

    $t = preg_replace('/<!--.*?-->/s', '', $t);

    // Markdown vira tag de verdade. O system prompt proíbe "**", mas quando
    // escapa é melhor virar negrito do que aparecer literal no campo.
    $t = preg_replace('/\*\*(.+?)\*\*/s', '<b>$1</b>', $t);

    $t = str_replace(array("\r\n", "\r"), "\n", $t);

    // Espaço/newline colado num <br> é enfeite do HTML, não conteúdo. Sem
    // limpar isto antes, "um<br>\ndois" viraria "um\n\ndois" e sairia com DUAS
    // quebras — e cada aprimoramento seguinte dobraria de novo, afastando os
    // parágrafos um pouco mais a cada rodada.
    $t = preg_replace('/[ \t\n]*<br\s*\/?>[ \t\n]*/i', '<br>', $t);

    // TODA quebra (tag ou \n) vira \n primeiro, para no fim virar <br> uma vez
    // só — senão um texto com <br> E \n juntos dobraria as linhas.
    $t = preg_replace('/<br\s*\/?>/i', "\n", $t);
    $t = preg_replace('/<\/(div|p|li|h[1-6])>/i', "\n", $t);

    // Só formatação inline sobrevive...
    $t = strip_tags($t, '<b><strong><i><em><u>');
    // ...e sem atributo nenhum (o strip_tags acima preserva o que vier dentro
    // da tag permitida, incluindo style/onmouseover).
    $t = preg_replace('/<(\/?)(b|strong|i|em|u)\b[^>]*>/i', '<$1$2>', $t);

    $t = str_replace("\xC2\xA0", ' ', $t);
    $t = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $t);
    $t = preg_replace('/[ \t]+/', ' ', $t);
    $t = preg_replace('/\n\s*\n+/', "\n\n", $t);
    $t = trim($t);

    // De volta ao formato que o contenteditable usa.
    return str_replace("\n", '<br>', $t);
}

// ===========================================================================
// ORÇAMENTO MENSAL DE IA DE TEXTO
//
// Teto de CUSTO (não de quantidade). Quantidade não serve aqui porque as ações
// custam coisas muito diferentes: um lote de 30 posts sai por ~20x o preço de
// aprimorar uma legenda. Contando "gerações" o lote sairia de graça.
//
// O usuário nunca vê reais — só o % consumido do ciclo. Reais são contabilidade
// interna, e o número em BRL fica congelado na linha de perfis_tokens.
//
// Migration: BIN/migrations/2026-08-09_ia_texto_limite.sql
// ===========================================================================

// Câmbio interno da conversão USD->BRL do custo de IA.
//
// Fica acima do spot de propósito (spot em 09/08/2026: R$ 5,08). O teto do
// plano é margem: se o dólar sobe e a conversão fosse justa, o custo real
// estouraria o orçamento sem que ninguém percebesse. Com buffer, uma alta de
// até ~8% já está paga.
//
// Ao reajustar, o passado NÃO muda: perfis_tokens.CustoBRL já está gravado.
define('IA_TEXTO_CAMBIO', 5.50);

// Modelo de texto padrão. gpt-5.6-luna é modelo de raciocínio: sem o
// reasoning_effort abaixo ele cobra ~285 tokens de raciocínio por chamada como
// saída, o que encarece 36% sem ganho perceptível em copy.
define('IA_TEXTO_MODELO', 'gpt-5.6-luna');
define('IA_TEXTO_ESFORCO', 'none');

/**
 * Preço por 1M de tokens, em USD. Fonte: tabela pública da OpenAI (ago/2026).
 *
 * 'taxa' é cobrança FIXA por chamada, que não aparece no bloco usage. Os
 * modelos de busca cobram US$ 25 por 1.000 buscas — US$ 0,025 por chamada, ou
 * ~275x o custo dos tokens dela. Sem essa linha o medidor marcaria quase zero
 * numa ação que é, de longe, a mais cara do produto.
 *
 * A ORDEM importa: o casamento é por prefixo e a API devolve o snapshot
 * ("gpt-4o-mini-search-preview-2025-03-11"), que também começa com
 * "gpt-4o-mini". O mais específico precisa vir primeiro.
 */
function iaTextoPrecos($model){
    $tabela = array(
        'gpt-4o-mini-search-preview' => array('in' => 0.15, 'cache' => 0.075, 'out' => 0.60, 'taxa' => 0.025),
        'gpt-4o-search-preview'      => array('in' => 2.50, 'cache' => 1.25,  'out' => 10.00, 'taxa' => 0.025),
        'gpt-5.6-luna'               => array('in' => 0.20, 'cache' => 0.02,  'out' => 1.20, 'taxa' => 0),
        'gpt-5.6-terra'              => array('in' => 2.00, 'cache' => 0.20,  'out' => 12.00, 'taxa' => 0),
        'gpt-5.6-sol'                => array('in' => 5.00, 'cache' => 0.50,  'out' => 30.00, 'taxa' => 0),
        'gpt-4o-mini'                => array('in' => 0.15, 'cache' => 0.075, 'out' => 0.60, 'taxa' => 0),
    );
    foreach($tabela as $nome => $preco){
        if(strpos((string)$model, $nome) === 0) return $preco;
    }
    // Modelo desconhecido cai no padrão: nunca contabilizar custo zero, senão
    // uma troca de modelo esvazia o medidor sem ninguém perceber.
    return $tabela[IA_TEXTO_MODELO];
}

/**
 * Custo em USD de uma chamada, a partir do bloco usage da OpenAI.
 * Tokens cacheados são cobrados à parte e precisam sair do total de entrada,
 * senão são contados duas vezes.
 */
function iaTextoCustoUSD($usage, $model){
    $in    = (int)($usage['prompt_tokens'] ?? 0);
    $out   = (int)($usage['completion_tokens'] ?? 0);
    $cache = (int)($usage['prompt_tokens_details']['cached_tokens'] ?? 0);

    if($cache > $in) $cache = $in;
    $inCheio = $in - $cache;

    $p = iaTextoPrecos($model);
    $tokens = ($inCheio * $p['in'] + $cache * $p['cache'] + $out * $p['out']) / 1000000.0;

    return $tokens + (float)$p['taxa'];
}

/**
 * Dono da conta que paga a IA de um contexto.
 *
 * Sempre o DONO, nunca quem clicou: um membro convidado usa o plano de quem o
 * convidou (mesma regra dos créditos de imagem). Sem isso, um membro sem
 * assinatura própria ficaria bloqueado dentro da marca do cliente.
 *
 * Ordem: marca > espaço > o próprio usuário. O espaço cobre a IA do documento,
 * que nem sempre tem marca.
 */
function iaTextoDono($perfisID, $userID, $espacoID = 0){
    $perfisID = (int)$perfisID;
    if($perfisID > 0){
        $r = pesquisarUmComBindParam("SELECT UsuarioID FROM perfis WHERE ID = ?", 'i', $perfisID);
        if(!empty($r['UsuarioID'])) return (int)$r['UsuarioID'];
    }

    $espacoID = (int)$espacoID;
    if($espacoID > 0){
        $r = pesquisarUmComBindParam("SELECT UsuarioID FROM espaco WHERE ID = ?", 'i', $espacoID);
        if(!empty($r['UsuarioID'])) return (int)$r['UsuarioID'];
    }

    return (int)$userID;
}

/**
 * Situação do orçamento de texto do dono no ciclo atual.
 *
 * Ciclo = usuario_assinatura.DataInicio até DataRenovacao, o mesmo dos créditos
 * de imagem. DataInicio avança a cada renovação paga, então o consumo zera
 * sozinho — não existe rotina de reset para dar manutenção.
 *
 * Retorna sempre o mesmo shape, para o chamador não precisar testar existência.
 */
function iaTextoOrcamento($donoID){
    $donoID = (int)$donoID;

    $base = array(
        'assinante'  => false,
        'limite'     => 0.0,
        'usado'      => 0.0,
        'disponivel' => 0.0,
        'perc'       => 0,
        'bloqueado'  => true,
        'reset'      => '',
        'donoID'     => $donoID
    );

    if($donoID <= 0) return $base;

    // StatusID 1 = ativa, 5 = cortesia (vale como ativa e nunca é cobrada).
    $sql = "
        SELECT ua.DataInicio, ua.DataRenovacao,
               COALESCE(ua.IaTextoLimiteExtra, 0)   AS extra,
               COALESCE(ppl.IaTextoLimiteMensal, 0) AS incluso
        FROM usuario_assinatura ua
        LEFT JOIN p_planos ppl ON ppl.ID = ua.PlanoID
        WHERE ua.UsuarioID = ? AND ua.StatusID IN (1, 5)
        LIMIT 1
    ";
    $ass = pesquisarUmComBindParam($sql, 'i', $donoID);

    if(empty($ass) || empty($ass['DataInicio'])) return $base;

    $limite = (float)$ass['incluso'] + (float)$ass['extra'];

    // Soma o custo já gravado de todas as marcas do dono no ciclo. CustoBRL é
    // congelado no INSERT, então esta soma não se mexe se o câmbio mudar.
    //
    // Soma por DonoID, não por UsuarioID: quem clicou pode ser um membro
    // convidado, e o consumo dele sai do teto de quem paga.
    $uso = pesquisarUmComBindParam(
        "SELECT COALESCE(SUM(CustoBRL), 0) AS gasto
           FROM perfis_tokens
          WHERE DonoID = ? AND Data >= ?",
        'is', $donoID, $ass['DataInicio']
    );
    $usado = (float)($uso['gasto'] ?? 0);

    $perc = $limite > 0 ? (int)floor(($usado / $limite) * 100) : 100;
    if($perc > 100) $perc = 100;

    return array(
        'assinante'  => true,
        'limite'     => $limite,
        'usado'      => $usado,
        'disponivel' => max(0.0, $limite - $usado),
        'perc'       => $perc,
        'bloqueado'  => ($limite <= 0 || $usado >= $limite),
        'reset'      => (string)($ass['DataRenovacao'] ?? ''),
        'donoID'     => $donoID
    );
}

/**
 * Grava o consumo da chamada. Uma linha por chamada, em perfis_tokens.
 *
 * O custo é gravado SEMPRE, inclusive quando a chamada estourou o orçamento no
 * meio: o dinheiro já foi gasto na OpenAI, e não registrar deixaria o ciclo
 * seguinte pagar por ele.
 */
function iaTextoRegistrarUso($userID, $perfisID, $usage, $model, $local, $espacoID = 0){
    $in    = (int)($usage['prompt_tokens'] ?? 0);
    $out   = (int)($usage['completion_tokens'] ?? 0);
    $cache = (int)($usage['prompt_tokens_details']['cached_tokens'] ?? 0);
    $total = (int)($usage['total_tokens'] ?? ($in + $out));

    $usd = iaTextoCustoUSD($usage, $model);
    $brl = $usd * IA_TEXTO_CAMBIO;
    $cambio = IA_TEXTO_CAMBIO;

    // UsuarioID = quem clicou (é o que o painel admin conta como usuário ativo).
    // DonoID    = quem paga (é por ele que o teto soma). São coisas diferentes
    // sempre que um membro convidado gera algo na marca do cliente.
    $donoID = iaTextoDono($perfisID, $userID, $espacoID);

    $sql = "
        INSERT INTO perfis_tokens
            (UsuarioID, DonoID, PerfisID, Tokens, TokensEntrada, TokensSaida, TokensCache,
             CustoUSD, CustoBRL, Cambio, Model, Local)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    ";
    insertComBindParam($sql, 'iiiiiiidddss',
        (int)$userID, $donoID, (int)$perfisID, $total, $in, $out, $cache,
        $usd, $brl, $cambio, $model, $local
    );

    return $brl;
}

/**
 * Resposta única de bloqueio. Todo ponto que gera texto devolve exatamente
 * isto, para o front ter um só formato para tratar.
 */
function iaTextoRespostaBloqueio($orc){
    return array(
        'ok'        => 0,
        'iaLimite'  => 1,
        'code'      => 'LIMITE_IA_TEXTO',
        'assinante' => !empty($orc['assinante']) ? 1 : 0,
        'perc'      => (int)($orc['perc'] ?? 100),
        'reset'     => (string)($orc['reset'] ?? '')
    );
}

/**
 * Monta o campo do orçamento do jeito que o front consome: só % e data de
 * reset. Valor em reais NÃO sai daqui — é custo interno.
 */
function iaTextoParaFront($perfisID, $userID, $espacoID = 0){
    $orc = iaTextoOrcamento(iaTextoDono($perfisID, $userID, $espacoID));
    return array(
        'assinante' => !empty($orc['assinante']) ? 1 : 0,
        'perc'      => (int)$orc['perc'],
        'bloqueado' => !empty($orc['bloqueado']) ? 1 : 0,
        'reset'     => (string)$orc['reset']
    );
}

/**
 * Monta um bloco de contexto de briefing para geração de conteúdo com IA.
 * Reúne, numa única fonte da verdade: dados da empresa, público-alvo,
 * objetivos estratégicos do período e datas comemorativas/importantes —
 * para que legenda, objetivo, conteúdo e ideias em lote saiam coesos com
 * o planejamento.
 *
 * @param int    $perfisID
 * @param string $dataRef Data de referência do post ("AAAA-MM-DD" ou datetime). Vazio = hoje.
 * @param array  $opc     Opções:
 *   - incluirObjetivos (bool, default true)
 *   - incluirDatas     (bool, default true)
 *   - destacarVigente  (bool, default true) marca o objetivo/data que cai na data de referência
 *   - assuntoDefinido  (bool, default false) tema explícito do usuário manda; objetivos/datas viram contexto
 *   - periodoDe/periodoAte (string) modo LOTE: objetivos e datas usam esse intervalo exato.
 *                          Sem eles (modo POST ÚNICO): objetivos e datas ficam só no dia da data de referência.
 *   - tag              (bool, default true) envolve em <transcrição>...</transcrição>
 * @return string '' se a marca não existir.
 */
function montarContextoBriefingIA($perfisID, $dataRef = '', $opc = array()){
    $perfisID = (int)$perfisID;

    $incluirObjetivos = !isset($opc['incluirObjetivos']) || $opc['incluirObjetivos'];
    $incluirDatas     = !isset($opc['incluirDatas'])     || $opc['incluirDatas'];
    $destacarVigente  = !isset($opc['destacarVigente'])  || $opc['destacarVigente'];
    $usarTag          = !isset($opc['tag'])              || $opc['tag'];
    $incluirVisual    = !isset($opc['incluirVisual'])    || $opc['incluirVisual'];

    // Assunto/tema definido pelo usuário: quando true, os objetivos estratégicos
    // e datas viram CONTEXTO de apoio (não mandato). Evita que uma campanha/promo
    // cadastrada nos objetivos (ex.: "leve 4 pague 3") sequestre um pedido como
    // "mensagem para dia dos pais". A geração em lote deixa false (aí conectar aos
    // objetivos do período é justamente o desejado).
    $assuntoDefinido  = isset($opc['assuntoDefinido']) && $opc['assuntoDefinido'];

    // Data de referência (só a parte AAAA-MM-DD)
    $dataRef = trim((string)$dataRef);
    $ts = $dataRef !== '' ? strtotime($dataRef) : false;
    if($ts === false) $ts = time();
    $dataRefDia = date('Y-m-d', $ts);

    // ---- Dados da empresa + público-alvo ----
    // p.Pais pode não existir se a migration 2026-08-13_regiao_atendimento.sql
    // ainda não rodou — por isso SELECT * em vez de listar a coluna, mesmo
    // padrão tolerante que o bloco de perfis_sobre logo abaixo já usa.
    $sql = "SELECT * FROM perfis p WHERE p.ID = ?";
    $p = pesquisarUmComBindParam($sql, 'i', $perfisID);
    if(!$p) return '';

    $ctx  = '#Dados da empresa:';
    $ctx .= "\nNome: ".$p['Nome'];
    if(strlen(trim($p['Segmento'])) > 0)
        $ctx .= "\nSegmento: ".$p['Segmento'];

    // ONDE A MARCA ESTÁ (sede). Serve para a IA situar moeda, feriados,
    // sotaque e referências — não é onde ela atende.
    $cidadeUF = trim($p['Cidade'].(strlen(trim($p['UF'])) > 0 ? '-'.$p['UF'] : ''), '-');
    if(strlen($cidadeUF) > 0)
        $ctx .= "\nSede (de onde a marca fala): ".$cidadeUF;
    if(isset($p['Pais']) && strlen(trim($p['Pais'])) > 0)
        $ctx .= "\nPaís: ".trim($p['Pais']);

    if(strlen(trim($p['Observacoes'])) > 0)
        $ctx .= "\nSobre: ".$p['Observacoes'];

    if(strlen(trim($p['PublicoAlvo'])) > 0
        || strlen(trim($p['PublicoAlvoComportamento'])) > 0
        || strlen(trim($p['PublicoAlvoComunicacao'])) > 0){
        $ctx .= "\n\n#Público-alvo:";
        if(strlen(trim($p['PublicoAlvo'])) > 0)
            $ctx .= "\nQuem é: ".$p['PublicoAlvo'];
        if(strlen(trim($p['PublicoAlvoComportamento'])) > 0)
            $ctx .= "\nComportamento: ".$p['PublicoAlvoComportamento'];
        if(strlen(trim($p['PublicoAlvoComunicacao'])) > 0)
            $ctx .= "\nTom de comunicação: ".$p['PublicoAlvoComunicacao'];
    }

    // ---- Briefing da marca (perfis_sobre): posicionamento, comunicação e persona ----
    // Usa isset() em cada coluna para ser tolerante caso a migration de novos
    // campos ainda não tenha sido aplicada no banco.
    $s = pesquisarUmComBindParam("SELECT * FROM perfis_sobre WHERE PerfisID = ?", 'i', $perfisID);
    if($s){
        $montaBloco = function($titulo, $mapa) use ($s){
            $linhas = '';
            foreach($mapa as $col => $lbl){
                if(isset($s[$col]) && strlen(trim($s[$col])) > 0)
                    $linhas .= "\n".$lbl.': '.trim($s[$col]);
            }
            return $linhas !== '' ? "\n\n#".$titulo.':'.$linhas : '';
        };

        // ONDE A MARCA QUER ATENDER. Fica logo depois da sede, e o rótulo diz à
        // IA que este é o foco PADRÃO — um objetivo vigente do planejamento
        // (bloco mais abaixo, com data de início e fim) pode apontar outra
        // região naquele período, e aí o objetivo é que manda.
        if(isset($s['RegiaoAtendimento']) && strlen(trim($s['RegiaoAtendimento'])) > 0)
            $ctx .= "\nRegião de atendimento preferencial (foco padrão; um objetivo vigente pode redirecionar): ".trim($s['RegiaoAtendimento']);

        $ctx .= $montaBloco('Posicionamento da marca', array(
            'PropostaValor' => 'Proposta de valor',
            'Missao'        => 'Missão',
            'Visao'         => 'Visão',
            'ValoresMarca'  => 'Valores',
            'Diferenciais'  => 'Diferenciais',
            'Concorrentes'  => 'Concorrentes/referências',
            'Arquetipo'     => 'Arquétipo de marca',
        ));

        $ctx .= $montaBloco('Comunicação e identidade', array(
            'TomVoz'             => 'Tom de voz',
            'PersonalidadeMarca' => 'Personalidade',
            'EstiloVisualMarca'  => 'Estilo visual',
            'PilaresConteudo'    => 'Pilares de conteúdo',
            'PalavrasUsa'        => 'Palavras que usa',
            'PalavrasEvita'      => 'Palavras/temas que evita',
            'Hashtags'           => 'Hashtags padrão',
        ));

        $ctx .= $montaBloco('Persona detalhada', array(
            'PersonaDemografia'    => 'Demografia',
            'PersonaComportamento' => 'Comportamento',
            'PersonaDores'         => 'Dores',
            'PersonaDesejos'       => 'Desejos',
            'PersonaObjecoes'      => 'Objeções',
            'PersonaCanais'        => 'Canais',
        ));
    }

    // ---- Identidade visual (perfis_visual): cores e fontes da marca ----
    // Relevante sobretudo para as observações/direção de arte de Artes e Carrosséis.
    // isset() por coluna para tolerar bases sem a migration de IaTomVoz.
    if($incluirVisual){
        $v = pesquisarUmComBindParam(
            "SELECT CoresPrimarias, CoresSecundarias, FonteTitulos, FonteTextos, IaTomVoz
             FROM perfis_visual WHERE PerfisID = ?",
            'i', $perfisID
        );
        if($v){
            $vis = '';
            if(strlen(trim($v['CoresPrimarias'] ?? '')) > 0)   $vis .= "\nCores primárias: ".trim($v['CoresPrimarias']);
            if(strlen(trim($v['CoresSecundarias'] ?? '')) > 0) $vis .= "\nCores secundárias: ".trim($v['CoresSecundarias']);
            if(strlen(trim($v['FonteTitulos'] ?? '')) > 0)     $vis .= "\nFonte de títulos: ".trim($v['FonteTitulos']);
            if(strlen(trim($v['FonteTextos'] ?? '')) > 0)      $vis .= "\nFonte de textos: ".trim($v['FonteTextos']);
            if(strlen(trim($v['IaTomVoz'] ?? '')) > 0)         $vis .= "\nTom de voz (IA): ".trim($v['IaTomVoz']);
            if($vis !== '')
                $ctx .= "\n\n#Identidade visual da marca (use nas artes e na direção de arte das observações):".$vis;
        }
    }

    // Escopo de período — vale para objetivos E datas:
    //  - lote (periodoDe/periodoAte explícito): usa o intervalo do lote;
    //  - post único: usa SÓ o dia exato do post (vigência no dia).
    // Impede que objetivos/datas de outro período (ex.: uma campanha "leve 4 pague 3"
    // de 2025) vazem para um post de 2026 — a seção é "do período", respeita o período.
    $temPeriodoExplicito = isset($opc['periodoDe'], $opc['periodoAte']);
    if($temPeriodoExplicito){
        $de  = $opc['periodoDe'];
        $ate = $opc['periodoAte'];
    } else {
        $de = $ate = $dataRefDia;
    }

    // ---- Objetivos estratégicos do período ----
    if($incluirObjetivos){
        // Sobreposição do período do objetivo com o escopo [$de, $ate].
        // No post único ($de == $ate == dia do post) => só objetivos vigentes no dia.
        $objs = pesquisarComBindParam("
            SELECT po.Texto, po.DataDe, po.DataAte
            FROM perfis_objetivos po
            WHERE po.PerfisID = ? AND po.DataDe <= ? AND po.DataAte >= ?
            ORDER BY po.DataDe ASC
        ", 'iss', $perfisID, $ate, $de);

        if(!empty($objs)){
            $ctx .= "\n\n#Objetivos estratégicos do período:";
            foreach($objs as $o){
                $vigente = $destacarVigente && !$assuntoDefinido
                    && $o['DataDe'] <= $dataRefDia && $dataRefDia <= $o['DataAte'];
                $ctx .= "\n- ".$o['Texto']
                      . ' (período: '.$o['DataDe'].' a '.$o['DataAte'].')'
                      . ($vigente ? ' [OBJETIVO VIGENTE NESTE POST — priorize-o]' : '');
            }
        }
    }

    // ---- Datas comemorativas/importantes ----
    if($incluirDatas){
        $datas = datasPlanejamentoPorPeriodo($perfisID, $de, $ate);
        if(!empty($datas)){
            $ctx .= "\n\n#Datas comemorativas/importantes (considere ao criar o conteúdo):";
            foreach($datas as $d){
                $marca = ($destacarVigente && date('Y-m-d', strtotime($d['Data'])) === $dataRefDia)
                    ? ' [É A DATA DESTE POST]' : '';
                $ctx .= "\n- ".$d['Titulo'].': '.$d['Data'].$marca;
            }
        }
    }

    if($assuntoDefinido)
        $ctx .= "\n\n#PRIORIDADE: o TEMA/ASSUNTO pedido pelo usuário é a prioridade absoluta do conteúdo. Use os objetivos estratégicos, datas e demais dados acima apenas como CONTEXTO de apoio (tom de voz, público, marca). NÃO transforme o post em promoção, campanha, oferta ou desconto que não tenha sido explicitamente pedido no assunto.";
    else
        $ctx .= "\n\n#Conecte o conteúdo aos objetivos estratégicos e às datas importantes acima.";

    if($usarTag)
        $ctx = "<transcrição>\n".$ctx."\n</transcrição>";

    return $ctx;
}

/**
 * Bloco COMPACTO de briefing com as informações-CHAVE da marca, para contextos
 * de TEXTO que não carregam o briefing completo (ex.: split de carrossel).
 * Traz só o essencial para o texto sair on-brand — segmento, público, tom de voz,
 * palavras que a marca usa/evita e pilares de conteúdo — sem objetivos/datas
 * (que dependem de período) nem os blocos longos de posicionamento/persona.
 *
 * @param int $perfisID
 * @return string '' quando não há nada preenchido.
 */
function montarBriefingChaveIA($perfisID){
    $perfisID = (int)$perfisID;
    if($perfisID <= 0) return '';

    $p = pesquisarUmComBindParam(
        "SELECT Nome, Segmento, PublicoAlvo, PublicoAlvoComunicacao FROM perfis WHERE ID = ?",
        'i', $perfisID
    );
    if(!$p) return '';

    $s = pesquisarUmComBindParam(
        "SELECT TomVoz, PalavrasUsa, PalavrasEvita, PilaresConteudo FROM perfis_sobre WHERE PerfisID = ?",
        'i', $perfisID
    );

    $linhas = array();
    $add = function($lbl, $val) use (&$linhas){
        if($val !== null && strlen(trim($val)) > 0)
            $linhas[] = $lbl.': '.trim($val);
    };

    $add('Marca', $p['Nome'] ?? '');
    $add('Segmento', $p['Segmento'] ?? '');
    $add('Público-alvo', $p['PublicoAlvo'] ?? '');
    // Tom de voz: prioriza o de perfis_sobre; cai para o tom de comunicação do público.
    $tom = (isset($s['TomVoz']) && trim($s['TomVoz']) !== '') ? $s['TomVoz'] : ($p['PublicoAlvoComunicacao'] ?? '');
    $add('Tom de voz', $tom);
    if($s){
        $add('Palavras que a marca usa', $s['PalavrasUsa'] ?? '');
        $add('Palavras/temas que a marca evita', $s['PalavrasEvita'] ?? '');
        $add('Pilares de conteúdo', $s['PilaresConteudo'] ?? '');
    }

    if(empty($linhas)) return '';
    return "#Diretrizes da marca (respeite o tom e o vocabulário ao escrever):\n- ".implode("\n- ", $linhas);
}

function getLongLivedAccessToken($shortLivedToken, $appId, $appSecret) {
    // Endpoint para trocar o token de curta duração por um de longa duração
    $url = "https://graph.facebook.com/v25.0/oauth/access_token";

    // Parâmetros da requisição
    $params = [
        'grant_type' => 'fb_exchange_token',
        'client_id' => $appId,
        'client_secret' => $appSecret,
        'fb_exchange_token' => $shortLivedToken
    ];

    // Iniciando a sessão cURL
    $ch = curl_init();

    // Configurando as opções cURL
    curl_setopt($ch, CURLOPT_URL, $url . '?' . http_build_query($params));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    // Executando a requisição
    $response = curl_exec($ch);

    // Verificando se ocorreu um erro
    if (curl_errno($ch)) {
        echo 'Erro na requisição: ' . curl_error($ch);
        return null;
    }

    // Fechando a sessão cURL
    curl_close($ch);

    // Decodificando a resposta JSON
    $data = json_decode($response, true);

    // Verificando se o access token foi retornado
    if (isset($data['access_token'])) {
        return $data; // Retorna o access token e outros dados
    } else {
        // Caso ocorra um erro
        echo 'Erro ao obter access token: ' . (isset($data['error']['message']) ? $data['error']['message'] : 'Erro desconhecido');
        return null;
    }
}

// =========================================================================
// INSTAGRAM — QUAL CONEXÃO PUBLICA (direta x via Facebook)
// -------------------------------------------------------------------------
// Uma marca pode ter as DUAS conexões do Instagram ao mesmo tempo, e elas NÃO
// são intercambiáveis: cada uma tem seu próprio ID, seu próprio token e seu
// próprio host. Misturar (ID de uma + token da outra) é o bug clássico daqui —
// a Meta responde "objeto não existe / sem permissão" mesmo com tudo conectado.
//
//   direta (Instagram Login)  -> InstagramID     + InstagramToken + graph.instagram.com
//   via Facebook (Page)       -> FaceInstagramID + FaceToken      + graph.facebook.com
//
// Por isso o ID, o token e o host saem SEMPRE juntos, de um lugar só. Nunca
// leia perfis.InstagramID direto para publicar — use este resolvedor.
// =========================================================================

// Resolve o trio (id, token, host) do Instagram de uma marca.
// $perfil é qualquer array que tenha as colunas de perfis (post do worker serve).
// Prioriza a conexão DIRETA: é a que o usuário conectou por último de propósito
// e a que não depende da Página. Cai para a via Facebook quando a direta não
// está utilizável (sem token, token vencido ou ID ainda não resolvido).
// Retorna ['id','token','baseUrl','direto','origem'] — id vazio = não dá para publicar.
function instagramContaResolver($perfil){
    $idDireto    = trim((string)($perfil['InstagramID'] ?? ''));
    $tokenDireto = trim((string)($perfil['InstagramToken'] ?? ''));
    $expireDireto = $perfil['InstagramTokenExpire'] ?? null;
    $diretoNoPrazo = empty($expireDireto) || strtotime($expireDireto) > time();

    if($tokenDireto !== '' && $idDireto !== '' && $diretoNoPrazo){
        return [
            'id'      => $idDireto,
            'token'   => $tokenDireto,
            'baseUrl' => 'https://graph.instagram.com',
            'direto'  => true,
            'origem'  => 'Instagram (conexão direta)'
        ];
    }

    // Via Facebook: o par só vale com os DOIS. ID sem token publica em nada — e
    // devolver 'id' preenchido nesse caso faria o chamador seguir em frente e
    // falhar lá na Meta, em vez de dizer "reconecte a marca".
    $idPagina    = trim((string)($perfil['FaceInstagramID'] ?? ''));
    $tokenPagina = trim((string)($perfil['FaceToken'] ?? ''));
    $parCompleto = ($idPagina !== '' && $tokenPagina !== '');

    return [
        'id'      => $parCompleto ? $idPagina : '',
        'token'   => $tokenPagina,
        'baseUrl' => 'https://graph.facebook.com',
        'direto'  => false,
        'origem'  => 'Instagram (via Facebook)'
    ];
}

// GET curto e blindado na Meta. Devolve array decodificado ou null.
// Timeout baixo de propósito: isto roda dentro do worker, que não pode ficar
// pendurado numa chamada e perder a trava.
function metaGetJson($url, $timeout = 15){
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
    curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
    $resp = curl_exec($ch);
    curl_close($ch);
    if($resp === false) return null;
    $data = json_decode($resp, true);
    return is_array($data) ? $data : null;
}

// AUTO-CURA do ID da conexão DIRETA.
//
// O oauth do Instagram devolve o "Instagram-scoped user ID" (app-scoped), que
// NÃO é o IG ID usado para publicar (/{ig-id}/media). O IG ID de verdade vem do
// /me?fields=user_id. Esta função pergunta à Meta e grava em perfis.InstagramID
// — é o que conserta, sem reconexão manual, as marcas que ficaram sem o ID
// (inclusive as que o tiveram apagado pelo fluxo antigo do Facebook).
// Retorna o IG ID ou ''.
function instagramIDDiretoResolver($perfisID, $instagramToken){
    $perfisID = (int)$perfisID;
    $instagramToken = trim((string)$instagramToken);
    if($perfisID <= 0 || $instagramToken === '') return '';

    $r = metaGetJson(META_IG_URL . '/me?fields=user_id,username&access_token=' . urlencode($instagramToken));
    $igId = trim((string)($r['user_id'] ?? ''));
    if($igId === '') return '';

    updateComBindParam("UPDATE perfis SET InstagramID = ? WHERE ID = ?", 'si', $igId, $perfisID);
    return $igId;
}

// AUTO-CURA do ID da conexão VIA FACEBOOK: pergunta à Página qual conta do
// Instagram está vinculada a ela hoje e grava em perfis.FaceInstagramID.
// Retorna o IG ID ou ''.
function instagramIDPaginaResolver($perfisID, $facebookID, $faceToken){
    $perfisID   = (int)$perfisID;
    $facebookID = trim((string)$facebookID);
    $faceToken  = trim((string)$faceToken);
    if($perfisID <= 0 || $facebookID === '' || $faceToken === '') return '';

    $r = metaGetJson(META_GRAPH_URL . '/' . $facebookID . '?fields=instagram_business_account&access_token=' . urlencode($faceToken));
    $igId = trim((string)($r['instagram_business_account']['id'] ?? ''));
    if($igId === '') return '';

    updateComBindParam("UPDATE perfis SET FaceInstagramID = ? WHERE ID = ?", 'si', $igId, $perfisID);
    return $igId;
}

// Resolve a conta do Instagram e, se o ID do caminho escolhido estiver faltando,
// tenta descobri-lo na Meta antes de desistir. É a versão "com rede" do
// instagramContaResolver, para os caminhos que podem gastar uma chamada HTTP
// (publicação, métricas, manutenção de token).
//
// $perfil precisa ter InstagramID/FaceInstagramID/InstagramToken/FaceToken/FacebookID.
// $perfisID é OBRIGATÓRIO quando $perfil não é uma linha de `perfis`: no worker
// o array é o POST, onde 'ID' é o ID do conteúdo — gravar por ele consertaria a
// marca errada. Por isso 'PerfisID' vem antes de 'ID' no fallback.
function instagramContaResolverComReparo($perfil, $perfisID = 0){
    $perfisID = (int)($perfisID ?: ($perfil['PerfisID'] ?? $perfil['ID'] ?? 0));

    // Token direto sem ID = o estado quebrado que originou tudo isto. Pergunta à
    // Meta ANTES de escolher: se deixasse o resolvedor decidir primeiro, a marca
    // que também tem Página cairia calada para a via Facebook e o ID da conexão
    // direta nunca seria resolvido. Custo só existe enquanto falta o ID.
    if($perfisID > 0 && !empty($perfil['InstagramToken']) && empty($perfil['InstagramID'])){
        $igId = instagramIDDiretoResolver($perfisID, $perfil['InstagramToken']);
        if($igId !== '') $perfil['InstagramID'] = $igId;
    }

    $conta = instagramContaResolver($perfil);
    if($conta['id'] !== '' || $perfisID <= 0) return $conta;

    // Sobrou a Página: descobre o Instagram vinculado a ela.
    if(!empty($perfil['FacebookID']) && !empty($perfil['FaceToken'])){
        $igId = instagramIDPaginaResolver($perfisID, $perfil['FacebookID'], $perfil['FaceToken']);
        if($igId !== ''){
            $perfil['FaceInstagramID'] = $igId;
            return instagramContaResolver($perfil);
        }
    }

    return $conta;
}

// =========================================================================
// MÉTRICAS — SINCRONIZAÇÃO SOB DEMANDA E IMPORTAÇÃO DO HISTÓRICO
// -------------------------------------------------------------------------
// O robô de métricas (browse/publicar/worker/robo_metricas_redes.php) trabalha
// numa janela curta (DIAS_JANELA = 30) porque no dia a dia é isso que muda. Só
// que quem ACABA DE CONECTAR uma rede não tem dia nenhum gravado: a tela
// Métricas abre zerada e vai se preenchendo aos poucos, um dia por execução,
// como se a marca não tivesse passado — quando na verdade a rede social tem o
// histórico inteiro guardado do outro lado.
//
// Quem resolve isso é o botão "Sincronizar métricas" da tela. Estas funções
// são o pedido dele: registram a marca+rede na tabela metricas_sincronizacao e
// cutucam o robô. Quem sabe puxar histórico é o robô (cada API entrega o
// passado de um jeito diferente); aqui não tem regra de negócio nenhuma.
//
// NADA DISTO É AUTOMÁTICO, de propósito: importar um ano custa centenas de
// chamadas de API por marca. Sem linha na tabela, o robô roda a janela de 30
// dias de sempre. A primeira carga só acontece quando alguém clica.
//
// Migration: BIN/migrations/2026-08-14_metricas_sincronizacao.sql
// =========================================================================

// Chave da chamada interna (mesma ideia do 'UserID_kdmv090vmslkvsmsdlk90' que o
// resto do projeto usa): o robô é um arquivo dentro de pasta pública e sem isto
// qualquer um dispararia uma carga larga de qualquer marca só pela URL.
if (!defined('METRICAS_ROBO_CHAVE')) define('METRICAS_ROBO_CHAVE', 'bkfmtr_9f2c1a7d5e4b8306');
if (!defined('METRICAS_ROBO_URL'))   define('METRICAS_ROBO_URL', 'https://airpost.com.br/browse/publicar/worker/robo_metricas_redes.php');

/**
 * A tabela de sincronização existe?
 *
 * Migration é manual neste projeto, então o código precisa conviver com ela
 * ainda não aplicada — nesse caso o botão avisa que não dá para sincronizar
 * agora e o robô segue no ritmo antigo, em vez de estourar erro de banco.
 * Consultado uma vez por processo.
 */
function metricasSincDisponivel() {
    static $existe = null;
    if ($existe !== null) return $existe;

    $r = pesquisarComBindParam("SHOW TABLES LIKE 'metricas_sincronizacao'", '');
    $existe = !empty($r);
    return $existe;
}

/**
 * Redes REALMENTE conectadas de uma marca, no mesmo critério que o robô usa
 * para decidir o que sincronizar. Mora aqui para a regra existir num lugar só:
 * a tela pergunta "o que vou sincronizar?" e o robô pergunta "o que vou
 * varrer?" — se as duas respostas divergirem, o botão promete rede que o robô
 * ignora.
 *
 * Devolve [1..5] (Facebook, Instagram, TikTok, LinkedIn, YouTube).
 */
function metricasRedesConectadas($perfil) {
    if (empty($perfil) || !is_array($perfil)) return [];

    $redes = [];

    if (!empty($perfil['FacebookID']) && !empty($perfil['FaceToken'])) $redes[] = 1;

    // As duas conexões do Instagram (direta e via Página) resolvidas de uma vez
    // só — ler InstagramID solto aqui é o erro clássico deste projeto.
    $contaIg = instagramContaResolver($perfil);
    if (!empty($contaIg['id']) && !empty($contaIg['token'])) $redes[] = 2;

    if (($perfil['TiktokAtivo'] ?? 0) == 1 && !empty($perfil['TiktokTokenRefrash'])) $redes[] = 3;

    // Validade VAZIA não é validade vencida (mesmo critério do robô).
    $liExpira = $perfil['LinkedinTokenExpire'] ?? '';
    if (!empty($perfil['LinkedinToken']) && (empty($liExpira) || strtotime($liExpira) > time())) $redes[] = 4;

    if (($perfil['YoutubeAtivo'] ?? 0) == 1 && !empty($perfil['GoogleRefreshToken'])) $redes[] = 5;

    return $redes;
}

/**
 * Registra o pedido de sincronização de uma marca+rede.
 *
 * Linha nova nasce com Status 0 (primeira carga pendente) — é isso que faz o
 * robô puxar o histórico ANTIGO no primeiro clique. Rede que já teve a carga
 * concluída fica em 2 e o clique vira só um "atualizar agora" de 30 dias: o
 * passado já está no banco e reimportar só queimaria cota.
 *
 * DataSolicitado é o carimbo que a tela usa para saber que ainda está rodando
 * (enquanto DataSincronizado for anterior a ele).
 */
function metricasSincPedir($perfisID, $plataformaID) {
    $perfisID = (int)$perfisID;
    $plataformaID = (int)$plataformaID;
    if ($perfisID <= 0 || $plataformaID <= 0) return false;
    if (!metricasSincDisponivel()) return false;

    return updateComBindParam(
        "INSERT INTO metricas_sincronizacao (PerfisID, PlataformaID, Status, DataSolicitado)
         VALUES (?, ?, 0, NOW())
         ON DUPLICATE KEY UPDATE
            DataSolicitado = NOW(),
            Status         = IF(Status = 2, 2, 0)",
        'ii', $perfisID, $plataformaID
    );
}

/**
 * O robô avisa que terminou aquela rede — é o que faz a tela parar de dizer
 * "sincronizando" e recarregar os gráficos.
 *
 * UPDATE puro, NUNCA INSERT: criar linha aqui transformaria toda marca que o
 * cron toca em "primeira carga pendente", e a importação do histórico voltaria
 * a ser automática pela porta dos fundos.
 *
 * Só carimba com a primeira carga RESOLVIDA (2 concluída, 3 falhou com motivo).
 * Status 0/1 significa que a importação do histórico ainda não rolou — o robô
 * pode ter passado direto por causa da cota da execução (BACKFILL_POR_EXECUCAO)
 * ou por estar rodando em outro processo. Carimbar aí faria a tela anunciar
 * "pronto" com o passado ainda faltando.
 */
function metricasSincFim($perfisID, $plataformaID) {
    $perfisID = (int)$perfisID;
    $plataformaID = (int)$plataformaID;
    if ($perfisID <= 0 || $plataformaID <= 0) return false;
    if (!metricasSincDisponivel()) return false;

    return updateComBindParam(
        "UPDATE metricas_sincronizacao SET DataSincronizado = NOW()
          WHERE PerfisID = ? AND PlataformaID = ? AND Status IN (2, 3)",
        'ii', $perfisID, $plataformaID
    );
}

/**
 * Cutuca o robô para sincronizar AGORA, sem esperar o cron da madrugada.
 *
 * Dispara e larga: o timeout de 800ms é só para a requisição sair — quem
 * responde é o robô, que segue rodando com ignore_user_abort() depois que este
 * cURL desiste. A tela não pode ficar presa esperando uma importação de um ano
 * de histórico terminar.
 *
 * $plataformaID = 0 sincroniza todas as redes da marca numa execução só.
 *
 * Se a chamada não vingar (servidor ocupado, rede interna), não se perde nada:
 * o pedido está gravado e o cron termina o serviço.
 */
function metricasSincDisparar($perfisID, $plataformaID = 0) {
    $perfisID = (int)$perfisID;
    if ($perfisID <= 0) return false;

    $ch = curl_init(METRICAS_ROBO_URL);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
        'chave'  => METRICAS_ROBO_CHAVE,
        'perfil' => $perfisID,
        'rede'   => (int)$plataformaID,
    ]));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_NOSIGNAL, true);       // obrigatório para timeout em ms
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 800);
    curl_setopt($ch, CURLOPT_TIMEOUT_MS, 800);
    curl_exec($ch);
    curl_close($ch);

    return true;
}

/**
 * Estado da sincronização por rede — alimenta o botão e a mensagem da tela.
 *
 * Devolve [ plataformaID => [
 *     'status'    => 0|1|2|3   (estado da primeira carga)
 *     'rodando'   => bool      (pedido feito e ainda não concluído)
 *     'dias'      => int
 *     'mensagem'  => string
 *     'primeira'  => bool      (nunca importou o histórico)
 * ] ]
 */
function metricasSincEstado($perfisID, array $plataformas) {
    $perfisID = (int)$perfisID;
    $out = [];
    if ($perfisID <= 0 || empty($plataformas)) return $out;
    if (!metricasSincDisponivel()) return $out;

    $ids = [];
    foreach ($plataformas as $p) { $p = (int)$p; if ($p > 0) $ids[] = $p; }
    if (empty($ids)) return $out;

    $ph = implode(',', array_fill(0, count($ids), '?'));
    $linhas = pesquisarComBindParam(
        // COALESCE em vez de "IS NULL OR ...": nunca sincronizada é o mesmo que
        // sincronizada antes do pedido, e escrever isso numa comparação só
        // mantém a regra em UM lugar.
        "SELECT PlataformaID, Status, DiasImportados, Mensagem,
                (COALESCE(DataSincronizado, '1970-01-01') < DataSolicitado) AS Rodando
         FROM metricas_sincronizacao
         WHERE PerfisID = ? AND PlataformaID IN ({$ph})",
        'i' . str_repeat('i', count($ids)), $perfisID, ...$ids
    );

    if (empty($linhas) || !is_array($linhas)) return $out;

    foreach ($linhas as $l) {
        $status = (int)$l['Status'];
        $out[(int)$l['PlataformaID']] = [
            'status'   => $status,
            'rodando'  => ((int)$l['Rodando'] === 1),
            'dias'     => (int)$l['DiasImportados'],
            'mensagem' => (string)($l['Mensagem'] ?? ''),
            'primeira' => ($status !== 2),
        ];
    }
    return $out;
}

/**
 * O mesmo estado, mastigado para a tela: é isto que vai no JSON.
 *
 * $plataformas deve ser a lista de redes CONECTADAS da marca
 * (metricasRedesConectadas) — passar as cinco faria o resumo dizer "falta
 * importar o histórico do TikTok" para quem nem tem TikTok.
 *
 * 'primeira' = alguma rede ainda não tem o histórico importado. É o que deixa
 * a tela vazia convidar a clicar no botão em vez de só dizer "sem dados".
 *
 * 'conectadas' e 'importar' existem para o aviso ANTES do clique dizer o que
 * vai acontecer com ESTA marca ("vamos buscar em Facebook e Instagram; o
 * histórico do Instagram vem agora") em vez de um texto genérico. Cada rede vai
 * como ['id','nome','chave'] — a 'chave' é o que a tela usa para desenhar o
 * logo da rede no aviso (biRedeLogo), sem precisar adivinhar pelo nome.
 */
function metricasSincResumo($perfisID, array $plataformas) {
    $nomes  = [1 => 'Facebook', 2 => 'Instagram', 3 => 'TikTok', 4 => 'LinkedIn', 5 => 'YouTube'];
    $chaves = [1 => 'facebook', 2 => 'instagram', 3 => 'tiktok', 4 => 'linkedin', 5 => 'youtube'];
    $estado = metricasSincEstado($perfisID, $plataformas);

    $rodando    = [];
    $notas      = [];
    $conectadas = [];
    $importar   = [];
    $primeira   = false;

    foreach ($plataformas as $p) {
        $p = (int)$p;
        if ($p <= 0) continue;
        $nome = $nomes[$p] ?? 'Rede';
        $rede = ['id' => $p, 'nome' => $nome, 'chave' => $chaves[$p] ?? ''];
        $conectadas[] = $rede;

        // Rede sem linha nenhuma: ninguém nunca mandou importar o histórico dela.
        if (!isset($estado[$p])) { $primeira = true; $importar[] = $rede; continue; }

        $e = $estado[$p];
        if ($e['rodando']) $rodando[] = $rede;

        if ($e['status'] === 2) {
            // Concluída: a mensagem só interessa quando a rede NÃO entregou o
            // passado (TikTok sem escopo, canal sem Analytics...).
            if ($e['mensagem'] !== '' && $e['dias'] <= 0) $notas[] = "{$nome}: {$e['mensagem']}";
            continue;
        }

        $primeira = true;
        $importar[] = $rede;
        // Falhou: o motivo quase sempre é ação do usuário (token vencido, canal
        // sem permissão de leitura) e some da tela se não for dito.
        if ($e['status'] === 3 && $e['mensagem'] !== '') $notas[] = "{$nome}: {$e['mensagem']}";
    }

    return [
        'disponivel' => metricasSincDisponivel(),
        'rodando'    => !empty($rodando),
        'redes'      => $rodando,
        'conectadas' => $conectadas,
        'importar'   => $importar,
        'primeira'   => $primeira,
        'notas'      => $notas,
    ];
}


function getExpiryDate($expires_in) {
    // Obtém a data e hora atuais
    $current_time = time();
    
    // Adiciona os segundos ao tempo atual
    $expiry_time = $current_time + $expires_in;
    
    // Converte o tempo de expiração para o formato 'YYYY-MM-DD HH:MM'
    return date('Y-m-d H:i', $expiry_time);
}
function getExpiryDate12meses() {
    // Obtém a data e hora atuais
    $current_time = time();

    // Adiciona os meses informados à data atual
    $expiry_time = strtotime("+12 months", $current_time);

    // Converte o tempo de expiração para o formato 'YYYY-MM-DD HH:MM'
    return date('Y-m-d H:i', $expiry_time);
}





function postDeleteRedesMeta($postID){
    $url = 'https://airpost.com.br/browse/publicar/meta.php';

    // Inicializa o cURL
    $ch = curl_init();

    // Configura as opções do cURL
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_POST, true); // Define que é uma requisição POST
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Retorna a resposta em vez de exibir

    // Array com os dados do POST
    $postData = [
        'acao' => 'deletarPublicado',
		'UserID_kdmv090vmslkvsmsdlk90' => 'systen',
        'postID' => $postID
    ];

    // Define os dados a serem enviados no POST
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));

    // Executa a requisição e captura a resposta
    $response = curl_exec($ch);

    // Fecha a conexão cURL
    curl_close($ch);

	//echo $response;exit();
	return $response;
}

function getCurrentDateTime() {
    // Obtém a data e hora atuais
    $today = new DateTime();

    // Formata a data e hora no formato desejado: YYYY-MM-DD HH:MM:SS
    return $today->format('Y-m-d H:i:s');
  }

/* ============================================================================
 * FUSO HORÁRIO  (por MARCA, com preferência pessoal do usuário)
 *
 * REGRA MESTRA: quem manda na publicação é o fuso da MARCA.
 * A hora que aparece no agendamento É a hora local daquela marca — marca em
 * Lisboa agendada para 14:00 publica às 14:00 de Lisboa. Não é por post.
 *
 * usuario.FusoHorario é preferência PESSOAL: vira o default ao criar uma marca
 * nova e é o "agora" das telas que não são de uma marca só (agenda, tarefas).
 * A marca NÃO herda em tempo de leitura (nada de COALESCE): fosse assim, trocar
 * o fuso pessoal remarcaria em silêncio todo post já agendado de toda marca.
 *
 * COMO A DATA VIVE NO BANCO
 *   perfis_posts.DataAgendado -> a hora que o usuário escolheu, CRUA.
 * Não existe coluna espelho, nem conversão na gravação. Quem agenda "dia 1,
 * 20:00" grava 20:00 — o servidor não reinterpreta nada.
 *
 * Quem resolve a diferença é o WORKER, na hora da verificação: para cada fuso
 * em uso, o PHP calcula que horas são naquele fuso e compara com a DataAgendado
 * dos posts daquela marca (ver browse/publicar/worker_publicacao.php).
 *
 * Por isso trocar o fuso de uma marca já desloca o momento real de publicação
 * dos pendentes sozinho — não há nada pré-calculado para re-sincronizar.
 * ========================================================================= */

// Fuso do sistema quando não há nada configurado (comportamento histórico).
function fusoHorarioPadrao(){
    return 'America/Sao_Paulo';
}

// Catálogo oferecido nas telas — Brasil primeiro, como pediu o negócio.
// Não use timezone_identifiers_list() cru na UI: são 400+ itens e o usuário
// não faz ideia de qual escolher.
function fusosDisponiveis(){
    return array(
        array('grupo' => 'Brasil', 'itens' => array(
            array('id' => 'America/Sao_Paulo',  'nome' => 'Brasília, São Paulo, Sul e Sudeste'),
            array('id' => 'America/Belem',      'nome' => 'Belém, Fortaleza, Recife, Salvador'),
            array('id' => 'America/Manaus',     'nome' => 'Manaus, Cuiabá, Campo Grande, Porto Velho'),
            array('id' => 'America/Rio_Branco', 'nome' => 'Rio Branco, Acre'),
            array('id' => 'America/Noronha',    'nome' => 'Fernando de Noronha'),
        )),
        array('grupo' => 'Outros países', 'itens' => array(
            array('id' => 'America/Argentina/Buenos_Aires', 'nome' => 'Buenos Aires'),
            array('id' => 'America/Montevideo',   'nome' => 'Montevidéu'),
            array('id' => 'America/Asuncion',     'nome' => 'Assunção'),
            array('id' => 'America/Santiago',     'nome' => 'Santiago'),
            array('id' => 'America/La_Paz',       'nome' => 'La Paz'),
            array('id' => 'America/Lima',         'nome' => 'Lima'),
            array('id' => 'America/Bogota',       'nome' => 'Bogotá'),
            array('id' => 'America/Mexico_City',  'nome' => 'Cidade do México'),
            array('id' => 'America/New_York',     'nome' => 'Nova York, Miami, Toronto'),
            array('id' => 'America/Chicago',      'nome' => 'Chicago, Cidade do Texas'),
            array('id' => 'America/Los_Angeles',  'nome' => 'Los Angeles, Vancouver'),
            array('id' => 'Europe/Lisbon',        'nome' => 'Lisboa, Porto'),
            array('id' => 'Europe/Madrid',        'nome' => 'Madri, Barcelona'),
            array('id' => 'Europe/London',        'nome' => 'Londres, Dublin'),
            array('id' => 'Europe/Paris',         'nome' => 'Paris, Berlim, Roma, Amsterdã'),
            array('id' => 'Africa/Luanda',        'nome' => 'Luanda'),
            array('id' => 'Africa/Maputo',        'nome' => 'Maputo'),
            array('id' => 'Asia/Dubai',           'nome' => 'Dubai'),
            array('id' => 'Asia/Tokyo',           'nome' => 'Tóquio'),
            array('id' => 'Australia/Sydney',     'nome' => 'Sydney'),
        )),
    );
}

// Offset atual em texto ("GMT-3"). Calculado na hora — países com horário de
// verão mudam ao longo do ano e um rótulo fixo mentiria metade do ano.
function fusoOffsetTexto($fuso){
    try {
        $tz  = new DateTimeZone($fuso);
        $seg = $tz->getOffset(new DateTime('now', $tz));
    } catch (Exception $e) {
        return '';
    }

    $sinal = ($seg < 0) ? '-' : '+';
    $seg   = abs($seg);
    $h     = intdiv($seg, 3600);
    $m     = intdiv($seg % 3600, 60);

    return 'GMT' . $sinal . $h . ($m ? ':' . str_pad($m, 2, '0', STR_PAD_LEFT) : '');
}

// Rótulo curto para exibir junto de uma data ("Brasília, São Paulo… (GMT-3)").
function fusoRotulo($fuso){
    $fuso = fusoNormalizar($fuso);
    foreach(fusosDisponiveis() as $grupo){
        foreach($grupo['itens'] as $it){
            if($it['id'] === $fuso)
                return $it['nome'] . ' (' . fusoOffsetTexto($fuso) . ')';
        }
    }
    // Fuso válido fora do catálogo (veio de import/config manual): mostra o ID.
    return str_replace('_', ' ', $fuso) . ' (' . fusoOffsetTexto($fuso) . ')';
}

// Catálogo achatado para o front (window.fusosLst) — o dropdown genérico filtra
// por data-titulo varrendo só os filhos .it, então um cabeçalho de grupo no meio
// da lista nunca seria escondido pela busca. Por isso lista plana, com o grupo
// embutido no texto de busca ("brasil" acha os brasileiros, "lisboa" acha Lisboa).
function fusosParaSelect(){
    $r = array();
    foreach(fusosDisponiveis() as $grupo){
        foreach($grupo['itens'] as $it){
            $r[] = array(
                'ID'     => $it['id'],
                'Nome'   => $it['nome'],
                'Grupo'  => $grupo['grupo'],
                'Offset' => fusoOffsetTexto($it['id']),
                // O que a busca enxerga: nome + grupo + o próprio ID da zona.
                'Busca'  => $it['nome'] . ' ' . $grupo['grupo'] . ' ' . str_replace(array('_', '/'), ' ', $it['id']),
            );
        }
    }
    return $r;
}

// Valida contra a lista oficial da IANA (não contra o catálogo da UI) — assim
// um fuso configurado direto no banco continua funcionando. Mesma régua do bind
// param: nada que veio de POST entra sem passar por aqui.
function fusoValido($fuso){
    if(!is_string($fuso) || $fuso === '')
        return false;

    static $validos = null;
    if($validos === null)
        $validos = array_flip(timezone_identifiers_list());

    return isset($validos[$fuso]);
}

// Devolve sempre um fuso utilizável — cai no padrão em vez de estourar.
function fusoNormalizar($fuso){
    return fusoValido($fuso) ? $fuso : fusoHorarioPadrao();
}

// Fuso pessoal do usuário (default ao criar marca + "agora" das telas gerais).
/**
 * Marca que o usuário está no sistema AGORA.
 *
 * Pendurado no polling que já roda em toda tela (carregaEspacosListas, ~30s).
 * Escreve em dois lugares, com custos e propósitos diferentes:
 *
 *  - `usuario.DataAtualizado` (sobrescreve) responde "está online agora?".
 *    O throttle está no próprio WHERE: se o último toque foi há menos de 2
 *    minutos, o UPDATE casa ZERO linhas e o MySQL não escreve nada. Na prática
 *    a cada 4 ticks do polling só um vira escrita de verdade.
 *
 *  - `usuario_atividade` (append, 1 linha por usuário/dia) responde "quantos
 *    estavam ativos no dia X" — que `UltimoLoginData` nunca respondeu, porque
 *    é sobrescrito. A PK composta faz o INSERT IGNORE ser idempotente; a
 *    guarda de sessão evita até emitir a query depois da primeira vez no dia.
 *
 * Migration: BIN/migrations/2026-08-07_presenca_usuario.sql
 * Falha silenciosa de propósito: presença é telemetria, nunca pode derrubar a
 * tela de quem está trabalhando (updateComBindParam já loga em error_log).
 */
function registrarPresenca($usuarioID){
    $usuarioID = (int)$usuarioID;
    if($usuarioID <= 0) return;

    updateComBindParam(
        "UPDATE usuario
            SET DataAtualizado = NOW()
          WHERE ID = ?
            AND (DataAtualizado IS NULL OR DataAtualizado < DATE_SUB(NOW(), INTERVAL 2 MINUTE))",
        'i', $usuarioID
    );

    // Uma vez por dia por sessão: sem isso o INSERT IGNORE seria emitido a cada
    // 30s só para o banco descartar por conflito de chave.
    $hoje = date('Y-m-d');
    if(isset($_SESSION['presencaDia']) && $_SESSION['presencaDia'] === $hoje) return;

    insertComBindParam(
        "INSERT IGNORE INTO usuario_atividade (UsuarioID, Dia) VALUES (?, ?)",
        'is', $usuarioID, $hoje
    );
    $_SESSION['presencaDia'] = $hoje;
}

function fusoDaConta($usuarioID){
    $usuarioID = (int)$usuarioID;
    if($usuarioID <= 0)
        return fusoHorarioPadrao();

    static $cache = array();
    if(isset($cache[$usuarioID]))
        return $cache[$usuarioID];

    $r = pesquisarUmComBindParam("SELECT FusoHorario FROM usuario WHERE ID = ?", 'i', $usuarioID);
    $cache[$usuarioID] = fusoNormalizar(is_array($r) ? $r['FusoHorario'] : '');

    return $cache[$usuarioID];
}

// Fuso da marca — é ELE que decide quando o post vai ao ar.
// Cache estático porque o worker chama isto em loop, post a post.
/* ===========================================================================
   ORDEM DAS LISTAS DE CONTEUDO — publicacao (padrao) ou entrega
   ---------------------------------------------------------------------------
   A preferencia vive em $_SESSION["filtro_fluxo_ordem"], gravada junto do cache
   de filtros (acao setFluxoFiltro). Vale para a conta inteira, nao por tela:
   quem passou a trabalhar por prazo de entrega quer ver TODAS as listas assim,
   e uma escolha por tela viraria seis lugares para reconfigurar.

   COALESCE, e nao a coluna pura: conteudo sem prazo proprio se ordena pela
   propria publicacao. Sem isso, todo conteudo antigo (DataEntrega NULL, que
   hoje e 100% da base) desabaria em bloco numa ponta da lista e a tela pareceria
   quebrada no primeiro clique.

   Devolve EXPRESSAO SQL fixa, nunca valor de usuario: a unica leitura da sessao
   e um teste de igualdade contra 'entrega'. Nada aqui e concatenavel de fora.
   ============================================================================= */
function ordemConteudoAtual(){
    if(!isset($_SESSION)) return 'publicacao';
    return (isset($_SESSION["filtro_fluxo_ordem"]) && $_SESSION["filtro_fluxo_ordem"] === 'entrega')
        ? 'entrega'
        : 'publicacao';
}

function ordemConteudoSql($alias = 'pp'){
    // $alias e sempre literal de codigo ('pp', 't'...), nunca entrada de request.
    return ordemConteudoAtual() === 'entrega'
        ? "COALESCE($alias.DataEntrega, $alias.DataAgendado)"
        : "$alias.DataAgendado";
}

/* O ORDER BY COMPLETO — use este, nao o ordemConteudoSql sozinho.
   ---------------------------------------------------------------------------
   Ordenar so pela data deixava dois defeitos visiveis nas listas:

   1. SEM DESEMPATE, a ordem de conteudos com a MESMA data e indefinida — e data
      repetida e a regra, nao a excecao: geracao em lote cria a semana inteira
      as 09:00. O MySQL pode devolver esses empates em ordem diferente a cada
      consulta, entao o polling repintava a lista com os cartoes trocados de
      lugar sozinhos. `ID ASC` fixa a ordem de criacao dentro do empate.

   2. SEM DATA vinha PRIMEIRO. `DataAgendado` aceita NULL e tambem o zero do
      MySQL ('0000-00-00 00:00:00'), e os dois sobem ao topo em ASC — o
      conteudo sem data abria a lista, na frente do que e para hoje. Aqui eles
      vao para o fim, que e onde se procura o que ainda nao foi datado.
      (O predicado do zero e o mesmo de BIN/tarefa_conteudo.php.)

   $modo fixa a ordem para as telas em que ela NAO e preferencia do usuario e
   sim a natureza da tela. Sao as que nao tem o chip "Ordenar", porque nelas nao
   ha o que escolher:

     'publicacao' — a GRADE DO FEED. Ela mostra como o perfil vai ficar; fora da
                    sequencia de publicacao a previa nao significa nada.
     'entrega'    — a tela de INICIO ("Seus conteudos em aberto"). E fila de
                    trabalho: o que manda e o prazo para ficar pronto, nao a
                    data em que vai ao ar. Sem prazo proprio o COALESCE cai na
                    publicacao, entao a fila nunca fica sem criterio.

   Sem $modo, segue a escolha da conta. Nos tres casos as duas melhorias acima
   (desempate e data ausente por ultimo) continuam valendo.
   ============================================================================= */
function ordemConteudoOrderBy($alias = 'pp', $modo = null){
    if($modo === 'publicacao')    $campo = "$alias.DataAgendado";
    else if($modo === 'entrega')  $campo = "COALESCE($alias.DataEntrega, $alias.DataAgendado)";
    else                          $campo = ordemConteudoSql($alias);

    return "($campo IS NULL OR $campo <= '1970-01-01 00:00:00') ASC, $campo ASC, $alias.ID ASC";
}

function fusoDaMarca($perfisID){
    $perfisID = (int)$perfisID;
    if($perfisID <= 0)
        return fusoHorarioPadrao();

    static $cache = array();
    if(isset($cache[$perfisID]))
        return $cache[$perfisID];

    $r = pesquisarUmComBindParam("SELECT FusoHorario FROM perfis WHERE ID = ?", 'i', $perfisID);
    $cache[$perfisID] = fusoNormalizar(is_array($r) ? $r['FusoHorario'] : '');

    return $cache[$perfisID];
}

// Mapa { perfisID: fuso } de todas as marcas que o usuário enxerga.
// Vai para window.fusosMarcas no header e é o que o front consulta para saber
// em que relógio uma tela de marca está — sem isso cada endpoint que monta
// lstPerfis teria que lembrar de trazer a coluna, e um esquecido publicaria na
// hora errada em silêncio.
// UNION em vez de OR no WHERE: OR aqui forçaria full scan (regra do projeto).
// Inclui arquivadas (StatusID 2) de propósito: elas não publicam, mas suas telas
// ainda abrem e precisam mostrar data/hora no relógio certo.
function fusosDasMarcasDoUsuario($usuarioID){
    $usuarioID = (int)$usuarioID;
    if($usuarioID <= 0)
        return array();

    $lst = pesquisarComBindParam("
        SELECT p.ID, p.FusoHorario
        FROM perfis p
        WHERE p.UsuarioID = ? AND p.StatusID IN (1, 2)

        UNION

        SELECT p.ID, p.FusoHorario
        FROM perfis p
        INNER JOIN perfis_usuario pu ON pu.PerfisID = p.ID
        WHERE pu.UsuarioID = ? AND p.StatusID IN (1, 2)
    ", 'ii', $usuarioID, $usuarioID);

    $mapa = array();
    if(is_array($lst)){
        foreach($lst as $p)
            $mapa[(string)$p['ID']] = fusoNormalizar($p['FusoHorario']);
    }

    return $mapa;
}

// "Agora" no relógio de um fuso qualquer. É a peça que o worker usa para
// comparar com a DataAgendado crua do post — nada é convertido na gravação,
// a diferença é resolvida aqui, na hora da verificação.
function agoraNoFuso($fuso, $formato = 'Y-m-d H:i:s'){
    try {
        $d = new DateTime('now', new DateTimeZone(fusoNormalizar($fuso)));
        return $d->format($formato);
    } catch (Exception $e) {
        return date($formato);
    }
}

// "Agora" no relógio da marca — use isto no lugar de date('Y-m-d H:i:s') sempre
// que a data for parar em perfis_posts.DataAgendado.
function agoraNaMarca($perfisID, $formato = 'Y-m-d H:i:s'){
    return agoraNoFuso(fusoDaMarca($perfisID), $formato);
}

// "Agora" no relógio do usuário (telas multi-marca: agenda, tarefas, fluxo).
function agoraNaConta($usuarioID, $formato = 'Y-m-d H:i:s'){
    return agoraNoFuso(fusoDaConta($usuarioID), $formato);
}

// Fusos distintos realmente em uso pelas marcas.
//
// SÓ PARA DIAGNÓSTICO (log de boot do worker, telas de suporte). A captação do
// worker NÃO usa isto: ela é montada a partir do catálogo estático
// fusosDisponiveis(), sem tocar no banco — são vários workers em loop, e uma
// consulta a cada volta, vezes N workers, é custo puro por nada.
// Cache estático: no worker roda uma vez na vida do processo.
function fusosEmUsoPelasMarcas(){
    static $cache = null;

    if($cache !== null)
        return $cache;

    $lst = pesquisarComBindParam("SELECT DISTINCT FusoHorario FROM perfis WHERE StatusID IN (1, 2)", '');

    $fusos = array();
    if(is_array($lst)){
        foreach($lst as $f){
            $tz = fusoNormalizar($f['FusoHorario']);
            if(!in_array($tz, $fusos, true))
                $fusos[] = $tz;
        }
    }
    if(empty($fusos))
        $fusos[] = fusoHorarioPadrao();

    $cache = $fusos;

    return $cache;
}

// Quantos conteúdos pendentes uma marca tem — a tela usa para avisar que trocar
// o fuso desloca o momento real de publicação deles (não há nada a reescrever
// no banco, a conta é refeita a cada volta do worker).
function marcaAgendamentosPendentes($perfisID){
    $perfisID = (int)$perfisID;
    if($perfisID <= 0)
        return 0;

    $r = pesquisarUmComBindParam("
        SELECT COUNT(*) AS Qtd
        FROM perfis_posts
        WHERE PerfisID = ? AND Deletado = 0 AND StatusID IN (3, 6)
          AND DataAgendado IS NOT NULL AND DataAgendado > '1970-01-01 00:00:00'
    ", 'i', $perfisID);

    return is_array($r) ? (int)$r['Qtd'] : 0;
}



function enviarNotificacaoOneSignal($userId, $titulo, $mensagem, $caminhoUrl = '') {


    if($_SESSION["UserID"] == $userId){
        return 0;
       // $userId = 'user_'.$userId;
    }
    else{
        $userId = 'user_'.$userId;
    }

    $appId = "8aaab349-da01-491d-9b44-b5fcc34b6eb7"; // Substitua pelo seu app_id
    $apiKey = "os_v2_app_rkvlgso2afer3g2ewx6mgs3ow5eofqet2hpuucuvr3ku5cqefgyaa2zcail237ps7ug7jw25xxq66w4wnczpe3raaiubdrvxtnfmvzy"; // Substitua pela sua chave REST API

    $content = [
        "en" => $mensagem, // Obrigatório
        "pt" => $mensagem  // Opcional: para português
    ];

    $headings = [
        "en" => $titulo,   // Obrigatório
        "pt" => $titulo    // Opcional
    ];

    // Monta a URL final com base no caminho fornecido
    $urlBase = "https://airpost.com.br/browse/";
    $urlFinal = rtrim($urlBase, '/') . '/' . ltrim($caminhoUrl, '/');

    // Alvo: alias external_id (o $userId já vem como 'user_X', mesmo external_id
    // registrado no client via OneSignal.login('user_'+userID)). `target_channel`
    // é obrigatório com include_aliases.
    // Link: usamos APENAS `url` (parâmetro universal que cobre web push). A API
    // REJEITA enviar `url` junto com `web_url`/`app_url`
    // ("Remove url field when setting app_url or web_url") — por isso não há web_url.
    $fields = [
        'app_id' => $appId,
        'include_aliases' => ['external_id' => [$userId]],
        'target_channel' => 'push',
        'headings' => $headings,
        'contents' => $content,
        'url' => $urlFinal
    ];

    $fieldsJson = json_encode($fields);

    $ch = curl_init();
    // API atual do OneSignal: host api.onesignal.com + auth "Key" (o legado
    // onesignal.com/api/v1 com "Basic" foi descontinuado). Ver docs:
    // https://documentation.onesignal.com/reference/push-notification
    curl_setopt($ch, CURLOPT_URL, "https://api.onesignal.com/notifications?c=push");
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json; charset=utf-8',
        'Authorization: Key ' . $apiKey
    ]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $fieldsJson);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);

    $response = curl_exec($ch);
    // DIAGNÓSTICO: a chamada vinha descartando o resultado. Logamos erro de
    // transporte (curl) + HTTP code + corpo da resposta para descobrir por que
    // a notificação não é criada (SSL, auth, "not subscribed", timeout, etc.).
    $curlErrno = curl_errno($ch);
    $curlError = curl_error($ch);
    $httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    //echo $response;

    return $response;
}


function atualizaAssinaturaLimitesPerfisEspacos($usuarioID = null){
    
    $params = [];
    $types = null; 
    $whereClauseInterno = "";
    $whereClauseExterno = "";

    if ($usuarioID !== null && $usuarioID !== '') {
        // Filtra a contagem apenas para esse ID
        $whereClauseInterno = " WHERE ua_base.UsuarioID = ?";
        // Filtra o update apenas para esse ID
        $whereClauseExterno = " WHERE ua.UsuarioID = ?";
        
        $params[] = $usuarioID; 
        $params[] = $usuarioID; // Repete para o segundo placeholder
        $types = "ii"; 
    }

    $sql = "
    UPDATE usuario_assinatura AS ua
    JOIN (
        SELECT
            ua_base.UsuarioID,
            COUNT(DISTINCT p.id) AS TotalPerfis, 
            COUNT(DISTINCT e.id) AS TotalEspacos
        FROM
            usuario_assinatura ua_base 
        LEFT JOIN
            perfis p ON ua_base.UsuarioID = p.UsuarioID AND p.StatusID = 1 
        LEFT JOIN
            espaco e ON ua_base.UsuarioID = e.UsuarioID AND e.SituacaoID = 1
        " . $whereClauseInterno . "
        GROUP BY
            ua_base.UsuarioID
    ) AS m ON m.UsuarioID = ua.UsuarioID
    SET
        ua.PerfisQt_EmUso = m.TotalPerfis,
        ua.EspacosQt_EmUso = m.TotalEspacos
    " . $whereClauseExterno;

    updateComBindParam($sql, $types, ...$params); 
}

function tratarTextoHtml($html) {
    // 1. Converte <div> e <br> em quebras de linha reais
    // Adicionamos \n para fechar divs e para cada <br>
    $search = array('<div>', '</div>', '<br>', '<br/>', '<br />', '<p>', '</p>');
    $replace = array('', "\n", "\n", "\n", "\n", '', "\n");
    $texto = str_ireplace($search, $replace, $html);

    // 2. Remove qualquer outra tag HTML que tenha sobrado (ex: <span>, <strong>)
    $texto = strip_tags($texto);

    // 3. Converte entidades HTML (como &nbsp;, &amp;, &quot;) para caracteres normais
    $texto = html_entity_decode($texto, ENT_QUOTES, 'UTF-8');

    // 4. Remove espaços em branco desnecessários no início e fim
    $texto = trim($texto);

    // 5. Opcional: Limpa quebras de linha triplas (deixa no máximo duas)
    $texto = preg_replace("/\n\s*\n\s*\n/", "\n\n", $texto);

    return $texto;
}


/* =========================================================
   EQUIPES — camada organizacional que orquestra acessos em
   perfis_usuario (fonte de verdade). Toda escrita aqui usa
   bind param. Estado "total/limitado" do membro é derivado:
   se ele tem TODOS as marcas da equipe em perfis_usuario,
   exibe "Acesso total"; senão "Acesso limitado (N/M)".
   A flag equipe_usuarios.TipoAcesso só decide se ele recebe
   automaticamente as marcas adicionadas à equipe no futuro.

   PermissaoID em perfis_usuario: 1=Editor, 2=Editor Admin,
   3=Visualizador, 4=Cliente. Membros entram como 1 (Editor)
   e Administradores como 2 (Editor Admin).
   ========================================================= */

// ---- Permissões ----
function equipeEhDono($equipeID, $userID){
    $r = pesquisarUmComBindParam(
        "SELECT 1 ok FROM equipe WHERE ID = ? AND UsuarioID = ? AND Deletado = 0",
        'ii', $equipeID, $userID);
    return !empty($r);
}

// Usuário pode GERENCIAR a equipe = é o dono OU está marcado como
// "Administrador da equipe" (equipe_usuarios.IsAdmin = 1). Admins
// podem adicionar/remover membros e perfis e renomear a equipe —
// mas só o dono pode excluir a equipe.
function equipePodeGerenciar($equipeID, $userID){
    if(equipeEhDono($equipeID, $userID)) return true;
    $r = pesquisarUmComBindParam("
        SELECT 1 ok FROM equipe_usuarios eu
        INNER JOIN equipe e ON e.ID = eu.EquipeID AND e.Deletado = 0
        WHERE eu.EquipeID = ? AND eu.UsuarioID = ? AND eu.IsAdmin = 1
    ", 'ii', $equipeID, $userID);
    return !empty($r);
}

// Usuário pode adicionar esta marca à equipe se for o dono da marca
// ou possuir PermissaoID = 2 (Editor Admin) em perfis_usuario — mesma
// regra de "master" usada em PageAcessoPessoas.
function equipePodeUsarPerfil($perfisID, $userID){
    $r = pesquisarUmComBindParam("
        SELECT 1 ok FROM perfis p
        LEFT JOIN perfis_usuario pu ON pu.PerfisID = p.ID AND pu.UsuarioID = ?
        WHERE p.ID = ? AND (p.UsuarioID = ? OR pu.PermissaoID = 2)
    ", 'iii', $userID, $perfisID, $userID);
    return !empty($r);
}

// ---- Leitura ----
function equipeListarDoUsuario($userID){
    $equipes = pesquisarComBindParam("
        SELECT e.ID, e.Nome,
               (SELECT COUNT(*) FROM equipe_perfis   ep WHERE ep.EquipeID = e.ID) AS PerfisCount,
               (SELECT COUNT(*) FROM equipe_usuarios eu WHERE eu.EquipeID = e.ID) AS MembrosCount,
               (e.UsuarioID = ?) AS EhDono
        FROM equipe e
        LEFT JOIN equipe_usuarios eum ON eum.EquipeID = e.ID AND eum.UsuarioID = ?
        WHERE e.Deletado = 0
          AND (e.UsuarioID = ? OR eum.UsuarioID IS NOT NULL)
        GROUP BY e.ID
        ORDER BY e.Nome ASC
    ", 'iii', $userID, $userID, $userID) ?: [];

    // Enriquece cada equipe (poucas equipes) com thumbs e um resumo "agora".
    foreach($equipes as &$e){
        $eid = (int)$e['ID'];

        $perfis = pesquisarComBindParam("
            SELECT p.Nome, p.InstagramFoto, p.FacebookID, p.TiktokFoto,
                p.ImagemFonte, ".sqlLogoPerfilSubquery()." AS LogoPerfil
            FROM equipe_perfis ep INNER JOIN perfis p ON p.ID = ep.PerfisID
            WHERE ep.EquipeID = ? ORDER BY p.Nome ASC LIMIT 6
        ", 'i', $eid) ?: [];
        $e['perfis'] = array_map(function($p){ return ['Nome'=>$p['Nome'], 'Logo'=>imagemDoPerfil($p)]; }, $perfis);

        $mem = pesquisarComBindParam("
            SELECT u.Nome, u.ImagemPerfil
            FROM equipe_usuarios eu INNER JOIN usuario u ON u.ID = eu.UsuarioID
            WHERE eu.EquipeID = ? ORDER BY eu.IsAdmin DESC, u.Nome ASC LIMIT 6
        ", 'i', $eid) ?: [];
        $e['membros'] = array_map(function($m){ return ['Nome'=>$m['Nome'], 'ImagemPerfil'=>$m['ImagemPerfil']]; }, $mem);

        // Resumo do card: MESMO helper da Visão geral (equipeDemandaTotais), para
        // o número do card e o número de dentro da equipe nunca divergirem — e
        // para o card também contar TAREFAS, não só conteúdos.
        $perfilIDsEq = pesquisarComBindParam(
            "SELECT PerfisID FROM equipe_perfis WHERE EquipeID = ?", 'i', $eid) ?: [];
        $ids = array_map(function($x){ return (int)$x['PerfisID']; }, $perfilIDsEq);
        $t = equipeDemandaTotais($ids);
        $e['wip']       = $t['wip'];
        $e['hoje']      = $t['hoje'];
        $e['atrasados'] = $t['atrasados'];
        $e['semResp']   = $t['semResp'];
    }
    unset($e);
    return $equipes;
}

function equipeBuscar($equipeID){
    return pesquisarUmComBindParam(
        "SELECT ID, Nome, UsuarioID FROM equipe WHERE ID = ? AND Deletado = 0",
        'i', $equipeID);
}

// Resolve a URL da imagem da marca de rede social, na ordem oficial:
// Instagram > Facebook > TikTok. Quando nenhuma existe, retorna '' (front mostra iniciais).
// - Instagram: profile_picture_url salvo em InstagramFoto.
// - Facebook : endpoint público /{page-id}/picture (estável, sem versão).
// - TikTok   : avatar_url salvo em TiktokFoto (URL assinada que expira; o robô a renova).
// Função GLOBAL da imagem da marca. Recebe o item da marca e devolve o link
// correto, respeitando a configuração da aba Visual:
//   ImagemFonte = 'logo'                    -> usa o logo escolhido (LogoPerfil)
//   ImagemFonte = 'instagram'/'facebook'/'tiktok' -> fixa a foto daquela rede
//   senão (padrão 'rede')                   -> automático: Instagram > Facebook > TikTok
// A origem fixada tem prioridade; se a foto daquela origem não existir, degrada
// para a ordem automática abaixo (nunca fica com imagem quebrada).
// Espelhada no JS por window.imagemDoPerfil (browse/footer.php).
function imagemDoPerfil($p){
    if(!$p) return '';
    $logo  = $p['LogoPerfil'] ?? '';
    $fb    = !empty($p['FacebookID']) ? 'https://graph.facebook.com/'.$p['FacebookID'].'/picture?type=large' : '';
    $fonte = $p['ImagemFonte'] ?? 'rede';
    // Origem fixada explicitamente na aba Visual.
    if($fonte === 'logo'      && !empty($logo))               return $logo;
    if($fonte === 'instagram' && !empty($p['InstagramFoto'])) return $p['InstagramFoto'];
    if($fonte === 'facebook'  && $fb !== '')                  return $fb;
    if($fonte === 'tiktok'    && !empty($p['TiktokFoto']))    return $p['TiktokFoto'];
    // Automático (ImagemFonte='rede' ou origem fixada sem foto disponível).
    if(!empty($p['InstagramFoto'])) return $p['InstagramFoto'];
    if($fb !== '')                  return $fb;
    if(!empty($p['TiktokFoto']))    return $p['TiktokFoto'];
    if(!empty($logo))               return $logo;
    return '';
}

// Mantida por compatibilidade: delega para a função global.
function equipeLogoPerfil($p){
    return imagemDoPerfil($p);
}

// Iniciais da marca: 1ª letra das 2 primeiras palavras do nome ("João Silva" -> "JS");
// com 1 palavra usa as 2 primeiras letras ("Empresa" -> "EM"). Espelha iniciaisPerfil (JS).
function iniciaisPerfil($p){
    $nome = trim((string)($p['Nome'] ?? $p['Cliente'] ?? $p['PerfilNome'] ?? ''));
    if($nome === '') return '?';
    $ps = preg_split('/\s+/', $nome, -1, PREG_SPLIT_NO_EMPTY);
    if(count($ps) >= 2)
        return mb_strtoupper(mb_substr($ps[0],0,1,'UTF-8').mb_substr($ps[1],0,1,'UTF-8'), 'UTF-8');
    return mb_strtoupper(mb_substr($nome, 0, 2, 'UTF-8'), 'UTF-8');
}

// Avatar INTELIGENTE da marca: devolve o elemento já pronto — imagem (rede/logo) quando
// existe, senão as 2 iniciais do nome bem centralizadas (classe .avatar-perfil-letra).
// $opts: ['tag'=>'div'|'a','classe'=>'','href'=>'','attrs'=>'','style'=>''].
function htmlAvatarPerfil($p, $opts = []){
    $tag    = $opts['tag'] ?? 'div';
    $classe = trim('avatar-perfil ' . ($opts['classe'] ?? ''));
    $href   = ($tag === 'a' && !empty($opts['href'])) ? ' href="'.htmlspecialchars($opts['href']).'"' : '';
    $attrs  = !empty($opts['attrs']) ? ' '.$opts['attrs'] : '';
    $style  = $opts['style'] ?? '';
    $url    = imagemDoPerfil($p);
    if($url !== ''){
        return '<'.$tag.' class="'.$classe.'"'.$href.$attrs.' style="background-image:url('.htmlspecialchars($url).');'.$style.'"></'.$tag.'>';
    }
    return '<'.$tag.' class="'.$classe.' avatar-perfil-letra"'.$href.$attrs.' style="'.$style.'">'.htmlspecialchars(iniciaisPerfil($p)).'</'.$tag.'>';
}

// Subquery correlacionada que resolve a URL do logo escolhido como imagem da marca.
// Assume o alias '$alias' para a tabela perfis na query externa. Use em SELECTs:
//   SELECT ..., ".sqlLogoPerfilSubquery()." AS LogoPerfil FROM perfis p ...
function sqlLogoPerfilSubquery($alias = 'p'){
    return "(SELECT CONCAT('https://', s_lp.Dominio, ppu_lp.Arquivo)
               FROM perfis_posts_upload ppu_lp INNER JOIN servers s_lp ON s_lp.ID = ppu_lp.ServerID
               WHERE ppu_lp.ID = {$alias}.LogoPerfilID AND ppu_lp.TipoID = 1 AND ppu_lp.Deletado = 0
               LIMIT 1)";
}

// Subquery correlacionada que devolve a COR PRINCIPAL da marca: a primeira cor da
// paleta primária cadastrada na aba Visual (perfis_visual.CoresPrimarias é uma lista
// separada por vírgula). Vem NULL/'' quando a marca não tem paleta. Use em SELECTs:
//   SELECT ..., ".sqlCorPrincipalSubquery()." AS CorPrincipal FROM perfis p ...
function sqlCorPrincipalSubquery($alias = 'p'){
    return "(SELECT TRIM(SUBSTRING_INDEX(pv_cp.CoresPrimarias, ',', 1))
               FROM perfis_visual pv_cp
               WHERE pv_cp.PerfisID = {$alias}.ID
               LIMIT 1)";
}

function equipePerfis($equipeID){
    return pesquisarComBindParam("
        SELECT p.ID, p.Nome, p.Cidade, p.UF, p.Segmento, p.InstagramFoto, p.FacebookID, p.TiktokFoto,
            p.ImagemFonte, ".sqlLogoPerfilSubquery()." AS LogoPerfil
        FROM equipe_perfis ep
        INNER JOIN perfis p ON p.ID = ep.PerfisID
        WHERE ep.EquipeID = ?
        ORDER BY p.Nome ASC
    ", 'i', $equipeID);
}

// Perfis que o usuário (dono da equipe) pode adicionar — dele ou onde é admin —
// e que ainda não estão na equipe.
function equipePerfisDisponiveis($equipeID, $userID){
    return pesquisarComBindParam("
        SELECT DISTINCT
            p.ID, p.Nome, p.Cidade, p.UF, p.Segmento, p.InstagramFoto, p.FacebookID, p.TiktokFoto,
            p.ImagemFonte, ".sqlLogoPerfilSubquery()." AS LogoPerfil
        FROM perfis p
        LEFT JOIN perfis_usuario pu ON pu.PerfisID = p.ID AND pu.UsuarioID = ?
        WHERE (p.UsuarioID = ? OR pu.PermissaoID = 2)
          AND p.ID NOT IN (SELECT PerfisID FROM equipe_perfis WHERE EquipeID = ?)
        ORDER BY p.Nome ASC
    ", 'iii', $userID, $userID, $equipeID);
}

// Membros da equipe (todos: admins e não-admins). Badge é calculado:
// Total quando liberou todas as marcas, Limitado N/M caso contrário.
// Externo/ValorDemanda só entram no SELECT se a migration de externos rodou —
// nomes de coluna são literais, então não há risco de injeção aqui.
function equipeMembros($equipeID){
    $colsExt = equipeTemExternos()
        ? 'eu.Externo, eu.ValorDemanda,'
        : '0 AS Externo, NULL AS ValorDemanda,';
    return pesquisarComBindParam("
        SELECT
            eu.ID, eu.UsuarioID, eu.TipoAcesso, eu.IsAdmin,
            $colsExt
            u.Nome, u.Email, u.ImagemPerfil,
            (SELECT COUNT(*) FROM equipe_perfis ep WHERE ep.EquipeID = eu.EquipeID) AS TotalPerfis,
            (SELECT COUNT(*)
               FROM equipe_perfis ep
               INNER JOIN perfis_usuario pu ON pu.PerfisID = ep.PerfisID AND pu.UsuarioID = eu.UsuarioID
              WHERE ep.EquipeID = eu.EquipeID) AS PerfisLiberados
        FROM equipe_usuarios eu
        INNER JOIN usuario u ON u.ID = eu.UsuarioID
        WHERE eu.EquipeID = ?
        ORDER BY eu.IsAdmin DESC, u.Nome ASC
    ", 'i', $equipeID);
}

// Lista de marcas da equipe + flag "Liberado" para um usuário específico.
// Aceita email (quando o usuário ainda não foi adicionado) ou usuarioID.
function equipeMembroPerfisDetalhe($equipeID, $usuarioID){
    return pesquisarComBindParam("
        SELECT
            p.ID, p.Nome, p.Cidade, p.UF, p.Segmento, p.InstagramFoto, p.FacebookID, p.TiktokFoto,
            (pu.ID IS NOT NULL) AS Liberado
        FROM equipe_perfis ep
        INNER JOIN perfis p ON p.ID = ep.PerfisID
        LEFT JOIN perfis_usuario pu ON pu.PerfisID = p.ID AND pu.UsuarioID = ?
        WHERE ep.EquipeID = ?
        ORDER BY p.Nome ASC
    ", 'ii', $usuarioID, $equipeID);
}

// ---- Escrita: orquestração em perfis_usuario ----

// Insere acesso em perfis_usuario se ainda não existir (idempotente).
function equipeConcederAcesso($perfisID, $usuarioID, $permissaoID){
    $existe = pesquisarUmComBindParam(
        "SELECT ID FROM perfis_usuario WHERE PerfisID = ? AND UsuarioID = ?",
        'ii', $perfisID, $usuarioID);
    if(!empty($existe['ID'])){
        // Se existe e estamos elevando para Editor Admin (caso de admin),
        // garantimos o nível — mas nunca rebaixamos algo já existente.
        if($permissaoID == 2){
            updateComBindParam(
                "UPDATE perfis_usuario SET PermissaoID = 2 WHERE ID = ?",
                'i', $existe['ID']);
        }
        return $existe['ID'];
    }
    return insertComBindParam("
        INSERT INTO perfis_usuario (PerfisID, UsuarioID, PermissaoID, DataCriado)
        VALUES (?, ?, ?, NOW())
    ", 'iii', $perfisID, $usuarioID, $permissaoID);
}

function equipeRevogarAcesso($perfisID, $usuarioID){
    deleteComBindParam(
        "DELETE FROM perfis_usuario WHERE PerfisID = ? AND UsuarioID = ?",
        'ii', $perfisID, $usuarioID);
}

// PermissaoID que um membro deve receber nas marcas desta equipe (1 ou 2).
// Admin da equipe (IsAdmin = 1) e o dono recebem Editor Admin (2) nas marcas
// liberados — assim podem dar acesso a outras pessoas e gerenciar. Membro
// comum fica como Editor (1).
function equipePermissaoDoMembro($equipeID, $usuarioID){
    $a = pesquisarUmComBindParam(
        "SELECT 1 AS x FROM equipe_usuarios WHERE EquipeID = ? AND UsuarioID = ? AND IsAdmin = 1 LIMIT 1",
        'ii', $equipeID, $usuarioID);
    if(!isset($a['x'])){
        $a = pesquisarUmComBindParam(
            "SELECT 1 AS x FROM equipe WHERE ID = ? AND UsuarioID = ? LIMIT 1",
            'ii', $equipeID, $usuarioID);
    }
    return isset($a['x']) ? 2 : 1;
}

// ---------------------------------------------------------------------------
// O DONO PRECISA DE LINHA EM equipe_usuarios
// ---------------------------------------------------------------------------
// `equipeSalvar` grava a equipe só em `equipe` (UsuarioID = dono) e nunca criou
// linha em `equipe_usuarios`. Só que `equipe_usuarios` é onde vivem TODOS os
// atributos por pessoa — `CapacidadeSemana`, `Externo`, `ValorDemanda` — e é o
// vínculo que o cargo exige (`equipe_usuario_cargo`).
//
// Resultado: o dono aparecia em toda LEITURA (carga, agenda, produção, ranking)
// mas não podia receber cargo, capacidade nem valor por demanda — os três saves
// respondiam "Pessoa não é membro desta equipe". Ele era a única pessoa da
// operação impossível de configurar.
//
// Este helper é idempotente e conserta equipe antiga sob demanda. Não concede
// nada novo: o dono já tem todos os direitos por ser dono; a linha é apenas o
// lugar onde os atributos dele moram.
function equipeGarantirVinculoDono($equipeID){
    $equipeID = (int)$equipeID;
    $eq = equipeBuscar($equipeID);
    $donoID = (int)($eq['UsuarioID'] ?? 0);
    if($donoID <= 0) return 0;
    $ja = pesquisarUmComBindParam(
        "SELECT ID FROM equipe_usuarios WHERE EquipeID = ? AND UsuarioID = ? LIMIT 1",
        'ii', $equipeID, $donoID);
    if(!empty($ja['ID'])) return (int)$ja['ID'];
    // TipoAcesso 0 (total) + IsAdmin 1: é o que o dono já é de fato.
    return (int)insertComBindParam("
        INSERT INTO equipe_usuarios (EquipeID, UsuarioID, TipoAcesso, IsAdmin, DataCriado)
        VALUES (?, ?, 0, 1, NOW())
    ", 'ii', $equipeID, $donoID);
}

// Vínculo do ALVO de uma escrita por pessoa (capacidade, cargo, externo).
// Devolve o `equipe_usuarios.ID`; quando o alvo é o dono e a linha não existe,
// cria na hora (self-heal) em vez de recusar a operação.
function equipeVinculoParaGravar($equipeID, $usuarioID){
    $equipeID = (int)$equipeID; $usuarioID = (int)$usuarioID;
    if($equipeID <= 0 || $usuarioID <= 0) return 0;
    $r = pesquisarUmComBindParam(
        "SELECT ID FROM equipe_usuarios WHERE EquipeID = ? AND UsuarioID = ? LIMIT 1",
        'ii', $equipeID, $usuarioID);
    if(!empty($r['ID'])) return (int)$r['ID'];
    if(equipeEhDono($equipeID, $usuarioID)) return equipeGarantirVinculoDono($equipeID);
    return 0;
}

// Quando uma marca é adicionado à equipe, libera para todos os membros
// que têm acesso TOTAL (TipoAcesso = 0). Membros limitados ficam de fora.
// Admins/dono entram como Editor Admin (2); demais como Editor (1).
function equipeAposAdicionarPerfil($equipeID, $perfisID){
    $membros = pesquisarComBindParam(
        "SELECT UsuarioID FROM equipe_usuarios WHERE EquipeID = ? AND TipoAcesso = 0",
        'i', $equipeID);
    foreach($membros as $m){
        $uid = (int)$m['UsuarioID'];
        equipeConcederAcesso($perfisID, $uid, equipePermissaoDoMembro($equipeID, $uid));
    }
    // Herda a config de responsáveis por status vigente da equipe (intersecção
    // dos demais perfis) para o nova marca, mantendo coerência.
    equipeCopiarResponsaveisParaPerfil($equipeID, $perfisID);
    // E garante a automação dos CARGOS na marca nova (a intersecção acima não
    // cobre o caso de a equipe ter só esta marca, ou de um cargo recém-criado).
    equipeCargoAplicarNoPerfil($equipeID, $perfisID);
}

// Resolve email -> userID. Se não existir, cria (mesmo padrão de
// insertPageAcessoPessoa). Retorna null se email vazio/inválido.
function equipeResolverUsuarioPorEmail($email){
    $email = trim(strtolower($email));
    if($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) return null;
    $u = pesquisarUmComBindParam(
        "SELECT ID FROM usuario WHERE Email = ?", 's', $email);
    if(!empty($u['ID'])) return (int)$u['ID'];
    $nome = strstr($email, '@', true);
    return (int)insertComBindParam(
        "INSERT INTO usuario (Nome, Email, EmailConfirmado) VALUES (?, ?, 1)",
        'ss', $nome, $email);
}

// ===========================================================================
// LEITURA DAS TELAS DE EQUIPE — SÓ DADOS, NUNCA HTML
// ---------------------------------------------------------------------------
// Este bloco é o motor de consulta das telas de equipe. Ele NÃO monta HTML:
// toda marcação (kit .eq-*, grade da agenda, cards do drill-down, tabelas)
// é montada no cliente por `browse/comum/js/equipes.js` (window.Equipes) a
// partir do JSON que as funções `equipeDados*` devolvem — o mesmo padrão do
// resto do sistema (Arquivos, Documentos, Lixeira, Apresentação).
//
// Regra: PHP responde `json_encode(equipeDados*(...))`; o JS decide a pintura.
// Nada aqui pode voltar a concatenar tag — se precisar de um componente novo,
// ele nasce em equipes.js e recebe os campos daqui.
// ===========================================================================

// Série semanal da operação (últimas N semanas, ancorada na semana atual):
// entregas (transições para status Concluido=1) e avanços (transições p/ frente).
function equipeSerieSemanal($perfilIDs, $semanas = 8){
    if(empty($perfilIDs)) return [];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));

    // Buckets de segunda a segunda, o último contendo hoje.
    $hoje    = strtotime(date('Y-m-d'));
    $dowSeg  = (int)date('N', $hoje) - 1;              // 0 = segunda
    $iniAtual= strtotime('-'.$dowSeg.' day', $hoje);
    $iniPrim = strtotime('-'.(($semanas - 1) * 7).' day', $iniAtual);
    $ini     = date('Y-m-d 00:00:00', $iniPrim);
    $fim     = date('Y-m-d 00:00:00', strtotime('+7 day', $iniAtual));

    $buckets = [];
    for($i = 0; $i < $semanas; $i++){
        $ts = strtotime('+'.($i * 7).' day', $iniPrim);
        $buckets[date('Y-m-d', $ts)] = ['label'=>date('d/m', $ts), 'entregas'=>0, 'avancos'=>0, 'atual'=>($ts === $iniAtual)];
    }

    $rows = pesquisarComBindParam("
        SELECT DATE(DATE_SUB(pl.Data, INTERVAL WEEKDAY(pl.Data) DAY)) AS Semana,
               COUNT(DISTINCT CASE WHEN spPara.Concluido = 1 THEN pl.PostID END) AS Entregas,
               COUNT(CASE WHEN spPara.Ordem > spDe.Ordem THEN 1 END) AS Avancos
        FROM perfis_log pl
        INNER JOIN statusPost spDe   ON UPPER(spDe.Titulo)   = UPPER(pl.De)
        INNER JOIN statusPost spPara ON UPPER(spPara.Titulo) = UPPER(pl.Para)
        INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
        WHERE pl.Campo = 'Status' AND pl.PerfisID IN ($phP) AND pl.Data >= ? AND pl.Data < ?
        GROUP BY Semana
    ", $tpP.'ss', ...array_merge($perfilIDs, [$ini, $fim])) ?: [];
    foreach($rows as $r){
        if(isset($buckets[$r['Semana']])){
            $buckets[$r['Semana']]['entregas'] = (int)$r['Entregas'];
            $buckets[$r['Semana']]['avancos']  = (int)$r['Avancos'];
        }
    }
    return array_values($buckets);
}

// ---------------------------------------------------------------------------
// RISCOS CONSOLIDADOS — "Precisa de você agora"
// Antes eram 4 blocos soltos no fim da Visão geral (atrasados / parados / sem
// responsável / aguardando cliente). Um gestor não pensa em 4 listas: pensa em
// UMA fila ordenada por gravidade. Severidade: 3 atrasado · 2 sem responsável
// · 2 aguardando cliente · 1 parado.
// ---------------------------------------------------------------------------
function equipeRiscos($perfilIDs, $limite = 14){
    if(empty($perfilIDs)) return ['lista'=>[], 'total'=>0];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));

    $itens = [];

    // Atrasados (conteúdo) — vencimento já passou.
    $rows = pesquisarComBindParam("
        SELECT pp.ID, pp.PerfisID, pp.Numero, pp.Titulo, pp.DataAgendado,
               p.Nome AS Contexto, sp.Titulo AS StatusNome, sp.CorTexto AS StatusCor
        FROM perfis_posts pp
        INNER JOIN perfis p ON p.ID = pp.PerfisID
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
          AND pp.DataAgendado IS NOT NULL AND pp.DataAgendado < NOW()
        ORDER BY pp.DataAgendado ASC
        LIMIT 40
    ", $tpP, ...$perfilIDs) ?: [];
    foreach($rows as $a){
        $dias = max(0, (int)floor((time() - strtotime($a['DataAgendado'])) / 86400));
        $itens[] = [
            'sev'=>3, 'filtro'=>'atrasado', 'kind'=>'conteudo', 'id'=>(int)$a['ID'], 'perfil'=>(int)$a['PerfisID'],
            'titulo'=>(trim((string)$a['Titulo']) !== '' ? $a['Titulo'] : '#'.(int)$a['Numero']),
            'contexto'=>$a['Contexto'].' · #'.(int)$a['Numero'],
            'motivo'=>'Atrasado '.$dias.'d', 'ordem'=>$dias,
            'status'=>$a['StatusNome'], 'cor'=>$a['StatusCor'],
        ];
    }

    // Atrasadas (tarefa) — prazo já passou.
    $rows = pesquisarComBindParam("
        SELECT t.ID, t.Titulo, t.DataFim, t.EspacoID, t.ListaID,
               e.Titulo AS Contexto,
               ".sqlTarefaStatusNome('t', 'els', 'el')." AS StatusNome,
               ".sqlTarefaStatusCor('t', 'els')." AS StatusCor
        FROM tarefa t
        INNER JOIN espaco e ON e.ID = t.EspacoID
        LEFT JOIN espaco_lista el ON el.ID = t.ListaID
        LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
        WHERE t.Deletado = 0 AND t.Arquivado = 0
          AND ".sqlTarefaAberta('t', 'els')."
          AND t.DataFim IS NOT NULL AND t.DataFim < NOW()
          AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
        ORDER BY t.DataFim ASC
        LIMIT 40
    ", $tpP, ...$perfilIDs) ?: [];
    foreach($rows as $a){
        $dias = max(0, (int)floor((time() - strtotime($a['DataFim'])) / 86400));
        $itens[] = [
            'sev'=>3, 'filtro'=>'atrasado', 'kind'=>'tarefa', 'id'=>(int)$a['ID'],
            'espaco'=>(int)$a['EspacoID'], 'lista'=>(int)$a['ListaID'],
            'titulo'=>(trim((string)$a['Titulo']) !== '' ? $a['Titulo'] : 'Tarefa #'.(int)$a['ID']),
            'contexto'=>$a['Contexto'].' · tarefa',
            'motivo'=>'Atrasada '.$dias.'d', 'ordem'=>$dias,
            'status'=>$a['StatusNome'], 'cor'=>$a['StatusCor'],
        ];
    }

    // Sem responsável (conteúdo em andamento e sem dono).
    $orfaos = equipeSemResponsavel($perfilIDs);
    foreach($orfaos['lista'] as $a){
        $itens[] = [
            'sev'=>2, 'filtro'=>'semresp', 'kind'=>'conteudo', 'id'=>(int)$a['ID'], 'perfil'=>(int)$a['PerfisID'],
            'titulo'=>(trim((string)$a['Titulo']) !== '' ? $a['Titulo'] : '#'.(int)$a['Numero']),
            'contexto'=>$a['PerfilNome'].' · #'.(int)$a['Numero'],
            'motivo'=>'Sem responsável', 'ordem'=>0,
            'status'=>$a['StatusNome'], 'cor'=>$a['StatusCorTexto'],
        ];
    }

    // Sem responsável (TAREFA aberta e sem dono). Faltava: os KPIs do topo já
    // contavam tarefa em "Sem responsável" (equipeDemandaTotais soma as duas
    // naturezas), então clicar no tile filtrava uma fila que só tinha conteúdo
    // e o número nunca fechava com a lista.
    $rows = pesquisarComBindParam("
        SELECT t.ID, t.Titulo, t.EspacoID, t.ListaID,
               e.Titulo AS Contexto,
               ".sqlTarefaStatusNome('t', 'els', 'el')." AS StatusNome,
               ".sqlTarefaStatusCor('t', 'els')." AS StatusCor
        FROM tarefa t
        INNER JOIN espaco e ON e.ID = t.EspacoID
        LEFT JOIN espaco_lista el ON el.ID = t.ListaID
        LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
        WHERE t.Deletado = 0 AND t.Arquivado = 0
          AND ".sqlTarefaAberta('t', 'els')."
          AND NOT EXISTS (SELECT 1 FROM tarefa_usuario x WHERE x.TarefaID = t.ID)
          AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
        ORDER BY t.DataAtualizado ASC
        LIMIT 12
    ", $tpP, ...$perfilIDs) ?: [];
    foreach($rows as $a){
        $itens[] = [
            'sev'=>2, 'filtro'=>'semresp', 'kind'=>'tarefa', 'id'=>(int)$a['ID'],
            'espaco'=>(int)$a['EspacoID'], 'lista'=>(int)$a['ListaID'],
            'titulo'=>(trim((string)$a['Titulo']) !== '' ? $a['Titulo'] : 'Tarefa #'.(int)$a['ID']),
            'contexto'=>$a['Contexto'].' · tarefa',
            'motivo'=>'Sem responsável', 'ordem'=>0,
            'status'=>$a['StatusNome'], 'cor'=>$a['StatusCor'],
        ];
    }

    // Parados: em andamento, com dono, sem toque há mais de 3 dias.
    $rows = pesquisarComBindParam("
        SELECT pp.ID, pp.PerfisID, pp.Numero, pp.Titulo, pp.DataAtualizado,
               p.Nome AS Contexto, sp.Titulo AS StatusNome, sp.CorTexto AS StatusCor
        FROM perfis_posts pp
        INNER JOIN perfis p ON p.ID = pp.PerfisID
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
          AND pp.DataAtualizado < DATE_SUB(NOW(), INTERVAL 3 DAY)
        ORDER BY pp.DataAtualizado ASC
        LIMIT 20
    ", $tpP, ...$perfilIDs) ?: [];
    foreach($rows as $a){
        $dias = max(0, (int)floor((time() - strtotime($a['DataAtualizado'])) / 86400));
        $itens[] = [
            'sev'=>1, 'filtro'=>'parado', 'kind'=>'conteudo', 'id'=>(int)$a['ID'], 'perfil'=>(int)$a['PerfisID'],
            'titulo'=>(trim((string)$a['Titulo']) !== '' ? $a['Titulo'] : '#'.(int)$a['Numero']),
            'contexto'=>$a['Contexto'].' · #'.(int)$a['Numero'],
            'motivo'=>'Parado '.$dias.'d', 'ordem'=>$dias,
            'status'=>$a['StatusNome'], 'cor'=>$a['StatusCor'],
        ];
    }

    // Paradas (TAREFA aberta sem toque há +3 dias) — mesma correção de
    // consistência com o KPI "Parados +3 dias".
    $rows = pesquisarComBindParam("
        SELECT t.ID, t.Titulo, t.EspacoID, t.ListaID, t.DataAtualizado,
               e.Titulo AS Contexto,
               ".sqlTarefaStatusNome('t', 'els', 'el')." AS StatusNome,
               ".sqlTarefaStatusCor('t', 'els')." AS StatusCor
        FROM tarefa t
        INNER JOIN espaco e ON e.ID = t.EspacoID
        LEFT JOIN espaco_lista el ON el.ID = t.ListaID
        LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
        WHERE t.Deletado = 0 AND t.Arquivado = 0
          AND ".sqlTarefaAberta('t', 'els')."
          AND t.DataAtualizado < DATE_SUB(NOW(), INTERVAL 3 DAY)
          AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
        ORDER BY t.DataAtualizado ASC
        LIMIT 12
    ", $tpP, ...$perfilIDs) ?: [];
    foreach($rows as $a){
        $dias = max(0, (int)floor((time() - strtotime((string)$a['DataAtualizado'])) / 86400));
        $itens[] = [
            'sev'=>1, 'filtro'=>'parado', 'kind'=>'tarefa', 'id'=>(int)$a['ID'],
            'espaco'=>(int)$a['EspacoID'], 'lista'=>(int)$a['ListaID'],
            'titulo'=>(trim((string)$a['Titulo']) !== '' ? $a['Titulo'] : 'Tarefa #'.(int)$a['ID']),
            'contexto'=>$a['Contexto'].' · tarefa',
            'motivo'=>'Parada '.$dias.'d', 'ordem'=>$dias,
            'status'=>$a['StatusNome'], 'cor'=>$a['StatusCor'],
        ];
    }

    // Gravidade desc, depois "quanto tempo assim" desc.
    usort($itens, function($a, $b){
        if($b['sev'] !== $a['sev']) return $b['sev'] - $a['sev'];
        return $b['ordem'] - $a['ordem'];
    });
    return ['lista'=>array_slice($itens, 0, $limite), 'total'=>count($itens)];
}

// Pseudo-pessoa "Sem responsável" (chave 0). Itens órfãos são risco: eles
// precisam aparecer NA agenda, e não só na fila de riscos da Visão geral.
function equipeAgendaPessoaOrfa(){
    return ['ID'=>0, 'Nome'=>'Sem responsável', 'ImagemPerfil'=>'', 'orfa'=>true];
}

// Heat 0..4 pela razão itens do dia / capacidade diária. Mesma escala usada na
// grade e na legenda, para os dois nunca discordarem.
function equipeHeatNivel($qtd, $capDia){
    if($qtd <= 0) return 0;
    $razao = $qtd / max(1, (int)$capDia);
    if($razao > 1.5) return 4;
    if($razao > 1.0) return 3;
    if($razao > 0.6) return 2;
    return 1;
}

// ===========================================================================
// FECHAMENTO DO MÊS — "como a equipe foi neste mês?"
// ---------------------------------------------------------------------------
// A Visão geral era só snapshot de AGORA: não havia como responder "como foi
// junho?" sem abrir a aba Pessoas e somar cabeça. Este helper fecha o mês
// contando as DUAS naturezas (conteúdo e tarefa), o que os outros números de
// produção da tela ainda não faziam — o log de tarefa vive em perfis_log com
// TarefaID e PerfisID vazio, então o escopo da marca vem de tarefa_perfis.
//
// Devolve entregas (total/por natureza), % no prazo, criados, retrabalho e o
// mesmo total do mês anterior (para o delta).
// ===========================================================================
function equipeFechamentoMes($equipeID, $perfilIDs, $ano, $mes){
    $z = ['entregas'=>0, 'conteudos'=>0, 'tarefas'=>0, 'noPrazo'=>0, 'comPrazo'=>0,
          'pctPrazo'=>0, 'criados'=>0, 'retrabalho'=>0, 'anterior'=>0, 'delta'=>0];
    if(empty($perfilIDs)) return $z;

    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));

    $janela = function($ano, $mes){
        $ini = sprintf('%04d-%02d-01 00:00:00', $ano, $mes);
        $pm = $mes + 1; $pa = $ano; if($pm > 12){ $pm = 1; $pa++; }
        return [$ini, sprintf('%04d-%02d-01 00:00:00', $pa, $pm)];
    };
    list($ini, $fim) = $janela($ano, $mes);
    $aAnt = $ano; $mAnt = $mes - 1; if($mAnt < 1){ $mAnt = 12; $aAnt--; }
    list($iniAnt, $fimAnt) = $janela($aAnt, $mAnt);

    // Entregas de CONTEÚDO no mês + aderência ao prazo agendado.
    $entregasConteudo = function($ini, $fim) use ($phP, $tpP, $perfilIDs){
        $r = pesquisarUmComBindParam("
            SELECT COUNT(DISTINCT pl.PostID) AS N,
                   COUNT(DISTINCT CASE WHEN pp.DataAgendado IS NOT NULL THEN pl.PostID END) AS ComPrazo,
                   COUNT(DISTINCT CASE WHEN pp.DataAgendado IS NOT NULL AND pl.Data <= pp.DataAgendado THEN pl.PostID END) AS NoPrazo
            FROM perfis_log pl
            INNER JOIN statusPost spc ON UPPER(spc.Titulo) = UPPER(pl.Para) AND spc.Concluido = 1
            INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
            WHERE pl.Campo = 'Status' AND pl.PerfisID IN ($phP) AND pl.Data >= ? AND pl.Data < ?
        ", $tpP.'ss', ...array_merge($perfilIDs, [$ini, $fim]));
        return ['n'=>(int)($r['N'] ?? 0), 'comPrazo'=>(int)($r['ComPrazo'] ?? 0), 'noPrazo'=>(int)($r['NoPrazo'] ?? 0)];
    };
    // Entregas de TAREFA no mês (status de destino com TipoID = 2).
    $entregasTarefa = function($ini, $fim) use ($phP, $tpP, $perfilIDs){
        $r = pesquisarUmComBindParam("
            SELECT COUNT(DISTINCT pl.TarefaID) AS N,
                   COUNT(DISTINCT CASE WHEN t.DataFim IS NOT NULL THEN pl.TarefaID END) AS ComPrazo,
                   COUNT(DISTINCT CASE WHEN t.DataFim IS NOT NULL AND pl.Data <= t.DataFim THEN pl.TarefaID END) AS NoPrazo
            FROM perfis_log pl
            INNER JOIN tarefa t ON t.ID = pl.TarefaID AND t.Deletado = 0
            INNER JOIN espaco_lista el ON el.ID = t.ListaID
            LEFT JOIN espaco_lista_status els ON els.ListaID = t.ListaID
                                             AND UPPER(els.Titulo) = UPPER(pl.Para)
            WHERE pl.Campo = 'Status' AND pl.TarefaID > 0 AND pl.Data >= ? AND pl.Data < ?
              AND ".sqlTarefaLogConcluida('els', 'el', 'pl')."
              AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
        ", 'ss'.$tpP, ...array_merge([$ini, $fim], $perfilIDs));
        return ['n'=>(int)($r['N'] ?? 0), 'comPrazo'=>(int)($r['ComPrazo'] ?? 0), 'noPrazo'=>(int)($r['NoPrazo'] ?? 0)];
    };

    $ec = $entregasConteudo($ini, $fim);
    $et = $entregasTarefa($ini, $fim);
    $z['conteudos'] = $ec['n'];
    $z['tarefas']   = $et['n'];
    $z['entregas']  = $ec['n'] + $et['n'];
    $z['comPrazo']  = $ec['comPrazo'] + $et['comPrazo'];
    $z['noPrazo']   = $ec['noPrazo'] + $et['noPrazo'];
    $z['pctPrazo']  = $z['comPrazo'] > 0 ? (int)round(($z['noPrazo'] / $z['comPrazo']) * 100) : 0;

    $ea = $entregasConteudo($iniAnt, $fimAnt);
    $eta = $entregasTarefa($iniAnt, $fimAnt);
    $z['anterior'] = $ea['n'] + $eta['n'];
    $z['delta']    = $z['entregas'] - $z['anterior'];

    // Entrou no mês (criados) — o outro lado da conta: entregar 40 com 60
    // entrando é fila crescendo, e isso não aparece em "entregas".
    $c = pesquisarUmComBindParam("
        SELECT COUNT(*) AS N FROM perfis_posts
        WHERE Deletado = 0 AND PerfisID IN ($phP) AND DataCriado >= ? AND DataCriado < ?
    ", $tpP.'ss', ...array_merge($perfilIDs, [$ini, $fim]));
    $t = pesquisarUmComBindParam("
        SELECT COUNT(*) AS N FROM tarefa t
        WHERE t.Deletado = 0 AND t.DataCriado >= ? AND t.DataCriado < ?
          AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
    ", 'ss'.$tpP, ...array_merge([$ini, $fim], $perfilIDs));
    $z['criados'] = (int)($c['N'] ?? 0) + (int)($t['N'] ?? 0);

    return $z;
}

// ===========================================================================
// FASE 2 — MOTOR DE FLUXO (etapas concluídas / retrabalho / tempo por etapa)
// ---------------------------------------------------------------------------
// Reconstrói as transições de status do log (perfis_log, Campo='Status') dos
// conteúdos das marcas da equipe NO PERÍODO e classifica, via Ordem do status:
//   - throughput por etapa = transições que saíram dela PARA FRENTE no período
//   - retrabalho por etapa  = transições que voltaram PARA ela (Ordem menor)
//   - tempo na etapa        = entrada→saída (ambos no período)
//   - "concluído por pessoa"= saiu da etapa do responsável p/ frente E o conteúdo
//     está ATUALMENTE além dela (não regrediu) -> credita o(s) responsável(is).
// Tudo bind param; títulos mapeados case-insensitive (statusPost é global).
// ===========================================================================

// WIP atual por usuário (conteúdos não concluídos atribuídos) nas marcas dados.
function equipeWipPorUsuario($perfilIDs){
    if(empty($perfilIDs)) return [];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));
    $rows = pesquisarComBindParam("
        SELECT ppu.UserID AS UID,
          COUNT(DISTINCT pp.ID) AS WIP,
          COUNT(DISTINCT CASE WHEN pp.DataAgendado IS NOT NULL AND pp.DataAgendado < NOW() THEN pp.ID END) AS Atrasados
        FROM perfis_posts pp
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        INNER JOIN perfis_posts_usuarios ppu ON ppu.PostID = pp.ID
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
        GROUP BY ppu.UserID
    ", $tp, ...$perfilIDs) ?: [];
    $map = [];
    foreach($rows as $r){ $map[(int)$r['UID']] = ['WIP'=>(int)$r['WIP'], 'Atrasados'=>(int)$r['Atrasados']]; }
    return $map;
}

// ===========================================================================
// DEMANDA DA EQUIPE — conteúdos (perfis_posts) + TAREFAS (tarefa)
// ---------------------------------------------------------------------------
// Antes só conteúdos entravam na conta de carga, então metade do trabalho de
// quem opera por tarefa era invisível para o gestor. Aqui as duas fontes são
// somadas em um vocabulário único ("itens"):
//
//   conteúdo -> data = perfis_posts.DataAgendado  · aberto = statusPost.Concluido = 0
//              responsável = perfis_posts_usuarios.UserID
//   tarefa   -> data = tarefa.DataFim (prazo)     · aberto = espaco_lista_status.TipoID <> 2
//              responsável = tarefa_usuario.UsuarioID
//              escopo da marca = tarefa_perfis (canônico; tarefa.PerfisID é legado
//              e NÃO é preenchido pelos inserts atuais de save-page.php)
//
// Regras do projeto respeitadas: tudo bind param, marcas viram placeholders
// ?,?,? e nenhuma condição alternativa usa OR no WHERE (viraria full scan) —
// as duas fontes são queries separadas, unidas em PHP.
// ===========================================================================

// Membros da equipe + o dono (equipe antiga pode ter o dono sem linha em
// equipe_usuarios — ver equipeGarantirVinculoDono).
// Devolve [uid => ['ID','Nome','ImagemPerfil','IsAdmin','ehDono','ehMembro','podeConfig']].
//
// 'ehMembro'   = tem linha em equipe_usuarios AGORA (estado do banco, honesto).
// 'podeConfig' = dá para gravar atributos por pessoa (capacidade, cargo, valor).
//                É 'ehMembro OU ehDono': para o dono, o save cria a linha na hora
//                (equipeVinculoParaGravar), então a tela não tem por que travar
//                o campo. Os renders usam SEMPRE 'podeConfig', nunca 'ehMembro'.
function equipePessoasDaEquipe($equipeID){
    $pessoas = [];
    foreach(equipeMembros($equipeID) as $m){
        $uid = (int)$m['UsuarioID'];
        $pessoas[$uid] = ['ID'=>$uid, 'Nome'=>$m['Nome'], 'ImagemPerfil'=>$m['ImagemPerfil'],
                          'IsAdmin'=>(int)$m['IsAdmin'], 'ehDono'=>false,
                          'ehMembro'=>true, 'podeConfig'=>true];
    }
    $eq = equipeBuscar($equipeID);
    $donoID = (int)($eq['UsuarioID'] ?? 0);
    if($donoID > 0){
        if(isset($pessoas[$donoID])){
            $pessoas[$donoID]['ehDono'] = true;
        } else {
            $du = pesquisarUmComBindParam("SELECT ID, Nome, ImagemPerfil FROM usuario WHERE ID = ?", 'i', $donoID);
            if(!empty($du)) $pessoas[$donoID] = ['ID'=>$donoID, 'Nome'=>$du['Nome'], 'ImagemPerfil'=>$du['ImagemPerfil'],
                                                 'IsAdmin'=>1, 'ehDono'=>true,
                                                 'ehMembro'=>false, 'podeConfig'=>true];
        }
    }
    return $pessoas;
}

// Gate de estrutura do banco: existe a tabela (ou a coluna) que a feature
// precisa? As features de equipe entram por migration MANUAL, e a tela inteira
// não pode quebrar enquanto a migration não roda — cada feature degrada sozinha.
// Resolvido uma vez por request/estrutura (cache estático).
function equipeTemEstrutura($tabela, $coluna = ''){
    static $cache = [];
    $chave = $tabela.'|'.$coluna;
    if(isset($cache[$chave])) return $cache[$chave];
    if($coluna === ''){
        $r = pesquisarUmComBindParam("
            SELECT COUNT(*) AS N FROM INFORMATION_SCHEMA.TABLES
            WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
        ", 's', $tabela);
    } else {
        $r = pesquisarUmComBindParam("
            SELECT COUNT(*) AS N FROM INFORMATION_SCHEMA.COLUMNS
            WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
        ", 'ss', $tabela, $coluna);
    }
    $cache[$chave] = ((int)($r['N'] ?? 0) > 0);
    return $cache[$chave];
}

// A coluna CapacidadeSemana vem de migration manual
// (BIN/migrations/2026-07-29_equipe_capacidade.sql). Sem ela a capacidade
// degrada para "só a sugerida" em vez de estourar erro de SQL.
function equipeTemColunaCapacidade(){
    return equipeTemEstrutura('equipe_usuarios', 'CapacidadeSemana');
}

// Cargos: BIN/migrations/2026-07-30_equipe_cargos_externos.sql
function equipeTemCargos(){
    return equipeTemEstrutura('equipe_cargo')
        && equipeTemEstrutura('equipe_cargo_status')
        && equipeTemEstrutura('equipe_usuario_cargo');
}
// Membros externos (pagamento por demanda): mesma migration.
function equipeTemExternos(){
    return equipeTemEstrutura('equipe_usuarios', 'Externo');
}
// Marcação "não pagar esta execução": mesma migration.
function equipeTemExecIgnorada(){
    return equipeTemEstrutura('equipe_execucao_ignorada');
}

// Capacidade (itens/semana) de referência por pessoa.
// Configurada em equipe_usuarios.CapacidadeSemana; quem não configurou herda a
// SUGERIDA = mediana de itens/semana efetivamente entregues pela própria equipe
// nos últimos 60 dias. Nunca fica sem referência e nunca crava número inventado.
// Devolve ['cap'=>[uid=>int], 'sugerida'=>int, 'configurado'=>[uid=>bool]].
function equipeCapacidade($equipeID, $perfilIDs, $uIDs){
    $sugerida = 0;
    if(!empty($perfilIDs) && !empty($uIDs)){
        $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
        $tpP = str_repeat('i', count($perfilIDs));
        $phU = implode(',', array_fill(0, count($uIDs), '?'));
        $tpU = str_repeat('i', count($uIDs));
        // Entregas por pessoa nos últimos 60 dias (mesma definição de "entrega"
        // do throughput: transição de status para um status Concluido = 1).
        $rows = pesquisarComBindParam("
            SELECT pl.UserID AS UID, COUNT(DISTINCT pl.PostID) AS N
            FROM perfis_log pl
            INNER JOIN statusPost spc ON UPPER(spc.Titulo) = UPPER(pl.Para) AND spc.Concluido = 1
            INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
            WHERE pl.Campo = 'Status'
              AND pl.PerfisID IN ($phP)
              AND pl.UserID IN ($phU)
              AND pl.Data >= DATE_SUB(NOW(), INTERVAL 60 DAY)
            GROUP BY pl.UserID
        ", $tpP.$tpU, ...array_merge($perfilIDs, $uIDs)) ?: [];
        $taxas = [];
        foreach($rows as $r){
            $porSemana = (int)round(((int)$r['N']) / (60 / 7));
            if($porSemana > 0) $taxas[] = $porSemana;
        }
        if(!empty($taxas)){
            sort($taxas);
            $n = count($taxas);
            $sugerida = ($n % 2 === 1)
                ? $taxas[intdiv($n, 2)]
                : (int)round(($taxas[$n/2 - 1] + $taxas[$n/2]) / 2);
        }
    }
    if($sugerida < 1) $sugerida = 5; // equipe sem histórico: piso conservador

    $cap = []; $configurado = [];
    if(equipeTemColunaCapacidade()){
        $cfg = pesquisarComBindParam("
            SELECT UsuarioID, CapacidadeSemana
            FROM equipe_usuarios
            WHERE EquipeID = ? AND CapacidadeSemana IS NOT NULL AND CapacidadeSemana > 0
        ", 'i', $equipeID) ?: [];
        foreach($cfg as $c){
            $cap[(int)$c['UsuarioID']] = (int)$c['CapacidadeSemana'];
            $configurado[(int)$c['UsuarioID']] = true;
        }
    }
    foreach($uIDs as $u){
        $u = (int)$u;
        if(!isset($cap[$u])){ $cap[$u] = $sugerida; $configurado[$u] = false; }
    }
    return ['cap'=>$cap, 'sugerida'=>$sugerida, 'configurado'=>$configurado];
}

// Utilização em % (itens da semana / capacidade) + faixa.
// Faixas (pesquisa de capacidade criativa: 70-80% é o sustentável):
//   'folga' <= 70 · 'ok' 70-100 · 'cheio' 100-125 · 'estouro' > 125
function equipeFaixaUtilizacao($itensSemana, $capacidade){
    $cap = max(1, (int)$capacidade);
    $pct = (int)round(((int)$itensSemana / $cap) * 100);
    if($pct > 125) $faixa = 'estouro';
    else if($pct > 100) $faixa = 'cheio';
    else if($pct >= 70) $faixa = 'ok';
    else $faixa = 'folga';
    return ['pct'=>$pct, 'faixa'=>$faixa];
}

// Carga ATUAL por pessoa somando conteúdos + tarefas abertas nas marcas da equipe.
// [uid => wip, hoje, semana, atrasados, parados, conteudos, tarefas, ultimo]
function equipeCargaPorPessoa($perfilIDs, $uIDs){
    if(empty($perfilIDs) || empty($uIDs)) return [];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));
    $phU = implode(',', array_fill(0, count($uIDs), '?'));
    $tpU = str_repeat('i', count($uIDs));
    $args = array_merge($perfilIDs, $uIDs);

    $base = ['wip'=>0, 'hoje'=>0, 'semana'=>0, 'atrasados'=>0, 'parados'=>0, 'conteudos'=>0, 'tarefas'=>0, 'ultimo'=>null];
    $map = [];
    foreach($uIDs as $u) $map[(int)$u] = $base;

    // --- Conteúdos em andamento atribuídos ---
    $rc = pesquisarComBindParam("
        SELECT ppu.UserID AS UID,
          COUNT(DISTINCT pp.ID) AS WIP,
          COUNT(DISTINCT CASE WHEN DATE(pp.DataAgendado) = CURDATE() THEN pp.ID END) AS Hoje,
          COUNT(DISTINCT CASE WHEN pp.DataAgendado >= CURDATE() AND pp.DataAgendado < DATE_ADD(CURDATE(), INTERVAL 7 DAY) THEN pp.ID END) AS Semana,
          COUNT(DISTINCT CASE WHEN pp.DataAgendado IS NOT NULL AND pp.DataAgendado < NOW() THEN pp.ID END) AS Atrasados,
          COUNT(DISTINCT CASE WHEN pp.DataAtualizado < DATE_SUB(NOW(), INTERVAL 3 DAY) THEN pp.ID END) AS Parados
        FROM perfis_posts pp
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        INNER JOIN perfis_posts_usuarios ppu ON ppu.PostID = pp.ID
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP) AND ppu.UserID IN ($phU)
        GROUP BY ppu.UserID
    ", $tpP.$tpU, ...$args) ?: [];
    foreach($rc as $r){
        $u = (int)$r['UID']; if(!isset($map[$u])) $map[$u] = $base;
        $map[$u]['conteudos'] = (int)$r['WIP'];
        $map[$u]['wip']      += (int)$r['WIP'];
        $map[$u]['hoje']     += (int)$r['Hoje'];
        $map[$u]['semana']   += (int)$r['Semana'];
        $map[$u]['atrasados']+= (int)$r['Atrasados'];
        $map[$u]['parados']  += (int)$r['Parados'];
    }

    // --- Tarefas abertas atribuídas (marcas da equipe via tarefa_perfis) ---
    $rt = pesquisarComBindParam("
        SELECT tu.UsuarioID AS UID,
          COUNT(DISTINCT t.ID) AS WIP,
          COUNT(DISTINCT CASE WHEN DATE(t.DataFim) = CURDATE() THEN t.ID END) AS Hoje,
          COUNT(DISTINCT CASE WHEN t.DataFim >= CURDATE() AND t.DataFim < DATE_ADD(CURDATE(), INTERVAL 7 DAY) THEN t.ID END) AS Semana,
          COUNT(DISTINCT CASE WHEN t.DataFim IS NOT NULL AND t.DataFim < NOW() THEN t.ID END) AS Atrasados,
          COUNT(DISTINCT CASE WHEN t.DataAtualizado < DATE_SUB(NOW(), INTERVAL 3 DAY) THEN t.ID END) AS Parados
        FROM tarefa t
        INNER JOIN tarefa_usuario tu ON tu.TarefaID = t.ID
        LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
        WHERE t.Deletado = 0 AND t.Arquivado = 0
          AND ".sqlTarefaAberta('t', 'els')."
          AND tu.UsuarioID IN ($phU)
          AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
        GROUP BY tu.UsuarioID
    ", $tpU.$tpP, ...array_merge($uIDs, $perfilIDs)) ?: [];
    foreach($rt as $r){
        $u = (int)$r['UID']; if(!isset($map[$u])) $map[$u] = $base;
        $map[$u]['tarefas']   = (int)$r['WIP'];
        $map[$u]['wip']      += (int)$r['WIP'];
        $map[$u]['hoje']     += (int)$r['Hoje'];
        $map[$u]['semana']   += (int)$r['Semana'];
        $map[$u]['atrasados']+= (int)$r['Atrasados'];
        $map[$u]['parados']  += (int)$r['Parados'];
    }

    // --- Último movimento (para "quem sumiu") ---
    $ru = pesquisarComBindParam("
        SELECT UserID AS UID, MAX(Data) AS Ult
        FROM perfis_log
        WHERE PerfisID IN ($phP) AND UserID IN ($phU)
        GROUP BY UserID
    ", $tpP.$tpU, ...$args) ?: [];
    foreach($ru as $r){
        $u = (int)$r['UID']; if(!isset($map[$u])) $map[$u] = $base;
        $map[$u]['ultimo'] = $r['Ult'];
    }

    return $map;
}

// Totais da equipe para a faixa de KPIs (conteúdos + tarefas, um número cada).
function equipeDemandaTotais($perfilIDs){
    $z = ['wip'=>0, 'hoje'=>0, 'semana'=>0, 'atrasados'=>0, 'parados'=>0, 'semResp'=>0];
    if(empty($perfilIDs)) return $z;
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));

    $c = pesquisarUmComBindParam("
        SELECT
          COUNT(DISTINCT pp.ID) AS WIP,
          COUNT(DISTINCT CASE WHEN DATE(pp.DataAgendado) = CURDATE() THEN pp.ID END) AS Hoje,
          COUNT(DISTINCT CASE WHEN pp.DataAgendado >= CURDATE() AND pp.DataAgendado < DATE_ADD(CURDATE(), INTERVAL 7 DAY) THEN pp.ID END) AS Semana,
          COUNT(DISTINCT CASE WHEN pp.DataAgendado IS NOT NULL AND pp.DataAgendado < NOW() THEN pp.ID END) AS Atrasados,
          COUNT(DISTINCT CASE WHEN pp.DataAtualizado < DATE_SUB(NOW(), INTERVAL 3 DAY) THEN pp.ID END) AS Parados,
          COUNT(DISTINCT CASE WHEN NOT EXISTS (SELECT 1 FROM perfis_posts_usuarios x WHERE x.PostID = pp.ID) THEN pp.ID END) AS SemResp
        FROM perfis_posts pp
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
    ", $tpP, ...$perfilIDs);

    $t = pesquisarUmComBindParam("
        SELECT
          COUNT(DISTINCT t.ID) AS WIP,
          COUNT(DISTINCT CASE WHEN DATE(t.DataFim) = CURDATE() THEN t.ID END) AS Hoje,
          COUNT(DISTINCT CASE WHEN t.DataFim >= CURDATE() AND t.DataFim < DATE_ADD(CURDATE(), INTERVAL 7 DAY) THEN t.ID END) AS Semana,
          COUNT(DISTINCT CASE WHEN t.DataFim IS NOT NULL AND t.DataFim < NOW() THEN t.ID END) AS Atrasados,
          COUNT(DISTINCT CASE WHEN t.DataAtualizado < DATE_SUB(NOW(), INTERVAL 3 DAY) THEN t.ID END) AS Parados,
          COUNT(DISTINCT CASE WHEN NOT EXISTS (SELECT 1 FROM tarefa_usuario x WHERE x.TarefaID = t.ID) THEN t.ID END) AS SemResp
        FROM tarefa t
        LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
        WHERE t.Deletado = 0 AND t.Arquivado = 0
          AND ".sqlTarefaAberta('t', 'els')."
          AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
    ", $tpP, ...$perfilIDs);

    return [
        'wip'       => (int)($c['WIP'] ?? 0)       + (int)($t['WIP'] ?? 0),
        'hoje'      => (int)($c['Hoje'] ?? 0)      + (int)($t['Hoje'] ?? 0),
        'semana'    => (int)($c['Semana'] ?? 0)    + (int)($t['Semana'] ?? 0),
        'atrasados' => (int)($c['Atrasados'] ?? 0) + (int)($t['Atrasados'] ?? 0),
        'parados'   => (int)($c['Parados'] ?? 0)   + (int)($t['Parados'] ?? 0),
        'semResp'   => (int)($c['SemResp'] ?? 0)   + (int)($t['SemResp'] ?? 0),
    ];
}

// Demanda por PESSOA e por DIA na janela [$dataIni, $dataFim) — o coração da aba
// Agenda ("o que cada um tem em cada dia"). Datas em 'YYYY-MM-DD', fim EXCLUSIVO.
// $tipo: 'tudo' | 'conteudos' | 'tarefas'.
//
// $opts:
//   'concluidos' (bool)  também traz o que JÁ FOI ENTREGUE. Sem isto, navegar
//                        para um mês passado devolvia grade vazia — tudo que a
//                        equipe entregou tinha saído do recorte de "aberto" e o
//                        gestor concluía que a tela estava quebrada.
//   'semResp'    (bool)  inclui itens SEM responsável na chave 0 ("Sem
//                        responsável"). Eles são risco e antes desapareciam da
//                        Agenda, porque a query exigia um responsável no JOIN.
//   'maxItens'   (int)   quantas peças guardar por dia para preview (default 3).
//   'comThumb'   (bool)  traz miniatura/extensão (só o modo Dia precisa).
//
// Devolve [uid => ['dias' => [Y-m-d => ['conteudos','tarefas','atrasados',
//         'concluidos','itens'[]]], 'total' => n, 'feitos' => n]].
// Os comparadores usam a coluna crua (>= / <) em vez de DATE(col) = ? para não
// anular o índice de data.
function equipeDemandaPorDia($perfilIDs, $uIDs, $dataIni, $dataFim, $tipo = 'tudo', $opts = []){
    if(empty($perfilIDs)) return [];
    $comConcluidos = !empty($opts['concluidos']);
    $comSemResp    = !empty($opts['semResp']);
    $maxItens      = (int)($opts['maxItens'] ?? 3);
    if($maxItens < 1) $maxItens = 3;
    $comThumb      = !empty($opts['comThumb']);
    if(empty($uIDs) && !$comSemResp) return [];

    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));
    $temU = !empty($uIDs);
    $phU = $temU ? implode(',', array_fill(0, count($uIDs), '?')) : '0';
    $tpU = $temU ? str_repeat('i', count($uIDs)) : '';

    // Recorte de "aberto x tudo". Com concluídos, a condição de status sai do
    // WHERE e o estado passa a ser um dado da linha (coluna Feito).
    $condConteudo = $comConcluidos ? '' : ' AND sp.Concluido = 0';
    $condTarefa   = $comConcluidos ? '' : ' AND '.sqlTarefaAberta('t', 'els');

    $selThumb = $comThumb
        ? ", (SELECT CONCAT('https://', s2.Dominio, ppu2.Arquivo)
               FROM perfis_posts_upload ppu2 INNER JOIN servers s2 ON s2.ID = ppu2.ServerID
               WHERE ppu2.PostID = pp.ID AND ppu2.TipoID = 0 AND ppu2.Deletado = 0 AND ppu2.DuplicadoRefID IS NULL
               ORDER BY ppu2.Versao DESC, ppu2.ID DESC LIMIT 1) AS Thumb,
             (SELECT ppu3.Extensao FROM perfis_posts_upload ppu3
               WHERE ppu3.PostID = pp.ID AND ppu3.TipoID = 0 AND ppu3.Deletado = 0 AND ppu3.DuplicadoRefID IS NULL
               ORDER BY ppu3.Versao DESC, ppu3.ID DESC LIMIT 1) AS ThumbExt"
        : ', NULL AS Thumb, NULL AS ThumbExt';

    $out = [];
    $add = function(&$out, $uid, $dia, $chave, $atrasado, $feito, $item) use ($maxItens){
        if(!isset($out[$uid])) $out[$uid] = ['dias'=>[], 'total'=>0, 'feitos'=>0];
        if(!isset($out[$uid]['dias'][$dia]))
            $out[$uid]['dias'][$dia] = ['conteudos'=>0, 'tarefas'=>0, 'atrasados'=>0, 'concluidos'=>0, 'itens'=>[]];
        $out[$uid]['dias'][$dia][$chave]++;
        if($atrasado) $out[$uid]['dias'][$dia]['atrasados']++;
        if($feito){ $out[$uid]['dias'][$dia]['concluidos']++; $out[$uid]['feitos']++; }
        $out[$uid]['total']++;
        if(count($out[$uid]['dias'][$dia]['itens']) < $maxItens) $out[$uid]['dias'][$dia]['itens'][] = $item;
    };

    // ---------------- Conteúdos ----------------
    if($tipo !== 'tarefas'){
        $sqlConteudo = "
            SELECT %s AS UID, DATE(pp.DataAgendado) AS Dia, pp.DataAgendado AS Quando,
                   pp.ID, pp.Numero, pp.Titulo, pp.PerfisID,
                   pp.AgendarInstagram, pp.AgendarFacebook, pp.AgendarTiktok,
                   pp.AgendarLinkedIn, pp.AgendarYoutube,
                   p.Nome AS Cliente, p.InstagramFoto, p.FacebookID, p.TiktokFoto, p.ImagemFonte,
                   ".sqlLogoPerfilSubquery()." AS LogoPerfil,
                   tp.Titulo AS TipoNome,
                   sp.Titulo AS StatusNome, sp.CorTexto AS StatusCor, sp.Concluido AS Feito
                   $selThumb
            FROM perfis_posts pp
            INNER JOIN statusPost sp ON sp.ID = pp.StatusID$condConteudo
            INNER JOIN perfis p ON p.ID = pp.PerfisID
            %s
            LEFT JOIN tipoPost tp ON tp.ID = pp.TipoID
            WHERE pp.Deletado = 0
              AND pp.PerfisID IN ($phP)
              %s
              AND pp.DataAgendado >= ? AND pp.DataAgendado < ?
            ORDER BY pp.DataAgendado ASC
        ";

        $lotes = [];
        if($temU){
            $lotes[] = [
                sprintf($sqlConteudo, 'ppu.UserID',
                    'INNER JOIN perfis_posts_usuarios ppu ON ppu.PostID = pp.ID',
                    "AND ppu.UserID IN ($phU)"),
                $tpP.$tpU.'ss',
                array_merge($perfilIDs, $uIDs, [$dataIni, $dataFim]),
            ];
        }
        if($comSemResp){
            // Órfãos entram como UID 0 — a grade ganha uma linha "Sem responsável".
            $lotes[] = [
                sprintf($sqlConteudo, '0', '',
                    'AND NOT EXISTS (SELECT 1 FROM perfis_posts_usuarios x WHERE x.PostID = pp.ID)'),
                $tpP.'ss',
                array_merge($perfilIDs, [$dataIni, $dataFim]),
            ];
        }

        foreach($lotes as $L){
            $rows = pesquisarComBindParam($L[0], $L[1], ...$L[2]) ?: [];
            foreach($rows as $r){
                $titulo = trim((string)$r['Titulo']);
                if($titulo === '') $titulo = '#'.(int)$r['Numero'];
                $feito = ((int)$r['Feito'] === 1);
                $atras = (!$feito && strtotime((string)$r['Quando']) < time());
                $add($out, (int)$r['UID'], $r['Dia'], 'conteudos', $atras, $feito, [
                    'kind'=>'conteudo', 'id'=>(int)$r['ID'], 'perfil'=>(int)$r['PerfisID'],
                    'numero'=>(int)$r['Numero'],
                    'titulo'=>$titulo, 'cor'=>($r['StatusCor'] ?: '#888'),
                    'status'=>(string)$r['StatusNome'], 'contexto'=>(string)$r['Cliente'],
                    'hora'=>date('H:i', strtotime((string)$r['Quando'])),
                    'quando'=>(string)$r['Quando'],
                    'tipo'=>(string)($r['TipoNome'] ?? ''),
                    'feito'=>$feito, 'atrasado'=>$atras,
                    'foto'=>imagemDoPerfil($r),
                    'canais'=>equipeCanaisDoPost($r),
                    'thumb'=>(string)($r['Thumb'] ?? ''), 'thumbExt'=>(string)($r['ThumbExt'] ?? ''),
                ]);
            }
        }
    }

    // ---------------- Tarefas ----------------
    if($tipo !== 'conteudos'){
        $sqlTarefa = "
            SELECT %s AS UID, DATE(t.DataFim) AS Dia, t.DataFim AS Quando,
                   t.ID, t.Titulo, t.EspacoID, t.ListaID,
                   e.Titulo AS Espaco, el.Titulo AS Lista,
                   ".sqlTarefaStatusNome('t', 'els', 'el')." AS StatusNome,
                   ".sqlTarefaStatusCor('t', 'els')." AS StatusCor,
                   ".sqlTarefaConcluida('t', 'els')." AS Feito
            FROM tarefa t
            %s
            INNER JOIN espaco e ON e.ID = t.EspacoID
            LEFT JOIN espaco_lista el ON el.ID = t.ListaID
            LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
            WHERE t.Deletado = 0 AND t.Arquivado = 0$condTarefa
              %s
              AND t.DataFim >= ? AND t.DataFim < ?
              AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
            ORDER BY t.DataFim ASC
        ";

        $lotes = [];
        if($temU){
            $lotes[] = [
                sprintf($sqlTarefa, 'tu.UsuarioID',
                    'INNER JOIN tarefa_usuario tu ON tu.TarefaID = t.ID',
                    "AND tu.UsuarioID IN ($phU)"),
                $tpU.'ss'.$tpP,
                array_merge($uIDs, [$dataIni, $dataFim], $perfilIDs),
            ];
        }
        if($comSemResp){
            $lotes[] = [
                sprintf($sqlTarefa, '0', '',
                    'AND NOT EXISTS (SELECT 1 FROM tarefa_usuario x WHERE x.TarefaID = t.ID)'),
                'ss'.$tpP,
                array_merge([$dataIni, $dataFim], $perfilIDs),
            ];
        }

        foreach($lotes as $L){
            $rows = pesquisarComBindParam($L[0], $L[1], ...$L[2]) ?: [];
            foreach($rows as $r){
                $titulo = trim((string)$r['Titulo']);
                if($titulo === '') $titulo = 'Tarefa #'.(int)$r['ID'];
                $feito = ((int)$r['Feito'] === 1);
                $atras = (!$feito && strtotime((string)$r['Quando']) < time());
                $add($out, (int)$r['UID'], $r['Dia'], 'tarefas', $atras, $feito, [
                    'kind'=>'tarefa', 'id'=>(int)$r['ID'],
                    'espaco'=>(int)$r['EspacoID'], 'lista'=>(int)$r['ListaID'],
                    'numero'=>0,
                    'titulo'=>$titulo, 'cor'=>($r['StatusCor'] ?: '#888'),
                    'status'=>(string)$r['StatusNome'], 'contexto'=>(string)$r['Espaco'],
                    'hora'=>date('H:i', strtotime((string)$r['Quando'])),
                    'quando'=>(string)$r['Quando'],
                    'tipo'=>trim((string)($r['Lista'] ?? '')),
                    'feito'=>$feito, 'atrasado'=>$atras,
                    'foto'=>'', 'canais'=>[],
                    'thumb'=>'', 'thumbExt'=>'',
                ]);
            }
        }
    }

    // Dentro de cada dia, ordena por horário — a célula e a lista do dia leem
    // como uma agenda de verdade, não na ordem em que as duas queries voltaram.
    foreach($out as $uid => &$d){
        foreach($d['dias'] as $dia => &$c){
            usort($c['itens'], function($a, $b){ return strcmp((string)$a['quando'], (string)$b['quando']); });
        }
        unset($c);
        ksort($d['dias']);
    }
    unset($d);

    return $out;
}

// Canais que um conteúdo mira, a partir das flags Agendar*. Devolve
// [['k'=>'instagram','n'=>'Instagram'], ...] — o front desenha o ícone por 'k'.
function equipeCanaisDoPost($r){
    $mapa = [
        'AgendarInstagram' => ['instagram', 'Instagram'],
        'AgendarFacebook'  => ['facebook',  'Facebook'],
        'AgendarTiktok'    => ['tiktok',    'TikTok'],
        'AgendarLinkedIn'  => ['linkedin',  'LinkedIn'],
        'AgendarYoutube'   => ['youtube',   'YouTube'],
    ];
    $out = [];
    foreach($mapa as $col => $x){
        if((int)($r[$col] ?? 0) === 1) $out[] = ['k'=>$x[0], 'n'=>$x[1]];
    }
    return $out;
}

// Atividade da pessoa por DIA no período (o "o que fez", agregado do perfis_log).
// [Y-m-d => ['acoes'=>n, 'avancos'=>n]]. Avanço = transição de status p/ frente.
function pessoaAtividadePorDia($perfilIDs, $uID, $ini, $fim){
    if(empty($perfilIDs) || (int)$uID <= 0) return [];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));
    $rows = pesquisarComBindParam("
        SELECT DATE(pl.Data) AS Dia,
               COUNT(*) AS Acoes,
               COUNT(CASE WHEN pl.Campo = 'Status' AND spPara.Ordem > spDe.Ordem THEN 1 END) AS Avancos
        FROM perfis_log pl
        LEFT JOIN statusPost spDe   ON UPPER(spDe.Titulo)   = UPPER(pl.De)
        LEFT JOIN statusPost spPara ON UPPER(spPara.Titulo) = UPPER(pl.Para)
        WHERE pl.UserID = ? AND pl.PerfisID IN ($phP) AND pl.Data >= ? AND pl.Data < ?
        GROUP BY DATE(pl.Data)
        ORDER BY Dia ASC
    ", 'i'.$tpP.'ss', ...array_merge([$uID], $perfilIDs, [$ini, $fim])) ?: [];
    $map = [];
    foreach($rows as $r) $map[$r['Dia']] = ['acoes'=>(int)$r['Acoes'], 'avancos'=>(int)$r['Avancos']];
    return $map;
}

// Histórico MENSAL da pessoa (N meses até o mês de referência): avanços de
// etapa e entregas (conteúdo concluído + tarefa fechada) por mês. É o que
// responde "como ela vinha nos meses anteriores" sem navegar mês a mês.
// [ ['ym','label','avancos','entregas','atual'], ... ] em ordem cronológica.
function pessoaSerieMeses($perfilIDs, $uID, $ano, $mes, $n = 6){
    $uID = (int)$uID;
    if(empty($perfilIDs) || $uID <= 0) return [];
    $mesesN = ['','Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'];

    $sA = $ano; $sM = $mes - ($n - 1);
    while($sM < 1){ $sM += 12; $sA--; }
    $ini = sprintf('%04d-%02d-01 00:00:00', $sA, $sM);
    $eA = $ano; $eM = $mes + 1; if($eM > 12){ $eM = 1; $eA++; }
    $fim = sprintf('%04d-%02d-01 00:00:00', $eA, $eM);

    $buckets = [];
    $bA = $sA; $bM = $sM;
    for($i = 0; $i < $n; $i++){
        $ym = sprintf('%04d-%02d', $bA, $bM);
        $buckets[$ym] = ['ym'=>$ym, 'label'=>$mesesN[$bM], 'ano'=>$bA, 'mes'=>$bM,
                         'avancos'=>0, 'entregas'=>0,
                         'atual'=>($bA === (int)$ano && $bM === (int)$mes)];
        $bM++; if($bM > 12){ $bM = 1; $bA++; }
    }

    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));

    // Conteúdo: avanços (transição p/ frente) e entregas (destino concluído).
    $rows = pesquisarComBindParam("
        SELECT DATE_FORMAT(pl.Data, '%Y-%m') AS YM,
               COUNT(*) AS Avancos,
               COUNT(DISTINCT CASE WHEN spPara.Concluido = 1 THEN pl.PostID END) AS Entregas
        FROM perfis_log pl
        INNER JOIN statusPost spDe   ON UPPER(spDe.Titulo)   = UPPER(pl.De)
        INNER JOIN statusPost spPara ON UPPER(spPara.Titulo) = UPPER(pl.Para)
        INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
        WHERE pl.Campo = 'Status' AND pl.UserID = ? AND pl.PerfisID IN ($phP)
          AND pl.Data >= ? AND pl.Data < ? AND spPara.Ordem > spDe.Ordem
        GROUP BY DATE_FORMAT(pl.Data, '%Y-%m')
    ", 'i'.$tpP.'ss', ...array_merge([$uID], $perfilIDs, [$ini, $fim])) ?: [];
    foreach($rows as $r){
        if(!isset($buckets[$r['YM']])) continue;
        $buckets[$r['YM']]['avancos']  += (int)$r['Avancos'];
        $buckets[$r['YM']]['entregas'] += (int)$r['Entregas'];
    }

    // Tarefa: entrega = mover para um status de conclusão da própria lista.
    $rows = pesquisarComBindParam("
        SELECT DATE_FORMAT(pl.Data, '%Y-%m') AS YM, COUNT(DISTINCT pl.TarefaID) AS Entregas
        FROM perfis_log pl
        INNER JOIN tarefa t ON t.ID = pl.TarefaID AND t.Deletado = 0
        INNER JOIN espaco_lista el ON el.ID = t.ListaID
        LEFT JOIN espaco_lista_status els ON els.ListaID = t.ListaID
                                         AND UPPER(els.Titulo) = UPPER(pl.Para)
        WHERE pl.Campo = 'Status' AND pl.UserID = ? AND pl.TarefaID > 0
          AND pl.Data >= ? AND pl.Data < ?
          AND ".sqlTarefaLogConcluida('els', 'el', 'pl')."
          AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
        GROUP BY DATE_FORMAT(pl.Data, '%Y-%m')
    ", 'iss'.$tpP, ...array_merge([$uID, $ini, $fim], $perfilIDs)) ?: [];
    foreach($rows as $r){
        if(!isset($buckets[$r['YM']])) continue;
        $buckets[$r['YM']]['entregas'] += (int)$r['Entregas'];
        $buckets[$r['YM']]['avancos']  += (int)$r['Entregas'];
    }

    return array_values($buckets);
}

// ---------------------------------------------------------------------------
// PRODUÇÃO POR PESSOA (atribuição pelo ATOR do log = quem avançou a etapa).
// Cada transição de status que foi PARA FRENTE (Ordem destino > origem) conta 1
// "produção" creditada a perfis_log.UserID, na etapa de ORIGEM (a que ele concluiu).
// Agrega por etapa (contagem de avanços) e por tipo/canal/cliente (PostIDs distintos).
// Mesmas regras de equipeFluxo: bind param, título->Ordem case-insensitive.
function equipeProducaoPorPessoa($equipeID, $ini, $fim){
    $perfis = equipePerfis($equipeID);
    $perfilIDs = array_map(function($p){ return (int)$p['ID']; }, $perfis);
    if(empty($perfilIDs)) return ['porPessoa'=>[], 'etapaInfo'=>[], 'etapasUsadas'=>[], 'tipoNomes'=>[]];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));

    // Mapa status: título(UPPER) -> Ordem/ID; e ID -> [Titulo, Cor, Ordem].
    $spAll = pesquisarComBindParam("SELECT ID, Titulo, CorTexto, Ordem FROM statusPost WHERE ID > ?", 'i', 0) ?: [];
    $ordemByTitle = []; $idByTitle = []; $etapaInfo = [];
    foreach($spAll as $s){
        $u = mb_strtoupper($s['Titulo'], 'UTF-8');
        $ordemByTitle[$u] = (int)$s['Ordem'];
        $idByTitle[$u]    = (int)$s['ID'];
        $etapaInfo[(int)$s['ID']] = ['Titulo'=>$s['Titulo'], 'Cor'=>($s['CorTexto'] ?: '#888'), 'Ordem'=>(int)$s['Ordem']];
    }

    // Transições do período + tipo/canal/cliente do conteúdo.
    $logs = pesquisarComBindParam("
        SELECT pl.PostID, pl.UserID, pl.De, pl.Para,
               pp.TipoID, tp.Titulo AS TipoNome,
               pp.PerfisID, p.Nome AS Cliente,
               pp.AgendarInstagram, pp.AgendarFacebook, pp.AgendarTiktok, pp.AgendarLinkedIn, pp.AgendarYoutube
        FROM perfis_log pl
        INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
        INNER JOIN perfis p ON p.ID = pp.PerfisID
        LEFT JOIN tipoPost tp ON tp.ID = pp.TipoID
        WHERE pl.Campo = 'Status' AND pl.PerfisID IN ($phP) AND pl.Data >= ? AND pl.Data < ?
    ", $tp.'ss', ...array_merge($perfilIDs, [$ini, $fim])) ?: [];

    // coluna => [chave, nome]  (chave usada como id no drill-down por canal)
    $canais = [
        'AgendarInstagram' => ['instagram', 'Instagram'],
        'AgendarFacebook'  => ['facebook',  'Facebook'],
        'AgendarTiktok'    => ['tiktok',    'TikTok'],
        'AgendarLinkedIn'  => ['linkedin',  'LinkedIn'],
        'AgendarYoutube'   => ['youtube',   'YouTube'],
    ];

    $porPessoa = []; $etapasUsadas = []; $tipoNomes = [];
    $eP = function(&$a, $uid){
        if(!isset($a[$uid])) $a[$uid] = [
            'total'=>0, 'porEtapa'=>[], 'porTipo'=>[], 'porCanal'=>[], 'porCliente'=>[],
            'porEtapaTipo'=>[],
            '_tipoSet'=>[], '_canalSet'=>[], '_cliSet'=>[]
        ];
    };

    foreach($logs as $l){
        $deU = mb_strtoupper((string)$l['De'], 'UTF-8');
        $paU = mb_strtoupper((string)$l['Para'], 'UTF-8');
        if(!isset($ordemByTitle[$deU]) || !isset($ordemByTitle[$paU])) continue;
        if($ordemByTitle[$paU] <= $ordemByTitle[$deU]) continue; // só avanço p/ frente
        $uid = (int)$l['UserID']; if($uid <= 0) continue;
        $deId = $idByTitle[$deU];
        $pid  = (int)$l['PostID'];
        $tipoID   = (int)$l['TipoID'];
        $tipoNome = ($l['TipoNome'] !== null && $l['TipoNome'] !== '') ? $l['TipoNome'] : ('#'.$tipoID);
        $tipoNomes[$tipoID] = $tipoNome;

        $eP($porPessoa, $uid);
        $porPessoa[$uid]['total']++;
        $porPessoa[$uid]['porEtapa'][$deId] = ($porPessoa[$uid]['porEtapa'][$deId] ?? 0) + 1;
        // Cruzamento etapa × tipo (peças; chave por TipoID p/ drill-down).
        $porPessoa[$uid]['porEtapaTipo'][$deId][$tipoID] = ($porPessoa[$uid]['porEtapaTipo'][$deId][$tipoID] ?? 0) + 1;
        $etapasUsadas[$deId] = true;

        // Por tipo (posts distintos) — chave = TipoID.
        if(empty($porPessoa[$uid]['_tipoSet'][$tipoID][$pid])){
            $porPessoa[$uid]['_tipoSet'][$tipoID][$pid] = true;
            if(!isset($porPessoa[$uid]['porTipo'][$tipoID])) $porPessoa[$uid]['porTipo'][$tipoID] = ['nome'=>$tipoNome, 'n'=>0];
            $porPessoa[$uid]['porTipo'][$tipoID]['n']++;
        }
        // Por canal (posts distintos; um post pode mirar vários) — chave = canalKey.
        foreach($canais as $col => $ck){
            if((int)$l[$col] === 1 && empty($porPessoa[$uid]['_canalSet'][$ck[0]][$pid])){
                $porPessoa[$uid]['_canalSet'][$ck[0]][$pid] = true;
                if(!isset($porPessoa[$uid]['porCanal'][$ck[0]])) $porPessoa[$uid]['porCanal'][$ck[0]] = ['nome'=>$ck[1], 'n'=>0];
                $porPessoa[$uid]['porCanal'][$ck[0]]['n']++;
            }
        }
        // Por cliente (posts distintos) — chave = PerfisID.
        $cliID   = (int)$l['PerfisID'];
        $cliNome = ($l['Cliente'] !== null && $l['Cliente'] !== '') ? $l['Cliente'] : ('#'.$cliID);
        if(empty($porPessoa[$uid]['_cliSet'][$cliID][$pid])){
            $porPessoa[$uid]['_cliSet'][$cliID][$pid] = true;
            if(!isset($porPessoa[$uid]['porCliente'][$cliID])) $porPessoa[$uid]['porCliente'][$cliID] = ['nome'=>$cliNome, 'n'=>0];
            $porPessoa[$uid]['porCliente'][$cliID]['n']++;
        }
    }

    $byN = function($a, $b){ return $b['n'] - $a['n']; };
    foreach($porPessoa as $uid => &$d){
        unset($d['_tipoSet'], $d['_canalSet'], $d['_cliSet']);
        arsort($d['porEtapa']);
        uasort($d['porTipo'], $byN); uasort($d['porCanal'], $byN); uasort($d['porCliente'], $byN);
    }
    unset($d);

    return ['porPessoa'=>$porPessoa, 'etapaInfo'=>$etapaInfo, 'etapasUsadas'=>array_keys($etapasUsadas), 'tipoNomes'=>$tipoNomes];
}

// Drill-down: conteúdos DISTINTOS que a pessoa avançou, filtrando por etapa/tipo/perfil/canal
// (qualquer filtro 0/'' = todos). Ordenado por perfil (p/ agrupar) e por último avanço.
function equipePessoaItens($equipeID, $uID, $filtros, $ini, $fim){
    $uID     = (int)$uID;
    $etapaID = (int)($filtros['etapa'] ?? 0);
    $tipoID  = (int)($filtros['tipo'] ?? 0);
    $perfil  = (int)($filtros['perfil'] ?? 0);
    $canal   = (string)($filtros['canal'] ?? '');
    $perfis = equipePerfis($equipeID);
    $perfilIDs = array_map(function($p){ return (int)$p['ID']; }, $perfis);
    if(empty($perfilIDs) || $uID <= 0) return [];
    if($perfil > 0 && !in_array($perfil, $perfilIDs, true)) return [];

    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));

    $extra = ''; $extraTipos = ''; $extraArgs = [];
    if($etapaID > 0){ $extra .= ' AND spDe.ID = ?';     $extraTipos .= 'i'; $extraArgs[] = $etapaID; }
    if($tipoID  > 0){ $extra .= ' AND pp.TipoID = ?';   $extraTipos .= 'i'; $extraArgs[] = $tipoID; }
    if($perfil  > 0){ $extra .= ' AND pp.PerfisID = ?'; $extraTipos .= 'i'; $extraArgs[] = $perfil; }
    // canal: coluna por whitelist (chave não entra crua no SQL).
    $canalCols = ['instagram'=>'AgendarInstagram', 'facebook'=>'AgendarFacebook', 'tiktok'=>'AgendarTiktok', 'linkedin'=>'AgendarLinkedIn', 'youtube'=>'AgendarYoutube'];
    if($canal !== '' && isset($canalCols[$canal])){ $extra .= ' AND pp.'.$canalCols[$canal].' = 1'; }

    $sql = "
        SELECT pp.ID, pp.Numero, pp.Titulo, pp.PerfisID, p.Nome AS Cliente,
               pp.DataAgendado AS Quando,
               pp.AgendarInstagram, pp.AgendarFacebook, pp.AgendarTiktok, pp.AgendarLinkedIn, pp.AgendarYoutube,
               p.InstagramFoto, p.FacebookID, p.TiktokFoto, p.ImagemFonte,
               ".sqlLogoPerfilSubquery()." AS LogoPerfil,
               tp.Titulo AS TipoNome, spAtual.Titulo AS StatusNome, spAtual.CorTexto AS StatusCor,
               spAtual.Concluido AS Feito,
               MAX(pl.Data) AS UltAvanco,
               (SELECT CONCAT('https://', s2.Dominio, ppu2.Arquivo)
                  FROM perfis_posts_upload ppu2 INNER JOIN servers s2 ON s2.ID = ppu2.ServerID
                  WHERE ppu2.PostID = pp.ID AND ppu2.TipoID = 0 AND ppu2.Deletado = 0 AND ppu2.DuplicadoRefID IS NULL
                  ORDER BY ppu2.Versao DESC, ppu2.ID DESC LIMIT 1) AS Thumb,
               (SELECT ppu3.Extensao
                  FROM perfis_posts_upload ppu3
                  WHERE ppu3.PostID = pp.ID AND ppu3.TipoID = 0 AND ppu3.Deletado = 0 AND ppu3.DuplicadoRefID IS NULL
                  ORDER BY ppu3.Versao DESC, ppu3.ID DESC LIMIT 1) AS ThumbExt
        FROM perfis_log pl
        INNER JOIN statusPost spDe   ON UPPER(spDe.Titulo)   = UPPER(pl.De)
        INNER JOIN statusPost spPara ON UPPER(spPara.Titulo) = UPPER(pl.Para)
        INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
        INNER JOIN perfis p ON p.ID = pp.PerfisID
        LEFT JOIN tipoPost tp ON tp.ID = pp.TipoID
        LEFT JOIN statusPost spAtual ON spAtual.ID = pp.StatusID
        WHERE pl.Campo = 'Status' AND pl.UserID = ?
          AND pl.PerfisID IN ($phP)
          AND pl.Data >= ? AND pl.Data < ?
          AND spPara.Ordem > spDe.Ordem
          $extra
        GROUP BY pp.ID, pp.Numero, pp.Titulo, pp.PerfisID, p.Nome, pp.DataAgendado,
                 pp.AgendarInstagram, pp.AgendarFacebook, pp.AgendarTiktok, pp.AgendarLinkedIn, pp.AgendarYoutube,
                 p.InstagramFoto, p.FacebookID, p.TiktokFoto, p.ImagemFonte, p.LogoPerfilID,
                 tp.Titulo, spAtual.Titulo, spAtual.CorTexto, spAtual.Concluido
        ORDER BY p.Nome ASC, UltAvanco DESC
    ";
    $types = 'i'.$tp.'ss'.$extraTipos;
    $args  = array_merge([$uID], $perfilIDs, [$ini, $fim], $extraArgs);
    return pesquisarComBindParam($sql, $types, ...$args) ?: [];
}

// Responsáveis dos itens de um drill-down, numa query por natureza.
// Devolve ['conteudo'=>[postID=>[ ['ID','Nome','ImagemPerfil'], ...]], 'tarefa'=>[...]].
// Vem depois da lista (e não por GROUP_CONCAT) para o card poder desenhar avatar
// de verdade sem inflar cada linha das queries de origem.
function equipeResponsaveisDosItens($itens){
    $out = ['conteudo'=>[], 'tarefa'=>[]];
    $c = []; $t = [];
    foreach($itens as $it){
        if((($it['Kind'] ?? 'conteudo') === 'tarefa')) $t[(int)$it['ID']] = true;
        else                                           $c[(int)$it['ID']] = true;
    }
    if(!empty($c)){
        $ids = array_keys($c);
        $ph  = implode(',', array_fill(0, count($ids), '?'));
        $rows = pesquisarComBindParam("
            SELECT ppu.PostID AS I, u.ID, u.Nome, u.ImagemPerfil
            FROM perfis_posts_usuarios ppu
            INNER JOIN usuario u ON u.ID = ppu.UserID
            WHERE ppu.PostID IN ($ph)
        ", str_repeat('i', count($ids)), ...$ids) ?: [];
        foreach($rows as $x){
            $out['conteudo'][(int)$x['I']][] = ['ID'=>(int)$x['ID'], 'Nome'=>$x['Nome'], 'ImagemPerfil'=>$x['ImagemPerfil']];
        }
    }
    if(!empty($t)){
        $ids = array_keys($t);
        $ph  = implode(',', array_fill(0, count($ids), '?'));
        $rows = pesquisarComBindParam("
            SELECT tu.TarefaID AS I, u.ID, u.Nome, u.ImagemPerfil
            FROM tarefa_usuario tu
            INNER JOIN usuario u ON u.ID = tu.UsuarioID
            WHERE tu.TarefaID IN ($ph)
        ", str_repeat('i', count($ids)), ...$ids) ?: [];
        foreach($rows as $x){
            $out['tarefa'][(int)$x['I']][] = ['ID'=>(int)$x['ID'], 'Nome'=>$x['Nome'], 'ImagemPerfil'=>$x['ImagemPerfil']];
        }
    }
    return $out;
}

// --- Blocos "agora" (snapshot) da Visão geral ------------------------------

// WIP atual (conteúdos não concluídos) agrupado por tipo de conteúdo.
function equipeWipPorTipo($perfilIDs){
    if(empty($perfilIDs)) return [];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));
    return pesquisarComBindParam("
        SELECT tp.Titulo AS Nome, COUNT(DISTINCT pp.ID) AS Qtd
        FROM perfis_posts pp
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        INNER JOIN tipoPost tp ON tp.ID = pp.TipoID
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
        GROUP BY tp.ID, tp.Titulo
        ORDER BY Qtd DESC
    ", $tp, ...$perfilIDs) ?: [];
}

// WIP atual contado por canal (flags Agendar*). Um post entra em cada canal que mira.
function equipeWipPorCanal($perfilIDs){
    if(empty($perfilIDs)) return [];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));
    $rows = pesquisarComBindParam("
        SELECT
          SUM(pp.AgendarInstagram) AS Instagram,
          SUM(pp.AgendarFacebook)  AS Facebook,
          SUM(pp.AgendarTiktok)    AS TikTok,
          SUM(pp.AgendarLinkedIn)  AS LinkedIn,
          SUM(pp.AgendarYoutube)   AS YouTube
        FROM perfis_posts pp
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
    ", $tp, ...$perfilIDs) ?: [];
    $row = $rows[0] ?? [];
    $out = [];
    foreach(['Instagram','Facebook','TikTok','LinkedIn','YouTube'] as $c){
        $q = (int)($row[$c] ?? 0);
        if($q > 0) $out[] = ['Nome'=>$c, 'Qtd'=>$q];
    }
    usort($out, function($a,$b){ return $b['Qtd'] - $a['Qtd']; });
    return $out;
}

// (equipeAgendaProximos foi removida: o "próximos 7 dias por dia" que ela servia
//  virou a aba Agenda, que mostra o mesmo recorte QUEBRADO POR PESSOA e somando
//  tarefas — ver equipeDemandaPorDia/equipeDadosAgenda.)

// Conteúdos em andamento SEM nenhum responsável/membro atribuído (órfãos = risco).
function equipeSemResponsavel($perfilIDs){
    if(empty($perfilIDs)) return ['qtd'=>0, 'lista'=>[]];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));
    $cnt = pesquisarComBindParam("
        SELECT COUNT(DISTINCT pp.ID) AS Qtd
        FROM perfis_posts pp
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
          AND NOT EXISTS (SELECT 1 FROM perfis_posts_usuarios ppu WHERE ppu.PostID = pp.ID)
    ", $tp, ...$perfilIDs) ?: [];
    $lista = pesquisarComBindParam("
        SELECT pp.ID, pp.PerfisID, pp.Numero, pp.Titulo, p.Nome AS PerfilNome,
               sp.Titulo AS StatusNome, sp.CorFundo AS StatusCor, sp.CorTexto AS StatusCorTexto
        FROM perfis_posts pp
        INNER JOIN perfis p ON p.ID = pp.PerfisID
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
          AND NOT EXISTS (SELECT 1 FROM perfis_posts_usuarios ppu WHERE ppu.PostID = pp.ID)
        ORDER BY pp.DataAtualizado ASC
        LIMIT 12
    ", $tp, ...$perfilIDs) ?: [];
    return ['qtd'=>(int)($cnt[0]['Qtd'] ?? 0), 'lista'=>$lista];
}

// Clientes (perfis_usuario.PermissaoID = 4) das marcas da equipe + nº de conteúdos EM
// ANDAMENTO em que o cliente está marcado (aguardando ação dele). Por (cliente, perfil).
function equipeClientesPendentes($perfilIDs){
    if(empty($perfilIDs)) return [];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));
    return pesquisarComBindParam("
        SELECT u.ID, u.Nome, u.ImagemPerfil, p.ID AS PerfilID, p.Nome AS PerfilNome,
          COUNT(DISTINCT CASE WHEN sp.ID IS NOT NULL THEN pp.ID END) AS Pendentes
        FROM perfis_usuario pu
        INNER JOIN usuario u ON u.ID = pu.UsuarioID
        INNER JOIN perfis  p ON p.ID = pu.PerfisID
        LEFT JOIN perfis_posts_usuarios ppu ON ppu.UserID = pu.UsuarioID AND ppu.PerfisID = pu.PerfisID
        LEFT JOIN perfis_posts pp ON pp.ID = ppu.PostID AND pp.Deletado = 0
        LEFT JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pu.PermissaoID = 4 AND pu.PerfisID IN ($phP)
        GROUP BY u.ID, u.Nome, u.ImagemPerfil, p.ID, p.Nome
        ORDER BY Pendentes DESC, u.Nome ASC
    ", $tp, ...$perfilIDs) ?: [];
}

function equipeFluxo($equipeID, $ini, $fim){
    $perfis = equipePerfis($equipeID);
    $perfilIDs = array_map(function($p){ return (int)$p['ID']; }, $perfis);
    if(empty($perfilIDs)) return ['porPessoa'=>[], 'porStatus'=>[], 'respById'=>[]];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));

    // Mapa de status: título(UPPER) -> ID; ID -> Ordem/Concluido.
    $spAll = pesquisarComBindParam("SELECT ID, Titulo, Ordem, Concluido FROM statusPost WHERE ID > ?", 'i', 0) ?: [];
    $byTitle = []; $ordemById = []; $concById = [];
    foreach($spAll as $s){
        $byTitle[mb_strtoupper($s['Titulo'], 'UTF-8')] = (int)$s['ID'];
        $ordemById[(int)$s['ID']] = (int)$s['Ordem'];
        $concById[(int)$s['ID']]  = (int)$s['Concluido'];
    }

    // Responsáveis por status no contexto da equipe (override por perfil + global do dono).
    $respById = [];
    $rr = pesquisarComBindParam("
        SELECT StatusID, UsuarioID FROM perfil_status_responsavel WHERE PerfisID IN ($phP) AND UsuarioID > 0
        UNION
        SELECT spr.StatusID, spr.UsuarioID
        FROM perfis p INNER JOIN statuspost_responsavel spr ON spr.OwnerID = p.UsuarioID
        WHERE p.ID IN ($phP)
    ", $tp.$tp, ...array_merge($perfilIDs, $perfilIDs)) ?: [];
    foreach($rr as $x){ $respById[(int)$x['StatusID']][(int)$x['UsuarioID']] = true; }

    // Histórico ANTERIOR ao período: por quais status cada conteúdo já passou.
    // Serve para só contar regressão/retrabalho quando o conteúdo REALMENTE já
    // esteve no status de destino antes — uma "queda" pela primeira vez num
    // status anterior (por onde nunca passou) não é retrabalho.
    $visited = []; // postID -> [sid => true]
    $pre = pesquisarComBindParam("
        SELECT DISTINCT pl.PostID, pl.De, pl.Para
        FROM perfis_log pl
        INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
        WHERE pl.Campo = 'Status' AND pl.PerfisID IN ($phP) AND pl.Data < ?
    ", $tp.'s', ...array_merge($perfilIDs, [$ini])) ?: [];
    foreach($pre as $x){
        $pid = (int)$x['PostID'];
        $d = $byTitle[mb_strtoupper((string)$x['De'],   'UTF-8')] ?? 0;
        $p = $byTitle[mb_strtoupper((string)$x['Para'], 'UTF-8')] ?? 0;
        if($d) $visited[$pid][$d] = true;
        if($p) $visited[$pid][$p] = true;
    }

    // Transições de status no período + status ATUAL do conteúdo.
    $logs = pesquisarComBindParam("
        SELECT pl.PostID, pl.Data, pl.De, pl.Para, pp.StatusID AS Atual
        FROM perfis_log pl
        INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
        WHERE pl.Campo = 'Status' AND pl.PerfisID IN ($phP) AND pl.Data >= ? AND pl.Data < ?
        ORDER BY pl.PostID ASC, pl.Data ASC, pl.ID ASC
    ", $tp.'ss', ...array_merge($perfilIDs, [$ini, $fim])) ?: [];

    $porPessoa = []; $porStatus = [];
    $eP = function(&$a, $k){ if(!isset($a[$k])) $a[$k] = ['entregas'=>0, 'retrabalho'=>0]; };
    $eS = function(&$a, $k){ if(!isset($a[$k])) $a[$k] = ['throughput'=>0, 'retrabalho'=>0, 'rejeicoes'=>0, 'tempoSoma'=>0, 'tempoN'=>0]; };

    $entryByPost = [];  // postID -> [sid -> data entrada]
    $creditOnce  = [];  // "postID|sid" -> já creditou no período

    foreach($logs as $l){
        $pid   = (int)$l['PostID'];
        $atual = (int)$l['Atual'];
        $deUp  = mb_strtoupper((string)$l['De'], 'UTF-8');
        $paUp  = mb_strtoupper((string)$l['Para'], 'UTF-8');
        $deId   = $byTitle[$deUp]  ?? 0;
        $paraId = $byTitle[$paUp]  ?? 0;

        // O conteúdo está atualmente em De -> já passou por ele.
        if($deId){ if(!isset($visited[$pid])) $visited[$pid] = []; $visited[$pid][$deId] = true; }

        // tempo na etapa de origem (entrada anterior -> esta saída)
        if($deId && isset($entryByPost[$pid][$deId])){
            $dur = strtotime($l['Data']) - strtotime($entryByPost[$pid][$deId]);
            if($dur > 0){ $eS($porStatus, $deId); $porStatus[$deId]['tempoSoma'] += $dur; $porStatus[$deId]['tempoN']++; }
            unset($entryByPost[$pid][$deId]);
        }

        if($paraId){ $entryByPost[$pid][$paraId] = $l['Data']; }

        if(!$deId || !$paraId) continue;
        $oDe = $ordemById[$deId]; $oPara = $ordemById[$paraId];

        if($oPara > $oDe){
            // saída p/ frente -> throughput da etapa de origem
            $eS($porStatus, $deId); $porStatus[$deId]['throughput']++;
            // concluído por: origem tem responsável e o conteúdo está ATUALMENTE além dela
            $alem = ($atual && (($ordemById[$atual] ?? -1) > $oDe || ($concById[$atual] ?? 0) == 1) && $atual != $deId);
            if($alem && isset($respById[$deId]) && empty($creditOnce[$pid.'|'.$deId])){
                $creditOnce[$pid.'|'.$deId] = true;
                foreach(array_keys($respById[$deId]) as $uid){ $eP($porPessoa, $uid); $porPessoa[$uid]['entregas']++; }
            }
        } else if($oPara < $oDe && isset($visited[$pid][$paraId])){
            // REGRESSÃO (voltou no fluxo) — só conta se o conteúdo JÁ esteve em Para antes.
            //  - RETRABALHO da etapa de DESTINO (Para) -> quem vai REFAZER (e responsável)
            $eS($porStatus, $paraId); $porStatus[$paraId]['retrabalho']++;
            if(isset($respById[$paraId])){
                foreach(array_keys($respById[$paraId]) as $uid){ $eP($porPessoa, $uid); $porPessoa[$uid]['retrabalho']++; }
            }
            //  - DEVOLUÇÃO/rejeição da etapa de ORIGEM (De) -> quem MANDOU de volta
            $eS($porStatus, $deId); $porStatus[$deId]['rejeicoes']++;
        }

        // Marca a entrada em Para DEPOIS de avaliar a regressão (a 1ª queda não se autoconta).
        if($paraId){ if(!isset($visited[$pid])) $visited[$pid] = []; $visited[$pid][$paraId] = true; }
    }

    return ['porPessoa'=>$porPessoa, 'porStatus'=>$porStatus, 'respById'=>$respById];
}

// ===========================================================================
// CARGOS DA EQUIPE
// ---------------------------------------------------------------------------
// Um cargo é o PAPEL da pessoa na operação (Designer, Redator, Editor…). Ele
// carrega duas configurações que economizam trabalho manual do gestor:
//
//   1. STATUS PADRÕES (equipe_cargo_status) — ao vincular alguém ao cargo, ela
//      é gravada como responsável desses status em TODAS as marcas da equipe
//      (perfil_status_responsavel, tier Geral = 1). É o MESMO vínculo que a aba
//      "Responsáveis por status" edita: cargo não é um sistema paralelo, é um
//      atalho para preencher esse vínculo. Então a rotação automática de
//      responsável (getResponsaveisStatusConteudo) passa a valer sem mais nada.
//   2. VALOR POR DEMANDA — padrão herdado pelos membros EXTERNOS do cargo.
//
// Uma pessoa pode ter VÁRIOS cargos. Ao remover um cargo, ela sai dos status
// dele — exceto os que outro cargo dela ainda cobre.
// ===========================================================================

// Cargos da equipe + quantas pessoas têm cada um + os status de cada um.
// Devolve [] quando a migration não foi aplicada (feature degrada).
function equipeCargos($equipeID){
    if(!equipeTemCargos()) return [];
    $cargos = pesquisarComBindParam("
        SELECT c.ID, c.Nome, c.Cor, c.Descricao, c.ValorDemanda, c.Ordem,
               (SELECT COUNT(*) FROM equipe_usuario_cargo uc WHERE uc.CargoID = c.ID) AS Pessoas
        FROM equipe_cargo c
        WHERE c.EquipeID = ? AND c.Deletado = 0
        ORDER BY c.Ordem ASC, c.Nome ASC
    ", 'i', $equipeID) ?: [];
    if(empty($cargos)) return [];

    $ids = array_map(function($c){ return (int)$c['ID']; }, $cargos);
    $ph  = implode(',', array_fill(0, count($ids), '?'));
    $col = equipeTemCargoDimensao() ? 'cs.TipoID, cs.LocalID' : '0 AS TipoID, 0 AS LocalID';
    $st  = pesquisarComBindParam("
        SELECT cs.CargoID, cs.StatusID, $col, sp.Titulo, sp.CorTexto, sp.Ordem
        FROM equipe_cargo_status cs
        INNER JOIN statusPost sp ON sp.ID = cs.StatusID
        WHERE cs.CargoID IN ($ph)
        ORDER BY sp.Ordem ASC
    ", str_repeat('i', count($ids)), ...$ids) ?: [];

    // A lista mostra UM chip por status (o recorte aparece no editor), mas o
    // editor precisa das regras cruas para reabrir marcado — vão as duas coisas.
    $porCargo = []; $regrasCargo = []; $vistoStatus = [];
    foreach($st as $s){
        $cid = (int)$s['CargoID']; $sid = (int)$s['StatusID'];
        $t = (int)$s['TipoID']; $l = (int)$s['LocalID'];
        if(!isset($vistoStatus[$cid][$sid])){
            $vistoStatus[$cid][$sid] = true;
            $porCargo[$cid][] = ['ID'=>$sid, 'Titulo'=>$s['Titulo'], 'Cor'=>($s['CorTexto'] ?: '#888')];
        }
        $regrasCargo[$cid][] = ['status'=>$sid, 'tipo'=>$t, 'local'=>$l];
    }
    foreach($cargos as &$c){
        $cid = (int)$c['ID'];
        $c['status'] = $porCargo[$cid] ?? [];
        $c['regras'] = $regrasCargo[$cid] ?? [];
        // "detalhado" = o cargo usa recorte em alguma regra; a lista sinaliza.
        $c['detalhado'] = false;
        foreach($c['regras'] as $r){ if($r['tipo'] > 0 || $r['local'] > 0){ $c['detalhado'] = true; break; } }
    }
    unset($c);
    return $cargos;
}

// [uid => [ ['ID','Nome','Cor'], ... ]] — cargos de cada pessoa da equipe.
// É o que alimenta os chips de cargo em toda tela de equipe.
function equipeCargosPorUsuario($equipeID){
    if(!equipeTemCargos()) return [];
    $rows = pesquisarComBindParam("
        SELECT uc.UsuarioID, c.ID, c.Nome, c.Cor
        FROM equipe_usuario_cargo uc
        INNER JOIN equipe_cargo c ON c.ID = uc.CargoID AND c.Deletado = 0
        WHERE uc.EquipeID = ?
        ORDER BY c.Ordem ASC, c.Nome ASC
    ", 'i', $equipeID) ?: [];
    $map = [];
    foreach($rows as $r){
        $map[(int)$r['UsuarioID']][] = ['ID'=>(int)$r['ID'], 'Nome'=>$r['Nome'], 'Cor'=>($r['Cor'] ?: '')];
    }
    return $map;
}

// Cargo detalhado: BIN/migrations/2026-08-13_cargo_tipo_canal.sql. Sem ela o
// cargo continua só por status e a tela não mostra a origem da regra.
function equipeTemCargoDimensao(){
    return equipeTemEstrutura('equipe_cargo_status', 'TipoID')
        && equipeTemEstrutura('perfil_status_responsavel', 'CargoID');
}

// As REGRAS de um cargo: [['status','tipo','local'], ...]. Tipo/canal em 0 =
// "todos" — que é exatamente o que existia antes da migration, por isso o
// fallback devolve a mesma coisa com os dois zerados.
function equipeCargoRegras($cargoID){
    if(!equipeTemCargos()) return [];
    $col = equipeTemCargoDimensao() ? 'TipoID, LocalID' : '0 AS TipoID, 0 AS LocalID';
    $rows = pesquisarComBindParam(
        "SELECT StatusID, $col FROM equipe_cargo_status WHERE CargoID = ?", 'i', (int)$cargoID) ?: [];
    $out = [];
    foreach($rows as $r){
        $out[] = ['status'=>(int)$r['StatusID'], 'tipo'=>(int)$r['TipoID'], 'local'=>(int)$r['LocalID']];
    }
    return $out;
}

// IDs dos status de um cargo (só os do cargo, sem join).
function equipeCargoStatusIDs($cargoID){
    if(!equipeTemCargos()) return [];
    $rows = pesquisarComBindParam(
        "SELECT DISTINCT StatusID FROM equipe_cargo_status WHERE CargoID = ?", 'i', (int)$cargoID) ?: [];
    return array_map(function($x){ return (int)$x['StatusID']; }, $rows);
}

// O cargo pertence a esta equipe? (guarda de toda escrita)
function equipeCargoDaEquipe($equipeID, $cargoID){
    if(!equipeTemCargos()) return false;
    $r = pesquisarUmComBindParam(
        "SELECT 1 ok FROM equipe_cargo WHERE ID = ? AND EquipeID = ? AND Deletado = 0",
        'ii', (int)$cargoID, (int)$equipeID);
    return !empty($r);
}

// Concede as regras de UM cargo a uma pessoa (tier Geral = 1), em TODAS as
// marcas da equipe. Aditivo: não encosta nas regras dos outros cargos nem nas
// MANUAIS (CargoID = 0) — que são o "ajuste extra" da aba Responsáveis.
//
// Com a migration do cargo detalhado a linha nasce carimbada com o CargoID; sem
// ela cai no comportamento antigo (só status, sem origem).
function equipeCargoConcederRegras($equipeID, $usuarioID, $cargoID, $regras){
    $usuarioID = (int)$usuarioID; $cargoID = (int)$cargoID;
    if($usuarioID <= 0 || empty($regras)) return;
    $temOrigem = equipeTemCargoDimensao();
    foreach(equipePerfis($equipeID) as $p){
        $pid = (int)$p['ID'];
        foreach($regras as $r){
            $sid = (int)$r['status']; if($sid <= 0) continue;
            $t = (int)$r['tipo']; $l = (int)$r['local'];
            $dim = respFiltroDimensao($t, $l);
            $ex = pesquisarUmComBindParam("
                SELECT ID FROM perfil_status_responsavel
                WHERE PerfisID = ? AND StatusID = ? AND UsuarioID = ? AND Geral = 1".$dim['sql']." LIMIT 1
            ", 'iii'.$dim['tipos'], ...array_merge([$pid, $sid, $usuarioID], $dim['args']));
            if(!empty($ex['ID'])){
                // A regra já existe (era manual ou de outro cargo): o cargo assume
                // a autoria só se ela estava solta, senão viraria disputa silenciosa.
                if($temOrigem){
                    updateComBindParam(
                        "UPDATE perfil_status_responsavel SET CargoID = ? WHERE ID = ? AND CargoID = 0",
                        'ii', $cargoID, (int)$ex['ID']);
                }
                continue;
            }
            respInserirLinha($pid, $sid, $usuarioID, 1, $t, $l, $cargoID);
        }
    }
}

// Revoga o que ESTE cargo deu a uma pessoa. Com a coluna de origem é um DELETE
// por CargoID — sem ela seria um diff por status que, com tipo/canal, deixaria
// linha órfã que ninguém mais saberia remover.
function equipeCargoRevogarRegras($equipeID, $usuarioID, $cargoID, $regrasFallback = []){
    $usuarioID = (int)$usuarioID; $cargoID = (int)$cargoID;
    if($usuarioID <= 0) return;

    if(equipeTemCargoDimensao()){
        foreach(equipePerfis($equipeID) as $p){
            deleteComBindParam(
                "DELETE FROM perfil_status_responsavel WHERE PerfisID = ? AND UsuarioID = ? AND CargoID = ?",
                'iii', (int)$p['ID'], $usuarioID, $cargoID);
        }
        return;
    }
    // Sem a migration: comportamento antigo, apaga a regra genérica dos status.
    $dim = respFiltroDimensao(0, 0);
    foreach(equipePerfis($equipeID) as $p){
        $pid = (int)$p['ID'];
        foreach($regrasFallback as $r){
            $sid = (int)$r['status']; if($sid <= 0) continue;
            deleteComBindParam("
                DELETE FROM perfil_status_responsavel
                WHERE PerfisID = ? AND StatusID = ? AND UsuarioID = ? AND Geral = 1".$dim['sql'],
                'iii'.$dim['tipos'], ...array_merge([$pid, $sid, $usuarioID], $dim['args']));
        }
    }
}

// Assinatura antiga (lista de StatusIDs) — mantida porque outros pontos ainda a
// chamam; encaminha para as funções por regra com tipo/canal "todos".
function equipeCargoAplicarResponsaveis($equipeID, $usuarioID, $statusIDs, $conceder = true, $cargoID = 0){
    $statusIDs = array_values(array_unique(array_filter(array_map('intval', (array)$statusIDs), function($v){ return $v > 0; })));
    if(empty($statusIDs)) return;
    $regras = array_map(function($s){ return ['status'=>$s, 'tipo'=>0, 'local'=>0]; }, $statusIDs);
    if($conceder) equipeCargoConcederRegras($equipeID, $usuarioID, $cargoID, $regras);
    else          equipeCargoRevogarRegras($equipeID, $usuarioID, $cargoID, $regras);
}

// Status que os cargos ATUAIS de uma pessoa cobrem (união).
function equipeCargoStatusDoUsuario($equipeID, $usuarioID){
    if(!equipeTemCargos()) return [];
    $rows = pesquisarComBindParam("
        SELECT DISTINCT cs.StatusID
        FROM equipe_usuario_cargo uc
        INNER JOIN equipe_cargo c ON c.ID = uc.CargoID AND c.Deletado = 0
        INNER JOIN equipe_cargo_status cs ON cs.CargoID = uc.CargoID
        WHERE uc.EquipeID = ? AND uc.UsuarioID = ?
    ", 'ii', (int)$equipeID, (int)$usuarioID) ?: [];
    return array_map(function($x){ return (int)$x['StatusID']; }, $rows);
}

// Vincula a pessoa ao cargo e liga a automação dos status dele.
function equipeCargoVincular($equipeID, $usuarioID, $cargoID){
    if(!equipeCargoDaEquipe($equipeID, $cargoID)) return false;
    $equipeID = (int)$equipeID; $usuarioID = (int)$usuarioID; $cargoID = (int)$cargoID;
    $ex = pesquisarUmComBindParam(
        "SELECT ID FROM equipe_usuario_cargo WHERE EquipeID = ? AND UsuarioID = ? AND CargoID = ? LIMIT 1",
        'iii', $equipeID, $usuarioID, $cargoID);
    if(empty($ex['ID'])){
        insertComBindParam("
            INSERT INTO equipe_usuario_cargo (EquipeID, UsuarioID, CargoID, DataCriado)
            VALUES (?, ?, ?, NOW())
        ", 'iii', $equipeID, $usuarioID, $cargoID);
    }
    equipeCargoConcederRegras($equipeID, $usuarioID, $cargoID, equipeCargoRegras($cargoID));
    return true;
}

// Desvincula e desliga a automação — menos os status que OUTRO cargo da pessoa
// ainda cobre (senão tirar "Designer" apagaria os status compartilhados com
// "Editor", que ela continua exercendo).
function equipeCargoDesvincular($equipeID, $usuarioID, $cargoID){
    if(!equipeTemCargos()) return false;
    $equipeID = (int)$equipeID; $usuarioID = (int)$usuarioID; $cargoID = (int)$cargoID;
    $doCargo = equipeCargoStatusIDs($cargoID);
    deleteComBindParam(
        "DELETE FROM equipe_usuario_cargo WHERE EquipeID = ? AND UsuarioID = ? AND CargoID = ?",
        'iii', $equipeID, $usuarioID, $cargoID);
    // Revoga o que ERA deste cargo. As regras dos OUTROS cargos da pessoa ficam:
    // com a coluna de origem elas têm CargoID próprio e nem são tocadas — antes
    // isso exigia o diff "menos o que outro cargo ainda cobre".
    if(equipeTemCargoDimensao()){
        equipeCargoRevogarRegras($equipeID, $usuarioID, $cargoID);
    } else {
        $restantes = equipeCargoStatusDoUsuario($equipeID, $usuarioID);
        $revogar   = array_values(array_diff($doCargo, $restantes));
        equipeCargoAplicarResponsaveis($equipeID, $usuarioID, $revogar, false, $cargoID);
    }
    return true;
}

// Recalcula a automação de TODAS as pessoas de um cargo (usar depois de mudar
// as regras dele). Com a coluna de origem é "apaga o que era do cargo e grava o
// que é agora" — o diff antigo não daria conta de tipo/canal.
// $statusAntes é o conjunto anterior, usado só no caminho sem migration.
function equipeCargoResincronizar($equipeID, $cargoID, $statusAntes){
    if(!equipeTemCargos()) return;
    $pessoas = pesquisarComBindParam(
        "SELECT UsuarioID FROM equipe_usuario_cargo WHERE EquipeID = ? AND CargoID = ?",
        'ii', (int)$equipeID, (int)$cargoID) ?: [];
    if(empty($pessoas)) return;

    if(equipeTemCargoDimensao()){
        $regras = equipeCargoRegras($cargoID);
        foreach($pessoas as $p){
            $uid = (int)$p['UsuarioID'];
            equipeCargoRevogarRegras($equipeID, $uid, $cargoID);
            equipeCargoConcederRegras($equipeID, $uid, $cargoID, $regras);
        }
        return;
    }

    $agora  = equipeCargoStatusIDs($cargoID);
    $entrou = array_values(array_diff($agora, $statusAntes));
    $saiu   = array_values(array_diff($statusAntes, $agora));
    foreach($pessoas as $p){
        $uid = (int)$p['UsuarioID'];
        if(!empty($entrou)) equipeCargoAplicarResponsaveis($equipeID, $uid, $entrou, true, $cargoID);
        if(!empty($saiu)){
            // Só revoga o que nenhum outro cargo da pessoa cobre.
            $restantes = equipeCargoStatusDoUsuario($equipeID, $uid);
            equipeCargoAplicarResponsaveis($equipeID, $uid, array_values(array_diff($saiu, $restantes)), false, $cargoID);
        }
    }
}

// Marca nova na equipe: replica a automação de cargo nela (equipeAposAdicionarPerfil
// copia a intersecção Geral, o que já cobre o caso comum — isto garante o resto).
function equipeCargoAplicarNoPerfil($equipeID, $perfilID){
    if(!equipeTemCargos()) return;
    $perfilID = (int)$perfilID;
    $col = equipeTemCargoDimensao() ? 'cs.TipoID, cs.LocalID' : '0 AS TipoID, 0 AS LocalID';
    $rows = pesquisarComBindParam("
        SELECT DISTINCT uc.UsuarioID, uc.CargoID, cs.StatusID, $col
        FROM equipe_usuario_cargo uc
        INNER JOIN equipe_cargo c ON c.ID = uc.CargoID AND c.Deletado = 0
        INNER JOIN equipe_cargo_status cs ON cs.CargoID = uc.CargoID
        WHERE uc.EquipeID = ?
    ", 'i', (int)$equipeID) ?: [];
    foreach($rows as $x){
        $uid = (int)$x['UsuarioID']; $sid = (int)$x['StatusID'];
        $t = (int)$x['TipoID']; $l = (int)$x['LocalID'];
        $dim = respFiltroDimensao($t, $l);
        $ex = pesquisarUmComBindParam("
            SELECT ID FROM perfil_status_responsavel
            WHERE PerfisID = ? AND StatusID = ? AND UsuarioID = ? AND Geral = 1".$dim['sql']." LIMIT 1
        ", 'iii'.$dim['tipos'], ...array_merge([$perfilID, $sid, $uid], $dim['args']));
        if(empty($ex['ID'])){
            respInserirLinha($perfilID, $sid, $uid, 1, $t, $l, (int)$x['CargoID']);
        }
    }
}

// Cria/edita um cargo (nome + cor + descrição + valor/demanda + status).
// Devolve o CargoID. $statusIDs = null mantém os status atuais intactos.
function equipeCargoSalvar($equipeID, $cargoID, $nome, $cor, $descricao, $valorDemanda, $statusIDs = null){
    if(!equipeTemCargos()) return 0;
    $equipeID = (int)$equipeID; $cargoID = (int)$cargoID;
    $nome = trim((string)$nome);
    if($nome === '') return 0;
    $cor = trim((string)$cor);
    if($cor !== '' && !preg_match('/^#[0-9a-fA-F]{3,8}$/', $cor)) $cor = '';
    $descricao = trim((string)$descricao);
    // $valorDemanda === false = "não mexe no valor fixo". O editor de cargo só
    // mostra esse campo quando a tabela de preço POR STATUS não existe; sem a
    // sentinela, salvar um cargo pelo editor novo apagaria em silêncio o valor
    // fixo legado de quem ainda depende dele.
    $manterValor = ($valorDemanda === false);
    $valor = ($valorDemanda === '' || $valorDemanda === null) ? null : max(0, (float)$valorDemanda);

    if($cargoID > 0){
        if(!equipeCargoDaEquipe($equipeID, $cargoID)) return 0;
        $antes = equipeCargoStatusIDs($cargoID);
        if($manterValor){
            updateComBindParam("
                UPDATE equipe_cargo SET Nome = ?, Cor = ?, Descricao = ?, DataAtualizado = NOW()
                WHERE ID = ? AND EquipeID = ?
            ", 'sssii', $nome, $cor, $descricao, $cargoID, $equipeID);
        } else {
            updateComBindParam("
                UPDATE equipe_cargo SET Nome = ?, Cor = ?, Descricao = ?, ValorDemanda = ?, DataAtualizado = NOW()
                WHERE ID = ? AND EquipeID = ?
            ", 'sssdii', $nome, $cor, $descricao, $valor, $cargoID, $equipeID);
        }
    } else {
        if($manterValor) $valor = null;
        $ord = pesquisarUmComBindParam(
            "SELECT COALESCE(MAX(Ordem), 0) + 1 AS N FROM equipe_cargo WHERE EquipeID = ?", 'i', $equipeID);
        $cargoID = (int)insertComBindParam("
            INSERT INTO equipe_cargo (EquipeID, Nome, Cor, Descricao, ValorDemanda, Ordem, DataCriado)
            VALUES (?, ?, ?, ?, ?, ?, NOW())
        ", 'isssdi', $equipeID, $nome, $cor, $descricao, $valor, (int)($ord['N'] ?? 1));
        $antes = [];
    }
    if($cargoID <= 0) return 0;

    // $statusIDs aceita as duas formas: lista de IDs (legado) ou lista de REGRAS
    // [['status','tipo','local'], ...] — é o que o editor detalhado manda.
    if(is_array($statusIDs)){
        $temDim = equipeTemCargoDimensao();
        $regras = []; $vistas = [];
        foreach($statusIDs as $r){
            $sid = is_array($r) ? (int)($r['status'] ?? 0) : (int)$r;
            if($sid <= 0) continue;
            $t = ($temDim && is_array($r)) ? max(0, (int)($r['tipo']  ?? 0)) : 0;
            $l = ($temDim && is_array($r)) ? max(0, (int)($r['local'] ?? 0)) : 0;
            $chave = $sid.'-'.$t.'-'.$l;
            if(isset($vistas[$chave])) continue;
            $vistas[$chave] = true;
            $regras[] = ['status'=>$sid, 'tipo'=>$t, 'local'=>$l];
        }

        deleteComBindParam("DELETE FROM equipe_cargo_status WHERE CargoID = ?", 'i', $cargoID);
        foreach($regras as $r){
            $val = pesquisarUmComBindParam("SELECT ID FROM statusPost WHERE ID = ? LIMIT 1", 'i', $r['status']);
            if(empty($val['ID'])) continue;
            if($r['tipo'] > 0){
                $vt = pesquisarUmComBindParam("SELECT ID FROM tipoPost WHERE ID = ? LIMIT 1", 'i', $r['tipo']);
                if(empty($vt['ID'])) $r['tipo'] = 0;
            }
            if($r['local'] > 0){
                $vl = pesquisarUmComBindParam("SELECT ID FROM localPost WHERE ID = ? LIMIT 1", 'i', $r['local']);
                if(empty($vl['ID'])) $r['local'] = 0;
            }
            if($temDim){
                insertComBindParam("INSERT INTO equipe_cargo_status (CargoID, StatusID, TipoID, LocalID) VALUES (?, ?, ?, ?)",
                    'iiii', $cargoID, $r['status'], $r['tipo'], $r['local']);
            } else {
                insertComBindParam("INSERT INTO equipe_cargo_status (CargoID, StatusID) VALUES (?, ?)", 'ii', $cargoID, $r['status']);
            }
        }
        equipeCargoResincronizar($equipeID, $cargoID, $antes);
    }
    return $cargoID;
}

// Soft delete do cargo: desliga a automação de todos os membros dele antes,
// para não deixar responsável fantasma que ninguém sabe de onde veio.
function equipeCargoRemover($equipeID, $cargoID){
    if(!equipeCargoDaEquipe($equipeID, $cargoID)) return false;
    $equipeID = (int)$equipeID; $cargoID = (int)$cargoID;
    $pessoas = pesquisarComBindParam(
        "SELECT UsuarioID FROM equipe_usuario_cargo WHERE EquipeID = ? AND CargoID = ?",
        'ii', $equipeID, $cargoID) ?: [];
    foreach($pessoas as $p){ equipeCargoDesvincular($equipeID, (int)$p['UsuarioID'], $cargoID); }
    updateComBindParam(
        "UPDATE equipe_cargo SET Deletado = 1, DataAtualizado = NOW() WHERE ID = ? AND EquipeID = ?",
        'ii', $cargoID, $equipeID);
    deleteComBindParam("DELETE FROM equipe_cargo_status WHERE CargoID = ?", 'i', $cargoID);
    return true;
}

// ===========================================================================
// MEMBROS EXTERNOS — pagamento por DEMANDA ENTREGUE
// ---------------------------------------------------------------------------
// Externo = não é contratado; recebe por entrega. Uma "execução" é a mesma
// unidade que a tela já chama de AVANÇO: a pessoa moveu um item de etapa para
// frente. Identidade da execução:
//     conteúdo -> (post, etapa de ORIGEM)   · a etapa que ela concluiu
//     tarefa   -> (tarefa, status CONCLUÍDO) · a entrega da tarefa
// Dedupe por essa identidade: reprocessar a mesma etapa do mesmo item (ida e
// volta de retrabalho) não paga duas vezes.
//
// Valor efetivo por demanda: ValorDemanda da pessoa > maior ValorDemanda entre
// os cargos dela > 0 (com 0, a tela diz "sem valor definido" em vez de somar
// um total falso).
// ===========================================================================

// [uid => ['externo'=>bool, 'valor'=>float, 'fonte'=>'membro'|'cargo'|'', 'valorCargo'=>float]]
function equipeValoresPorPessoa($equipeID){
    $out = [];
    if(!equipeTemExternos()) return $out;
    $rows = pesquisarComBindParam(
        "SELECT UsuarioID, Externo, ValorDemanda FROM equipe_usuarios WHERE EquipeID = ?",
        'i', (int)$equipeID) ?: [];
    foreach($rows as $r){
        $out[(int)$r['UsuarioID']] = [
            'externo'    => ((int)$r['Externo'] === 1),
            'valor'      => ($r['ValorDemanda'] === null ? 0.0 : (float)$r['ValorDemanda']),
            'fonte'      => ($r['ValorDemanda'] === null ? '' : 'membro'),
            'valorCargo' => 0.0,
        ];
    }
    if(equipeTemCargos()){
        $cg = pesquisarComBindParam("
            SELECT uc.UsuarioID, MAX(c.ValorDemanda) AS Valor
            FROM equipe_usuario_cargo uc
            INNER JOIN equipe_cargo c ON c.ID = uc.CargoID AND c.Deletado = 0
            WHERE uc.EquipeID = ? AND c.ValorDemanda IS NOT NULL
            GROUP BY uc.UsuarioID
        ", 'i', (int)$equipeID) ?: [];
        foreach($cg as $x){
            $uid = (int)$x['UsuarioID'];
            if(!isset($out[$uid])) continue;
            $out[$uid]['valorCargo'] = (float)$x['Valor'];
            if($out[$uid]['fonte'] === ''){
                $out[$uid]['valor'] = (float)$x['Valor'];
                $out[$uid]['fonte'] = 'cargo';
            }
        }
    }
    return $out;
}

// Execuções (avanços pagáveis) de UMA pessoa no período, com a marcação de
// ignorada já resolvida. Ordenado da mais recente para a mais antiga.
// Cada linha: kind, id, titulo, contexto, etapa, etapaCor, data, ignorado, motivo.
function equipeExecucoesPessoa($equipeID, $uID, $ini, $fim){
    $equipeID = (int)$equipeID; $uID = (int)$uID;
    $perfilIDs = array_map(function($p){ return (int)$p['ID']; }, equipePerfis($equipeID));
    if(empty($perfilIDs) || $uID <= 0) return [];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));

    $itens = [];

    // --- Conteúdo: avanço de etapa (credita a etapa de ORIGEM) ---
    $rows = pesquisarComBindParam("
        SELECT pl.PostID AS ItemID, spDe.ID AS EtapaID, spDe.Titulo AS Etapa, spDe.CorTexto AS EtapaCor,
               MAX(pl.Data) AS Quando,
               pp.Numero, pp.Titulo, pp.PerfisID, p.Nome AS Contexto, tp.Titulo AS TipoNome
        FROM perfis_log pl
        INNER JOIN statusPost spDe   ON UPPER(spDe.Titulo)   = UPPER(pl.De)
        INNER JOIN statusPost spPara ON UPPER(spPara.Titulo) = UPPER(pl.Para)
        INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
        INNER JOIN perfis p ON p.ID = pp.PerfisID
        LEFT JOIN tipoPost tp ON tp.ID = pp.TipoID
        WHERE pl.Campo = 'Status' AND pl.UserID = ?
          AND pl.PerfisID IN ($phP)
          AND pl.Data >= ? AND pl.Data < ?
          AND spPara.Ordem > spDe.Ordem
        GROUP BY pl.PostID, spDe.ID, spDe.Titulo, spDe.CorTexto,
                 pp.Numero, pp.Titulo, pp.PerfisID, p.Nome, tp.Titulo
    ", 'i'.$tpP.'ss', ...array_merge([$uID], $perfilIDs, [$ini, $fim])) ?: [];
    foreach($rows as $r){
        $t = trim((string)$r['Titulo']);
        $itens[] = [
            'kind'=>'conteudo', 'kindID'=>1, 'id'=>(int)$r['ItemID'], 'perfil'=>(int)$r['PerfisID'],
            'titulo'=>($t !== '' ? $t : '#'.(int)$r['Numero']),
            'contexto'=>(string)$r['Contexto'].' · #'.(int)$r['Numero'],
            'tipo'=>(string)($r['TipoNome'] ?? ''),
            'etapaID'=>(int)$r['EtapaID'], 'etapa'=>(string)$r['Etapa'], 'etapaCor'=>((string)$r['EtapaCor'] ?: '#888'),
            'data'=>$r['Quando'],
        ];
    }

    // --- Tarefa: conclusão (status de destino com TipoID = 2) ---
    // O log de tarefa vive em perfis_log com TarefaID (PerfisID fica vazio),
    // então o escopo da marca vem de tarefa_perfis, não do log.
    $rows = pesquisarComBindParam("
        SELECT pl.TarefaID AS ItemID, COALESCE(els.ID, 1) AS EtapaID,
               ".sqlTarefaStatusNome('t', 'els', 'el')." AS Etapa,
               ".sqlTarefaStatusCor('t', 'els')." AS EtapaCor,
               MAX(pl.Data) AS Quando, t.Titulo, t.EspacoID, t.ListaID, e.Titulo AS Contexto
        FROM perfis_log pl
        INNER JOIN tarefa t ON t.ID = pl.TarefaID AND t.Deletado = 0
        INNER JOIN espaco e ON e.ID = t.EspacoID
        INNER JOIN espaco_lista el ON el.ID = t.ListaID
        LEFT JOIN espaco_lista_status els ON els.ListaID = t.ListaID
                                         AND UPPER(els.Titulo) = UPPER(pl.Para)
        WHERE pl.Campo = 'Status' AND pl.UserID = ?
          AND pl.TarefaID > 0
          AND pl.Data >= ? AND pl.Data < ?
          AND ".sqlTarefaLogConcluida('els', 'el', 'pl')."
          AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
        GROUP BY pl.TarefaID, els.ID, els.Titulo, els.Cor, t.StatusID,
                 el.StatusFimTitulo, el.StatusInicioTitulo, t.Titulo, t.EspacoID, t.ListaID, e.Titulo
    ", 'iss'.$tpP, ...array_merge([$uID, $ini, $fim], $perfilIDs)) ?: [];
    foreach($rows as $r){
        $t = trim((string)$r['Titulo']);
        $itens[] = [
            'kind'=>'tarefa', 'kindID'=>2, 'id'=>(int)$r['ItemID'],
            'espaco'=>(int)$r['EspacoID'], 'lista'=>(int)$r['ListaID'],
            'titulo'=>($t !== '' ? $t : 'Tarefa #'.(int)$r['ItemID']),
            'contexto'=>(string)$r['Contexto'].' · tarefa',
            'tipo'=>'',
            'etapaID'=>(int)$r['EtapaID'], 'etapa'=>(string)$r['Etapa'], 'etapaCor'=>((string)$r['EtapaCor'] ?: '#888'),
            'data'=>$r['Quando'],
        ];
    }

    // Marcação "não pagar" — chave (kind, item, etapa), a mesma do unique key.
    $ign = [];
    if(equipeTemExecIgnorada()){
        $rows = pesquisarComBindParam("
            SELECT Kind, ItemID, StatusID, Motivo FROM equipe_execucao_ignorada
            WHERE EquipeID = ? AND UsuarioID = ?
        ", 'ii', $equipeID, $uID) ?: [];
        foreach($rows as $x){
            $ign[(int)$x['Kind'].'|'.(int)$x['ItemID'].'|'.(int)$x['StatusID']] = (string)($x['Motivo'] ?? '');
        }
    }
    // Preço de cada execução: o valor mora no STATUS que ela concluiu (tabela do
    // cargo > tabela da equipe), com o valor fixo antigo como último recurso.
    $tabVal = equipeTabelaValores($equipeID);
    foreach($itens as &$it){
        $k = $it['kindID'].'|'.$it['id'].'|'.$it['etapaID'];
        $it['ignorado'] = isset($ign[$k]);
        $it['motivo']   = $ign[$k] ?? '';
        $v = equipeValorDaExecucao($tabVal, $uID, $it['kindID'], $it['etapaID']);
        $it['valor']      = $v['valor'];
        $it['fonteValor'] = $v['fonte'];
    }
    unset($it);

    usort($itens, function($a, $b){ return strcmp((string)$b['data'], (string)$a['data']); });
    return $itens;
}

// Fechamento do mês por pessoa EXTERNA: quantas execuções, quantas ignoradas e
// quanto pagar. Duas queries de agregação (uma por natureza) + a tabela de
// ignoradas; sem laço de N execuções por pessoa.
//
// Desde que o valor passou a ser POR STATUS, a agregação precisa quebrar por
// (pessoa, kind, status): "20 entregas × R$ 30" virou "8 decupagens × R$ 15 +
// 12 edições × R$ 40". Por isso as ignoradas também vêm quebradas pela mesma
// chave — descontar no total da pessoa daria o desconto no status errado.
// Devolve ['linhas'=>[...], 'total'=>float, 'execucoes'=>int, 'ignoradas'=>int, 'semValor'=>int]
function equipeCustosMes($equipeID, $ano, $mes){
    $equipeID = (int)$equipeID;
    $z = ['linhas'=>[], 'total'=>0.0, 'execucoes'=>0, 'ignoradas'=>0, 'semValor'=>0];
    if(!equipeTemExternos()) return $z;

    $ini = sprintf('%04d-%02d-01 00:00:00', $ano, $mes);
    $pm = $mes + 1; $pa = $ano; if($pm > 12){ $pm = 1; $pa++; }
    $fim = sprintf('%04d-%02d-01 00:00:00', $pa, $pm);

    $pessoas  = equipePessoasDaEquipe($equipeID);
    $valores  = equipeValoresPorPessoa($equipeID);
    $cargosBy = equipeCargosPorUsuario($equipeID);

    $externos = [];
    foreach($pessoas as $uid => $p){
        if(!empty($valores[(int)$uid]['externo'])) $externos[(int)$uid] = $p;
    }
    if(empty($externos)) return $z;

    $perfilIDs = array_map(function($x){ return (int)$x['ID']; }, equipePerfis($equipeID));
    if(empty($perfilIDs)) return $z;
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));
    $uIDs = array_keys($externos);
    $phU = implode(',', array_fill(0, count($uIDs), '?'));
    $tpU = str_repeat('i', count($uIDs));

    // Execuções de conteúdo (distintas por post × etapa de origem), por etapa.
    // exec[uid][kind|status] = quantidade
    $exec = [];
    $rows = pesquisarComBindParam("
        SELECT UID, S, COUNT(*) AS N FROM (
            SELECT DISTINCT pl.UserID AS UID, pl.PostID AS I, spDe.ID AS S
            FROM perfis_log pl
            INNER JOIN statusPost spDe   ON UPPER(spDe.Titulo)   = UPPER(pl.De)
            INNER JOIN statusPost spPara ON UPPER(spPara.Titulo) = UPPER(pl.Para)
            INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
            WHERE pl.Campo = 'Status' AND pl.PerfisID IN ($phP) AND pl.UserID IN ($phU)
              AND pl.Data >= ? AND pl.Data < ? AND spPara.Ordem > spDe.Ordem
        ) a GROUP BY UID, S
    ", $tpP.$tpU.'ss', ...array_merge($perfilIDs, $uIDs, [$ini, $fim])) ?: [];
    foreach($rows as $r){
        $k = equipeValorChave(1, (int)$r['S']);
        $exec[(int)$r['UID']][$k] = ($exec[(int)$r['UID']][$k] ?? 0) + (int)$r['N'];
    }

    // Execuções de tarefa (distintas por tarefa × status concluído). Todas caem
    // na mesma chave de preço (kind 2): não há catálogo global de status de lista.
    $rows = pesquisarComBindParam("
        SELECT UID, COUNT(*) AS N FROM (
            SELECT DISTINCT pl.UserID AS UID, pl.TarefaID AS I, COALESCE(els.ID, 1) AS S
            FROM perfis_log pl
            INNER JOIN tarefa t ON t.ID = pl.TarefaID AND t.Deletado = 0
            INNER JOIN espaco_lista el ON el.ID = t.ListaID
            LEFT JOIN espaco_lista_status els ON els.ListaID = t.ListaID
                                             AND UPPER(els.Titulo) = UPPER(pl.Para)
            WHERE pl.Campo = 'Status' AND pl.TarefaID > 0 AND pl.UserID IN ($phU)
              AND pl.Data >= ? AND pl.Data < ?
              AND ".sqlTarefaLogConcluida('els', 'el', 'pl')."
              AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
        ) a GROUP BY UID
    ", $tpU.'ss'.$tpP, ...array_merge($uIDs, [$ini, $fim], $perfilIDs)) ?: [];
    foreach($rows as $r){
        $k = equipeValorChave(2, 0);
        $exec[(int)$r['UID']][$k] = ($exec[(int)$r['UID']][$k] ?? 0) + (int)$r['N'];
    }

    // Ignoradas do mês, pela MESMA chave de preço (AnoMes fixa o fechamento:
    // navegar de mês não muda o passado).
    $ign = [];
    if(equipeTemExecIgnorada()){
        $anoMes = sprintf('%04d-%02d', $ano, $mes);
        $rows = pesquisarComBindParam("
            SELECT UsuarioID, Kind, StatusID, COUNT(*) AS N FROM equipe_execucao_ignorada
            WHERE EquipeID = ? AND AnoMes = ? AND UsuarioID IN ($phU)
            GROUP BY UsuarioID, Kind, StatusID
        ", 'is'.$tpU, ...array_merge([$equipeID, $anoMes], $uIDs)) ?: [];
        foreach($rows as $r){
            $k = equipeValorChave((int)$r['Kind'], (int)$r['StatusID']);
            $ign[(int)$r['UsuarioID']][$k] = ($ign[(int)$r['UsuarioID']][$k] ?? 0) + (int)$r['N'];
        }
    }

    $tabVal   = equipeTabelaValores($equipeID);
    $rotulos  = [];
    foreach(equipeLinhasPrecificaveis() as $l) $rotulos[$l['chave']] = $l;

    $linhas = [];
    foreach($externos as $uid => $p){
        $uid = (int)$uid;
        $n = 0; $ni = 0; $pag = 0; $tot = 0.0;
        $porStatus = []; $vMin = null; $vMax = null; $semValorPessoa = false;
        foreach(($exec[$uid] ?? []) as $chave => $qtd){
            list($kind, $sid) = array_map('intval', explode('|', $chave));
            $qi   = min((int)$qtd, (int)($ign[$uid][$chave] ?? 0));
            $qp   = max(0, (int)$qtd - $qi);
            $v    = equipeValorDaExecucao($tabVal, $uid, $kind, $sid);
            $sub  = $qp * $v['valor'];
            $n   += (int)$qtd; $ni += $qi; $pag += $qp; $tot += $sub;
            if($qp > 0){
                if($v['valor'] <= 0) $semValorPessoa = true;
                if($vMin === null || $v['valor'] < $vMin) $vMin = $v['valor'];
                if($vMax === null || $v['valor'] > $vMax) $vMax = $v['valor'];
            }
            $porStatus[] = [
                'chave'=>$chave, 'kind'=>$kind, 'statusID'=>$sid,
                'nome'=>(string)($rotulos[$chave]['nome'] ?? ('Etapa #'.$sid)),
                'cor'=>(string)($rotulos[$chave]['cor'] ?? '#888'),
                'execucoes'=>(int)$qtd, 'ignoradas'=>$qi, 'pagaveis'=>$qp,
                'valor'=>$v['valor'], 'fonte'=>$v['fonte'], 'total'=>$sub,
            ];
        }
        usort($porStatus, function($a, $b){ return $b['pagaveis'] - $a['pagaveis']; });
        $linhas[] = [
            'uid'=>$uid, 'p'=>$p, 'cargos'=>($cargosBy[$uid] ?? []),
            'execucoes'=>$n, 'ignoradas'=>$ni, 'pagaveis'=>$pag, 'total'=>$tot,
            'valorMin'=>(float)($vMin ?? 0), 'valorMax'=>(float)($vMax ?? 0),
            'porStatus'=>$porStatus,
        ];
        $z['execucoes'] += $n;
        $z['ignoradas'] += $ni;
        $z['total']     += $tot;
        if($semValorPessoa) $z['semValor']++;
    }
    usort($linhas, function($a, $b){
        if($b['total'] != $a['total']) return ($b['total'] < $a['total']) ? -1 : 1;
        return $b['pagaveis'] - $a['pagaveis'];
    });
    $z['linhas'] = $linhas;
    return $z;
}

// Recorte de dimensão (TipoID/LocalID) para os WHERE de escrita. Toda gravação
// mexe SÓ na regra que está sendo editada: salvar "Criação · Vídeo" não pode
// encostar em "Criação" nem em "Criação · Arte". Sem a migration o recorte some
// e sobra o comportamento de hoje (a regra genérica é a única que existe).
function respFiltroDimensao($tipoID, $localID){
    if(!respStatusTemDimensao()) return ['sql'=>'', 'tipos'=>'', 'args'=>[]];
    return ['sql'=>' AND TipoID = ? AND LocalID = ?', 'tipos'=>'ii', 'args'=>[(int)$tipoID, (int)$localID]];
}

// A aba Responsáveis só manda nas regras MANUAIS (CargoID = 0). O que veio de
// cargo é ajustado no cargo — se a aba pudesse regravar aquilo, ela apagaria a
// autoria e a próxima sincronização do cargo duplicaria tudo.
function respFiltroManual(){
    return equipeTemCargoDimensao() ? ' AND CargoID = 0' : '';
}

// INSERT de uma linha de responsável já com as dimensões (quando existirem).
function respInserirLinha($perfilID, $statusCfgID, $usuarioID, $geral, $tipoID = 0, $localID = 0, $cargoID = 0){
    if(equipeTemCargoDimensao()){
        return insertComBindParam("
            INSERT INTO perfil_status_responsavel (PerfisID, StatusID, UsuarioID, Geral, TipoID, LocalID, CargoID)
            VALUES (?, ?, ?, ?, ?, ?, ?)
        ", 'iiiiiii', (int)$perfilID, (int)$statusCfgID, (int)$usuarioID, (int)$geral, (int)$tipoID, (int)$localID, (int)$cargoID);
    }
    if(respStatusTemDimensao()){
        return insertComBindParam("
            INSERT INTO perfil_status_responsavel (PerfisID, StatusID, UsuarioID, Geral, TipoID, LocalID)
            VALUES (?, ?, ?, ?, ?, ?)
        ", 'iiiiii', (int)$perfilID, (int)$statusCfgID, (int)$usuarioID, (int)$geral, (int)$tipoID, (int)$localID);
    }
    return insertComBindParam("
        INSERT INTO perfil_status_responsavel (PerfisID, StatusID, UsuarioID, Geral) VALUES (?, ?, ?, ?)
    ", 'iiii', (int)$perfilID, (int)$statusCfgID, (int)$usuarioID, (int)$geral);
}

// Substitui as regras de responsável GERAIS (Geral=1) de um status em TODAS as marcas
// da equipe. $regras = [['uid','tipo','local'], ...] — cada item é uma regra
// (Status, Usuário, Tipo, Canal); tipo/local em 0 = "todos". Lista vazia limpa o status.
// Não toca os overrides por perfil (Geral=0), que ficam preservados.
function equipeAplicarResponsavelStatus($equipeID, $statusCfgID, $regras){
    $statusCfgID = (int)$statusCfgID;
    $sp = pesquisarUmComBindParam("SELECT ID FROM statusPost WHERE ID = ? LIMIT 1", 'i', $statusCfgID);
    if(empty($sp['ID'])) return false;
    $regras = respRegrasNormalizar($regras);
    $perfis = equipePerfis($equipeID);
    foreach($perfis as $p){
        $pid = (int)$p['ID'];
        // UsuarioID > 0 preserva a sentinela "cliente da marca"; Geral = 1 preserva o
        // override por perfil; CargoID = 0 preserva o que veio de cargo.
        deleteComBindParam(
            "DELETE FROM perfil_status_responsavel WHERE PerfisID = ? AND StatusID = ? AND UsuarioID > 0 AND Geral = 1".respFiltroManual(),
            'ii', $pid, $statusCfgID);
        foreach($regras as $r){
            respInserirLinha($pid, $statusCfgID, $r['uid'], 1, $r['tipo'], $r['local'], 0);
        }
    }
    return true;
}

// Define as regras de responsável (Geral=0) de um status NUM perfil específico.
// $regras vazio = override ATIVO porém vazio (a marca não tem responsável neste status)
// — grava a sentinela STATUS_RESP_VAZIO. Para a marca VOLTAR A HERDAR o Geral use
// equipeReverterResponsavelStatusPerfil.
function equipeAplicarResponsavelStatusPerfil($perfilID, $statusCfgID, $regras){
    $perfilID = (int)$perfilID; $statusCfgID = (int)$statusCfgID;
    if($perfilID <= 0) return false;
    $sp = pesquisarUmComBindParam("SELECT ID FROM statusPost WHERE ID = ? LIMIT 1", 'i', $statusCfgID);
    if(empty($sp['ID'])) return false;
    // Limpa o override anterior por completo (usuários + sentinela de vazio).
    deleteComBindParam("DELETE FROM perfil_status_responsavel WHERE PerfisID = ? AND StatusID = ? AND Geral = 0", 'ii', $perfilID, $statusCfgID);
    $regras = respRegrasNormalizar($regras);
    if(count($regras) > 0){
        foreach($regras as $r){ respInserirLinha($perfilID, $statusCfgID, $r['uid'], 0, $r['tipo'], $r['local']); }
    } else {
        respInserirLinha($perfilID, $statusCfgID, STATUS_RESP_VAZIO, 0, 0, 0);
    }
    return true;
}

// "Destacar do cargo": as regras que o CARGO deu a esta pessoa NESTE status
// viram manuais (CargoID = 0). A partir daí a aba Responsáveis manda nelas e o
// cargo não as reescreve mais — é a saída para "quase todo mundo segue o cargo,
// menos fulano neste status", sem ter de tirar a pessoa do cargo.
function equipeDestacarResponsavelDoCargo($equipeID, $statusCfgID, $usuarioID){
    if(!equipeTemCargoDimensao()) return false;
    $statusCfgID = (int)$statusCfgID; $usuarioID = (int)$usuarioID;
    if($statusCfgID <= 0 || $usuarioID <= 0) return false;
    foreach(equipePerfis($equipeID) as $p){
        updateComBindParam("
            UPDATE perfil_status_responsavel SET CargoID = 0
            WHERE PerfisID = ? AND StatusID = ? AND UsuarioID = ? AND CargoID > 0
        ", 'iii', (int)$p['ID'], $statusCfgID, $usuarioID);
    }
    return true;
}

// Remove o override de um status num perfil — volta a herdar a config Geral.
function equipeReverterResponsavelStatusPerfil($perfilID, $statusCfgID){
    $perfilID = (int)$perfilID; $statusCfgID = (int)$statusCfgID;
    if($perfilID <= 0) return false;
    deleteComBindParam("DELETE FROM perfil_status_responsavel WHERE PerfisID = ? AND StatusID = ? AND Geral = 0", 'ii', $perfilID, $statusCfgID);
    return true;
}

// Ao adicionar uma marca novo à equipe, herda a config de responsáveis vigente
// (intersecção dos demais perfis da equipe) para o nova marca.
function equipeCopiarResponsaveisParaPerfil($equipeID, $novoPerfilID){
    $novoPerfilID = (int)$novoPerfilID;
    $perfis = equipePerfis($equipeID);
    $outros = [];
    foreach($perfis as $p){ if((int)$p['ID'] !== $novoPerfilID) $outros[] = (int)$p['ID']; }
    $n = count($outros);
    if($n === 0) return;
    $temDim = respStatusTemDimensao();
    $colDim = $temDim ? 'TipoID, LocalID' : '0 AS TipoID, 0 AS LocalID';
    $grpDim = $temDim ? ', TipoID, LocalID' : '';
    $ph = implode(',', array_fill(0, $n, '?'));
    $rows = pesquisarComBindParam(
        "SELECT StatusID, UsuarioID, $colDim, COUNT(*) AS N
         FROM perfil_status_responsavel
         WHERE PerfisID IN ($ph) AND Geral = 1
         GROUP BY StatusID, UsuarioID$grpDim", str_repeat('i', $n), ...$outros) ?: [];
    foreach($rows as $x){
        if((int)$x['N'] < $n) continue; // só o que está em TODOS os outros perfis (a config Geral)
        $dim = respFiltroDimensao((int)$x['TipoID'], (int)$x['LocalID']);
        $ex = pesquisarUmComBindParam(
            "SELECT ID FROM perfil_status_responsavel
             WHERE PerfisID = ? AND StatusID = ? AND UsuarioID = ? AND Geral = 1".$dim['sql']." LIMIT 1",
            'iii'.$dim['tipos'], ...array_merge([$novoPerfilID, (int)$x['StatusID'], (int)$x['UsuarioID']], $dim['args']));
        if(empty($ex['ID'])){
            respInserirLinha($novoPerfilID, (int)$x['StatusID'], (int)$x['UsuarioID'], 1, (int)$x['TipoID'], (int)$x['LocalID']);
        }
    }
}

// ===========================================================================
// VALOR POR DEMANDA — AGORA É POR STATUS
// ---------------------------------------------------------------------------
// Antes o externo tinha UM valor fixo por demanda: entregar a decupagem valia
// o mesmo que entregar a edição final. Isso não é o que a operação combina com
// o freelancer — cada etapa tem um preço. Agora o preço mora no STATUS:
//
//   equipe_status_valor(EquipeID, CargoID, Kind, StatusID, Valor)
//     CargoID = 0  -> tabela da EQUIPE  (vale para todo mundo)
//     CargoID > 0  -> tabela do CARGO   (sobrepõe a da equipe p/ quem tem o cargo)
//     Kind    = 1  -> status de CONTEÚDO (statusPost.ID)
//     Kind    = 2  -> conclusão de TAREFA (StatusID sempre 0: a lista de status
//                     é por lista de espaço, não há um catálogo global p/ precificar)
//
// Resolução do valor de UMA execução (pessoa, kind, status), nesta ordem:
//   1. maior valor entre os CARGOS da pessoa que precificam aquele status
//   2. valor da EQUIPE para aquele status
//   3. ValorDemanda da própria pessoa      (compatibilidade: valor fixo antigo)
//   4. maior ValorDemanda entre os cargos  (compatibilidade)
//   5. 0  -> a tela diz "definir" em vez de somar um total falso
//
// Migration manual: BIN/migrations/2026-07-30_equipe_valor_status.sql — como
// todo o resto da equipe, a feature degrada sozinha se ela não rodou.
// ===========================================================================

function equipeTemValorStatus(){
    return equipeTemEstrutura('equipe_status_valor');
}

// Chave canônica de uma etapa pagável. Kind 2 (tarefa) colapsa em um preço só:
// os status de tarefa vivem por lista de espaço, não há catálogo global.
function equipeValorChave($kind, $statusID){
    $kind = (int)$kind === 2 ? 2 : 1;
    return $kind.'|'.($kind === 2 ? 0 : (int)$statusID);
}

// Tabela de preços da equipe.
// ['equipe' => [chave => valor], 'cargo' => [cargoID => [chave => valor]]]
function equipeValoresStatus($equipeID){
    $out = ['equipe'=>[], 'cargo'=>[]];
    if(!equipeTemValorStatus()) return $out;
    $rows = pesquisarComBindParam(
        "SELECT CargoID, Kind, StatusID, Valor FROM equipe_status_valor WHERE EquipeID = ?",
        'i', (int)$equipeID) ?: [];
    foreach($rows as $r){
        $k = equipeValorChave($r['Kind'], $r['StatusID']);
        $c = (int)$r['CargoID'];
        if($c === 0) $out['equipe'][$k] = (float)$r['Valor'];
        else         $out['cargo'][$c][$k] = (float)$r['Valor'];
    }
    return $out;
}

// Grava (ou apaga, com valor vazio) o preço de um status. $cargoID = 0 é a
// tabela da equipe. Idempotente — a UI salva a cada blur do campo.
function equipeValorStatusSalvar($equipeID, $cargoID, $kind, $statusID, $valor){
    if(!equipeTemValorStatus()) return false;
    $equipeID = (int)$equipeID; $cargoID = (int)$cargoID;
    $kind = (int)$kind === 2 ? 2 : 1;
    $statusID = $kind === 2 ? 0 : (int)$statusID;
    if($equipeID <= 0) return false;
    if($cargoID > 0 && !equipeCargoDaEquipe($equipeID, $cargoID)) return false;
    if($kind === 1){
        $sp = pesquisarUmComBindParam("SELECT ID FROM statusPost WHERE ID = ? LIMIT 1", 'i', $statusID);
        if(empty($sp['ID'])) return false;
    }
    // Campo vazio = "não precifica este status aqui" -> some da tabela e volta
    // a herdar (cargo -> equipe -> valor fixo). Zerar não é o mesmo que apagar.
    $limpar = ($valor === '' || $valor === null);
    if($limpar){
        deleteComBindParam("
            DELETE FROM equipe_status_valor
            WHERE EquipeID = ? AND CargoID = ? AND Kind = ? AND StatusID = ?
        ", 'iiii', $equipeID, $cargoID, $kind, $statusID);
        return true;
    }
    $v = max(0, (float)$valor);
    $ex = pesquisarUmComBindParam("
        SELECT ID FROM equipe_status_valor
        WHERE EquipeID = ? AND CargoID = ? AND Kind = ? AND StatusID = ? LIMIT 1
    ", 'iiii', $equipeID, $cargoID, $kind, $statusID);
    if(!empty($ex['ID'])){
        updateComBindParam("UPDATE equipe_status_valor SET Valor = ?, DataAtualizado = NOW() WHERE ID = ?",
            'di', $v, (int)$ex['ID']);
    } else {
        insertComBindParam("
            INSERT INTO equipe_status_valor (EquipeID, CargoID, Kind, StatusID, Valor, DataAtualizado)
            VALUES (?, ?, ?, ?, ?, NOW())
        ", 'iiiid', $equipeID, $cargoID, $kind, $statusID, $v);
    }
    return true;
}

// Tabela pronta para precificar execução por execução, sem ida ao banco dentro
// do laço: [uid => ['fixo'=>float, 'fonteFixo'=>string, 'externo'=>bool, 'porStatus'=>[chave=>valor]]].
function equipeTabelaValores($equipeID){
    $valores  = equipeValoresPorPessoa($equipeID);
    $precos   = equipeValoresStatus($equipeID);
    $cargosBy = equipeCargosPorUsuario($equipeID);

    $tab = [];
    $uids = array_unique(array_merge(array_keys($valores), array_keys($cargosBy)));
    foreach($uids as $uid){
        $uid = (int)$uid;
        $porStatus = $precos['equipe'];
        // Cargo sobrepõe a equipe; entre cargos da mesma pessoa vence o MAIOR
        // (ela exerce os dois papéis, e o papel mais caro é o que ela entregou).
        foreach(($cargosBy[$uid] ?? []) as $c){
            foreach(($precos['cargo'][(int)$c['ID']] ?? []) as $k => $v){
                if(!isset($porStatus[$k]) || $v > $porStatus[$k]) $porStatus[$k] = (float)$v;
            }
        }
        $tab[$uid] = [
            'externo'   => !empty($valores[$uid]['externo']),
            'fixo'      => (float)($valores[$uid]['valor'] ?? 0),
            'fonteFixo' => (string)($valores[$uid]['fonte'] ?? ''),
            'porStatus' => $porStatus,
        ];
    }
    return $tab;
}

// Preço de UMA execução. Devolve ['valor'=>float, 'fonte'=>'status'|'fixo'|''].
function equipeValorDaExecucao($tab, $uid, $kind, $statusID){
    $t = $tab[(int)$uid] ?? null;
    if(!$t) return ['valor'=>0.0, 'fonte'=>''];
    $k = equipeValorChave($kind, $statusID);
    if(isset($t['porStatus'][$k])) return ['valor'=>(float)$t['porStatus'][$k], 'fonte'=>'status'];
    if($t['fixo'] > 0)             return ['valor'=>(float)$t['fixo'], 'fonte'=>'fixo'];
    return ['valor'=>0.0, 'fonte'=>''];
}

// Catálogo de linhas precificáveis da tela de valores: os status de conteúdo
// (na ordem do fluxo) + a linha única de tarefa.
function equipeLinhasPrecificaveis(){
    $sts = pesquisarComBindParam(
        "SELECT ID, Titulo, CorTexto FROM statusPost WHERE ID > ? ORDER BY Ordem ASC", 'i', 0) ?: [];
    $out = [];
    foreach($sts as $s){
        $out[] = ['kind'=>1, 'statusID'=>(int)$s['ID'], 'nome'=>(string)$s['Titulo'],
                  'cor'=>((string)$s['CorTexto'] ?: '#888'),
                  'chave'=>equipeValorChave(1, (int)$s['ID'])];
    }
    $out[] = ['kind'=>2, 'statusID'=>0, 'nome'=>'Tarefa concluída', 'cor'=>'#8a8f98',
              'chave'=>equipeValorChave(2, 0)];
    return $out;
}

// ===========================================================================
// NORMALIZADORES — as duas naturezas viram UM vocabulário para o JS
// ===========================================================================

// Converte linhas cruas de conteúdo/tarefa no item que window.Equipes desenha
// (card .ppd). Toda query que alimenta um drill-down passa por aqui.
function equipeItensParaJson($itens){
    if(empty($itens)) return [];
    $resp = equipeResponsaveisDosItens($itens);
    $out  = [];
    foreach($itens as $it){
        $ehTarefa = ((string)($it['Kind'] ?? 'conteudo') === 'tarefa');
        $id     = (int)$it['ID'];
        $feito  = ((int)($it['Feito'] ?? 0) === 1);
        $quando = (string)($it['Quando'] ?? '');
        $atras  = (!$feito && $quando !== '' && strtotime($quando) < time());
        $lst    = $resp[$ehTarefa ? 'tarefa' : 'conteudo'][$id] ?? [];
        $titulo = trim((string)($it['Titulo'] ?? ''));
        if($titulo === ''){
            $titulo = $ehTarefa ? ('Tarefa #'.$id) : ('Conteúdo #'.(int)($it['Numero'] ?? 0));
        }
        $out[] = [
            'kind'     => $ehTarefa ? 'tarefa' : 'conteudo',
            'id'       => $id,
            'numero'   => (int)($it['Numero'] ?? 0),
            'titulo'   => $titulo,
            'perfil'   => (int)($it['PerfisID'] ?? 0),
            'espaco'   => (int)($it['EspacoID'] ?? 0),
            'lista'    => (int)($it['ListaID'] ?? 0),
            'contexto' => (string)($it['Cliente'] ?? ''),
            'foto'     => $ehTarefa ? '' : imagemDoPerfil($it),
            'subtipo'  => $ehTarefa ? trim((string)($it['ListaNome'] ?? '')) : trim((string)($it['TipoNome'] ?? '')),
            'status'   => (string)($it['StatusNome'] ?? ''),
            'cor'      => (string)($it['StatusCor'] ?? ''),
            'feito'    => $feito,
            'atrasado' => $atras,
            'quando'   => $quando,
            'thumb'    => (string)($it['Thumb'] ?? ''),
            'thumbExt' => strtolower(trim((string)($it['ThumbExt'] ?? ''))),
            'canais'   => $ehTarefa ? [] : equipeCanaisDoPost($it),
            'resp'     => array_map(function($u){
                return ['id'=>(int)$u['ID'], 'nome'=>(string)$u['Nome'], 'img'=>(string)$u['ImagemPerfil']];
            }, $lst),
        ];
    }
    return $out;
}

// Composição da lista (o que o header do sub-popup e a barra de filtro leem).
function equipeItensResumo($itens){
    $z = ['total'=>count($itens), 'conteudos'=>0, 'tarefas'=>0, 'atrasados'=>0, 'feitos'=>0];
    foreach($itens as $it){
        if(($it['kind'] ?? 'conteudo') === 'tarefa') $z['tarefas']++; else $z['conteudos']++;
        if(!empty($it['feito']))    $z['feitos']++;
        if(!empty($it['atrasado'])) $z['atrasados']++;
    }
    return $z;
}

// Pessoa no formato que o JS usa em toda tela (avatar + cargos).
function equipePessoaJson($p, $cargos = []){
    return [
        'id'      => (int)($p['ID'] ?? 0),
        'nome'    => (string)($p['Nome'] ?? ''),
        'img'     => (string)($p['ImagemPerfil'] ?? ''),
        'isAdmin' => (int)($p['IsAdmin'] ?? 0) === 1,
        'ehDono'  => !empty($p['ehDono']),
        'podeConfig' => !empty($p['podeConfig']),
        'orfa'    => !empty($p['orfa']),
        'cargos'  => array_values($cargos),
    ];
}

// Marca no formato do chip/linha de configuração.
function equipePerfilJson($p){
    $local = trim((string)($p['Cidade'] ?? '').((!empty($p['Cidade']) && !empty($p['UF'])) ? '/' : '').(string)($p['UF'] ?? ''));
    return [
        'id'       => (int)$p['ID'],
        'nome'     => (string)$p['Nome'],
        'local'    => $local,
        'foto'     => imagemDoPerfil($p),
        'iniciais' => iniciaisPerfil($p),
    ];
}

// Erro/aviso padronizados: o JS tem um só caminho de tratamento.
function equipeErro($msg){   return ['ok'=>0, 'erro'=>$msg]; }
function equipeAviso($msg){  return ['ok'=>1, 'aviso'=>$msg]; }

// Escopo (marcas) da equipe resolvido uma vez por render.
function equipeEscopo($equipeID){
    $perfis = equipePerfis($equipeID) ?: [];
    return ['perfis'=>$perfis, 'ids'=>array_map(function($p){ return (int)$p['ID']; }, $perfis)];
}

// Normaliza ano/mês de qualquer painel navegável e devolve a janela do mês.
function equipeJanelaMes($ano, $mes){
    $ano = (int)$ano; $mes = (int)$mes;
    if($ano < 2000 || $ano > 2100) $ano = (int)date('Y');
    if($mes < 1 || $mes > 12)      $mes = (int)date('n');
    $ini = sprintf('%04d-%02d-01 00:00:00', $ano, $mes);
    $pm = $mes + 1; $pa = $ano; if($pm > 12){ $pm = 1; $pa++; }
    return ['ano'=>$ano, 'mes'=>$mes, 'ini'=>$ini, 'fim'=>sprintf('%04d-%02d-01 00:00:00', $pa, $pm)];
}

// ===========================================================================
// DADOS DAS TELAS  (tudo abaixo devolve array pronto para json_encode)
// ===========================================================================

// ---- Casco da equipe: abas, marcas, membros, administradores ----------------
function equipeDadosPopup($equipeID, $userID){
    $eq = equipeBuscar($equipeID);
    if(empty($eq)) return equipeErro('Equipe não encontrada.');

    $ehDono        = ((int)$eq['UsuarioID'] === (int)$userID);
    $podeGerenciar = equipePodeGerenciar($equipeID, $userID);
    // Self-heal de equipe antiga: o dono nascia SEM linha em equipe_usuarios e
    // por isso não podia receber cargo, capacidade nem valor por demanda. Aqui é
    // o ponto de entrada único da tela e a operação é idempotente. Precisa vir
    // ANTES de equipeMembros() para ele já aparecer nesta mesma resposta.
    if($podeGerenciar) equipeGarantirVinculoDono($equipeID);

    $perfis   = equipePerfis($equipeID) ?: [];
    $membros  = equipeMembros($equipeID) ?: [];
    $cargosBy = equipeCargosPorUsuario($equipeID);
    $donoID   = (int)$eq['UsuarioID'];

    $lstMembros = []; $lstAdmins = []; $totalExternos = 0;
    foreach($membros as $m){
        $uid   = (int)$m['UsuarioID'];
        $admin = ((int)$m['IsAdmin'] === 1);
        $total = (int)$m['TotalPerfis'];
        $lib   = (int)$m['PerfisLiberados'];
        $item  = [
            'id'        => $uid,
            'vinculoID' => (int)$m['ID'],
            'nome'      => (string)$m['Nome'],
            'email'     => (string)$m['Email'],
            'img'       => (string)$m['ImagemPerfil'],
            'isAdmin'   => $admin,
            'ehDono'    => ($uid === $donoID),
            'externo'   => ((int)($m['Externo'] ?? 0) === 1),
            'totalPerfis'     => $total,
            'perfisLiberados' => $lib,
            // "Acesso total" = tipo total E de fato com todas as marcas liberadas.
            'acessoTotal'     => (((int)$m['TipoAcesso'] === 0) && $lib >= $total),
            'cargos'    => array_values($cargosBy[$uid] ?? []),
        ];
        $lstMembros[] = $item;
        if($admin) $lstAdmins[] = $item;
        if($item['externo']) $totalExternos++;
    }

    return [
        'ok'            => 1,
        'id'            => (int)$equipeID,
        'nome'          => (string)$eq['Nome'],
        'ehDono'        => $ehDono,
        'podeGerenciar' => $podeGerenciar,
        'temCargos'     => equipeTemCargos(),
        'totalCargos'   => count(equipeCargos($equipeID)),
        'totalExternos' => $totalExternos,
        'perfis'        => array_map('equipePerfilJson', $perfis),
        'membros'       => $lstMembros,
        'admins'        => $lstAdmins,
    ];
}

// ---- Aba "Visão geral" -----------------------------------------------------
// Ordem pensada como um gestor lê a operação: fechamento do mês -> números de
// agora -> fila do que precisa dele -> carga e ritmo -> tabela -> pipeline.
function equipeDadosDashboard($equipeID, $userID, $ano = 0, $mes = 0){
    if(!equipePodeGerenciar($equipeID, $userID))
        return equipeErro('Sem permissão para ver o painel desta equipe.');

    $esc = equipeEscopo($equipeID);
    if(empty($esc['ids']))
        return equipeAviso('Adicione marcas à equipe (aba <b>Configurações › Marcas</b>) para ver o painel.');

    $perfilIDs = $esc['ids'];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));

    $pessoas = equipePessoasDaEquipe($equipeID);
    $uIDs    = array_keys($pessoas);
    $totais  = equipeDemandaTotais($perfilIDs);
    $carga   = equipeCargaPorPessoa($perfilIDs, $uIDs);
    $capInfo = equipeCapacidade($equipeID, $perfilIDs, $uIDs);
    $riscos  = equipeRiscos($perfilIDs);
    $serie   = equipeSerieSemanal($perfilIDs, 8);

    $j      = equipeJanelaMes($ano, $mes);
    $fech   = equipeFechamentoMes($equipeID, $perfilIDs, $j['ano'], $j['mes']);
    $fluxoM = equipeFluxo($equipeID, $j['ini'], $j['fim']);
    $retrabalhoMes = 0;
    foreach($fluxoM['porStatus'] as $st){ $retrabalhoMes += (int)($st['retrabalho'] ?? 0); }

    // Linhas por pessoa (carga + utilização), já ordenadas por quem está mais cheio.
    $linhas = [];
    foreach($pessoas as $uid => $p){
        $uid = (int)$uid;
        $c   = $carga[$uid] ?? ['wip'=>0,'hoje'=>0,'semana'=>0,'atrasados'=>0,'parados'=>0,'ultimo'=>null,'conteudos'=>0,'tarefas'=>0];
        $cap = (int)($capInfo['cap'][$uid] ?? $capInfo['sugerida']);
        $u   = equipeFaixaUtilizacao($c['semana'], $cap);
        $linhas[] = [
            'uid'=>$uid, 'nome'=>(string)$p['Nome'], 'img'=>(string)$p['ImagemPerfil'],
            'wip'=>(int)$c['wip'], 'hoje'=>(int)$c['hoje'], 'semana'=>(int)$c['semana'],
            'atrasados'=>(int)$c['atrasados'], 'parados'=>(int)$c['parados'],
            'conteudos'=>(int)$c['conteudos'], 'tarefas'=>(int)$c['tarefas'],
            'ultimo'=>$c['ultimo'], 'ultimoTs'=>($c['ultimo'] ? strtotime($c['ultimo']) : 0),
            'cap'=>$cap, 'pct'=>$u['pct'], 'faixa'=>$u['faixa'],
        ];
    }
    usort($linhas, function($a, $b){
        if($b['pct'] !== $a['pct']) return $b['pct'] - $a['pct'];
        return $b['wip'] - $a['wip'];
    });

    // Pipeline (onde o trabalho está) — com destaque do gargalo.
    $pipeline = pesquisarComBindParam("
        SELECT sp.ID, sp.Titulo, sp.CorTexto, sp.Ordem,
          COUNT(DISTINCT pp.ID) AS Qtd,
          COUNT(DISTINCT CASE WHEN pp.DataAtualizado < DATE_SUB(NOW(), INTERVAL 3 DAY) THEN pp.ID END) AS Parados
        FROM perfis_posts pp
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
        GROUP BY sp.ID, sp.Titulo, sp.CorTexto, sp.Ordem
        ORDER BY sp.Ordem ASC
    ", $tp, ...$perfilIDs) ?: [];
    // Gargalo = a etapa que mais SEGURA trabalho: peso 10 em "parados", volume desempata.
    $gargaloID = 0; $gargaloScore = -1;
    foreach($pipeline as $s){
        $score = ((int)$s['Parados'] * 10) + (int)$s['Qtd'];
        if($score > $gargaloScore){ $gargaloScore = $score; $gargaloID = (int)$s['ID']; }
    }
    $lstPipeline = [];
    foreach($pipeline as $s){
        $lstPipeline[] = [
            'id'=>(int)$s['ID'], 'nome'=>(string)$s['Titulo'], 'cor'=>((string)$s['CorTexto'] ?: '#888'),
            'qtd'=>(int)$s['Qtd'], 'parados'=>(int)$s['Parados'],
            'gargalo'=>((int)$s['ID'] === $gargaloID && (int)$s['Parados'] > 0),
        ];
    }

    // Carga por marca (com atrasados, p/ saber qual cliente está em risco).
    $porCliente = pesquisarComBindParam("
        SELECT p.ID, p.Nome, COUNT(DISTINCT pp.ID) AS Qtd,
          COUNT(DISTINCT CASE WHEN pp.DataAgendado IS NOT NULL AND pp.DataAgendado < NOW() THEN pp.ID END) AS Atrasados
        FROM perfis_posts pp
        INNER JOIN perfis p ON p.ID = pp.PerfisID
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
        GROUP BY p.ID, p.Nome
        ORDER BY Qtd DESC
    ", $tp, ...$perfilIDs) ?: [];

    $clientes = [];
    foreach(equipeClientesPendentes($perfilIDs) as $c){
        $clientes[] = [
            'id'=>(int)$c['ID'], 'nome'=>(string)$c['Nome'], 'img'=>(string)$c['ImagemPerfil'],
            'perfilID'=>(int)$c['PerfilID'], 'perfilNome'=>(string)$c['PerfilNome'],
            'pendentes'=>(int)$c['Pendentes'],
        ];
    }

    return [
        'ok'        => 1,
        'equipeID'  => (int)$equipeID,
        'ano'       => $j['ano'],
        'mes'       => $j['mes'],
        'fechamento'=> $fech,
        'retrabalho'=> $retrabalhoMes,
        'totais'    => $totais,
        'riscos'    => $riscos,
        'linhas'    => $linhas,
        'capSugerida'=> (int)$capInfo['sugerida'],
        'serie'     => $serie,
        'pipeline'  => $lstPipeline,
        'porCliente'=> array_map(function($c){
            return ['nome'=>(string)$c['Nome'], 'qtd'=>(int)$c['Qtd'], 'atrasados'=>(int)$c['Atrasados']];
        }, $porCliente),
        'clientes'  => $clientes,
    ];
}

// ---- Aba "Agenda": demanda por pessoa e por dia ----------------------------
// $dataIni 'Y-m-d' (padrão hoje) · $dias 7|15|30 · $tipo tudo|conteudos|tarefas
// $modo grade|dia · $concluidos '' (auto) | 0 | 1 · $semResp 0|1
//
// Regra do "auto" nos concluídos: janela inteiramente passada liga sozinha. Sem
// isso, navegar para o mês passado devolvia grade vazia (só itens abertos entram
// no recorte) e a tela parecia defeituosa justamente quando o gestor queria
// revisar o que a equipe entregou.
function equipeDadosAgenda($equipeID, $userID, $dataIni = '', $dias = 7, $tipo = 'tudo',
                           $soComDemanda = 0, $modo = 'grade', $concluidos = '', $semResp = 1){
    if(!equipePodeGerenciar($equipeID, $userID))
        return equipeErro('Sem permissão para ver o painel desta equipe.');

    $esc = equipeEscopo($equipeID);
    if(empty($esc['ids']))
        return equipeAviso('Adicione marcas à equipe (aba <b>Configurações › Marcas</b>) para ver a agenda.');
    $perfilIDs = $esc['ids'];

    $dias = in_array((int)$dias, [7, 15, 30], true) ? (int)$dias : 7;
    if(!preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$dataIni)) $dataIni = date('Y-m-d');
    if(!in_array($tipo, ['tudo', 'conteudos', 'tarefas'], true)) $tipo = 'tudo';
    if(!in_array($modo, ['grade', 'dia'], true)) $modo = 'grade';
    $soComDemanda = ((int)$soComDemanda === 1) ? 1 : 0;
    $semResp      = ((int)$semResp === 0) ? 0 : 1;

    $listaDias = [];
    for($i = 0; $i < $dias; $i++) $listaDias[] = date('Y-m-d', strtotime('+'.$i.' day', strtotime($dataIni)));
    $dataFim   = date('Y-m-d', strtotime('+'.$dias.' day', strtotime($dataIni)));
    $ehPassado = ($dataFim <= date('Y-m-d'));

    $concAuto      = ($concluidos === '' || $concluidos === null);
    $comConcluidos = $concAuto ? $ehPassado : ((int)$concluidos === 1);

    $pessoas  = equipePessoasDaEquipe($equipeID);
    $uIDs     = array_keys($pessoas);
    $cargosBy = equipeCargosPorUsuario($equipeID);
    $demanda  = equipeDemandaPorDia($perfilIDs, $uIDs, $dataIni, $dataFim, $tipo, [
        'concluidos' => $comConcluidos,
        'semResp'    => ($semResp === 1),
        // Modo Dia mostra card completo (com miniatura); a grade mostra 6 — com 3
        // o "+N" aparecia em quase todo dia cheio. O card tem altura fixa, então
        // mais itens não desalinham a linha, só a deixam mais alta.
        'maxItens'   => ($modo === 'dia' ? 12 : 6),
        'comThumb'   => ($modo === 'dia'),
    ]);
    // Capacidade é calculada ANTES da linha de órfãos: ela não tem capacidade e
    // não pode entrar na mediana da equipe.
    $capInfo  = equipeCapacidade($equipeID, $perfilIDs, $uIDs);

    if($semResp === 1 && (int)($demanda[0]['total'] ?? 0) > 0){
        $pessoas[0] = equipeAgendaPessoaOrfa();
    }
    if($soComDemanda){
        $pessoas = array_filter($pessoas, function($p) use ($demanda){
            return (int)($demanda[(int)$p['ID']]['total'] ?? 0) > 0;
        });
    }
    // Volume no período manda na ordem; a linha de órfãos vai sempre ao topo.
    uasort($pessoas, function($a, $b) use ($demanda){
        $oa = !empty($a['orfa']) ? 1 : 0;
        $ob = !empty($b['orfa']) ? 1 : 0;
        if($oa !== $ob) return $ob - $oa;
        $ta = (int)($demanda[(int)$a['ID']]['total'] ?? 0);
        $tb = (int)($demanda[(int)$b['ID']]['total'] ?? 0);
        if($ta !== $tb) return $tb - $ta;
        return strcasecmp((string)$a['Nome'], (string)$b['Nome']);
    });

    $totalItens = 0; $totalAtraso = 0; $totalFeito = 0; $totalOrfao = 0;
    $porDiaTot = [];
    foreach($listaDias as $d) $porDiaTot[$d] = 0;
    foreach($demanda as $uid => $dd){
        foreach($dd['dias'] as $d => $c){
            $n = (int)$c['conteudos'] + (int)$c['tarefas'];
            $totalItens  += $n;
            $totalAtraso += (int)$c['atrasados'];
            $totalFeito  += (int)$c['concluidos'];
            if((int)$uid === 0) $totalOrfao += $n;
            if(isset($porDiaTot[$d])) $porDiaTot[$d] += $n;
        }
    }
    // Pico do período: o dia mais carregado. É o número que decide remanejar.
    $pico = 0; $picoDia = '';
    foreach($porDiaTot as $d => $n){ if($n > $pico){ $pico = $n; $picoDia = $d; } }

    $lstPessoas = []; $qtdPessoas = 0;
    foreach($pessoas as $uid => $p){
        $uid = (int)$uid;
        if(empty($p['orfa'])) $qtdPessoas++;
        $lstPessoas[] = equipePessoaJson($p, $cargosBy[$uid] ?? []);
    }

    return [
        'ok'        => 1,
        'equipeID'  => (int)$equipeID,
        'ini'       => $dataIni,
        'fim'       => $dataFim,
        'dias'      => $dias,
        'tipo'      => $tipo,
        'socom'     => $soComDemanda,
        'modo'      => $modo,
        'conc'      => $comConcluidos ? 1 : 0,
        'semresp'   => $semResp,
        'ehPassado' => $ehPassado,
        'listaDias' => $listaDias,
        'pessoas'   => $lstPessoas,
        // Chave numérica vira string no JSON — o JS lê sempre por String(uid).
        'demanda'   => (object)$demanda,
        'cap'       => (object)$capInfo['cap'],
        'capSugerida' => (int)$capInfo['sugerida'],
        'kpis'      => [
            'itens'      => $totalItens,
            'atrasados'  => $totalAtraso,
            'feitos'     => $totalFeito,
            'orfaos'     => $totalOrfao,
            'pico'       => $pico,
            'picoDia'    => $picoDia,
            'qtdPessoas' => $qtdPessoas,
        ],
    ];
}

// Drill-down de uma célula da grade: itens daquela PESSOA naquele DIA.
// $uID = 0 é a linha "Sem responsável". $comConcluidos alinha o popup com o
// filtro "Mostrar entregues" da Agenda — sem isso, clicar numa célula de mês
// passado abriria um popup vazio.
function equipeDadosDiaItens($equipeID, $uID, $data, $tipo, $userID, $comConcluidos = 0){
    $equipeID = (int)$equipeID; $uID = (int)$uID;
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    if(!preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$data)) return equipeErro('Data inválida.');
    if(!in_array($tipo, ['tudo', 'conteudos', 'tarefas'], true)) $tipo = 'tudo';

    $perfilIDs = equipeEscopo($equipeID)['ids'];
    if(empty($perfilIDs) || $uID < 0) return equipeErro('Sem itens.');

    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tpP = str_repeat('i', count($perfilIDs));
    $fim = date('Y-m-d', strtotime('+1 day', strtotime($data)));
    $orfaos = ($uID === 0);
    $comConcluidos = ((int)$comConcluidos === 1);
    $condConteudo = $comConcluidos ? '' : ' AND sp.Concluido = 0';
    $condTarefa   = $comConcluidos ? '' : ' AND '.sqlTarefaAberta('t', 'els');

    $itens = [];

    if($tipo !== 'tarefas'){
        // Órfão não tem responsável para o JOIN: o vínculo vira NOT EXISTS.
        $joinResp = $orfaos ? '' : 'INNER JOIN perfis_posts_usuarios ppu ON ppu.PostID = pp.ID AND ppu.UserID = ?';
        $condResp = $orfaos ? 'AND NOT EXISTS (SELECT 1 FROM perfis_posts_usuarios x WHERE x.PostID = pp.ID)' : '';
        $sql = "
            SELECT 'conteudo' AS Kind, pp.ID, pp.Numero, pp.Titulo, pp.PerfisID, pp.DataAgendado AS Quando,
                   pp.AgendarInstagram, pp.AgendarFacebook, pp.AgendarTiktok, pp.AgendarLinkedIn, pp.AgendarYoutube,
                   p.Nome AS Cliente, p.InstagramFoto, p.FacebookID, p.TiktokFoto, p.ImagemFonte,
                   ".sqlLogoPerfilSubquery()." AS LogoPerfil,
                   tp.Titulo AS TipoNome, sp.Titulo AS StatusNome, sp.CorTexto AS StatusCor, sp.Concluido AS Feito,
                   (SELECT CONCAT('https://', s2.Dominio, ppu2.Arquivo)
                      FROM perfis_posts_upload ppu2 INNER JOIN servers s2 ON s2.ID = ppu2.ServerID
                      WHERE ppu2.PostID = pp.ID AND ppu2.TipoID = 0 AND ppu2.Deletado = 0 AND ppu2.DuplicadoRefID IS NULL
                      ORDER BY ppu2.Versao DESC, ppu2.ID DESC LIMIT 1) AS Thumb,
                   (SELECT ppu3.Extensao FROM perfis_posts_upload ppu3
                      WHERE ppu3.PostID = pp.ID AND ppu3.TipoID = 0 AND ppu3.Deletado = 0 AND ppu3.DuplicadoRefID IS NULL
                      ORDER BY ppu3.Versao DESC, ppu3.ID DESC LIMIT 1) AS ThumbExt
            FROM perfis_posts pp
            INNER JOIN perfis p ON p.ID = pp.PerfisID
            INNER JOIN statusPost sp ON sp.ID = pp.StatusID$condConteudo
            $joinResp
            LEFT JOIN tipoPost tp ON tp.ID = pp.TipoID
            WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
              $condResp
              AND pp.DataAgendado >= ? AND pp.DataAgendado < ?
            GROUP BY pp.ID, pp.Numero, pp.Titulo, pp.PerfisID, pp.DataAgendado, p.Nome,
                     p.InstagramFoto, p.FacebookID, p.TiktokFoto, p.ImagemFonte, p.LogoPerfilID,
                     pp.AgendarInstagram, pp.AgendarFacebook, pp.AgendarTiktok, pp.AgendarLinkedIn, pp.AgendarYoutube,
                     tp.Titulo, sp.Titulo, sp.CorTexto, sp.Concluido
            ORDER BY pp.DataAgendado ASC
        ";
        $tipos = $orfaos ? $tpP.'ss' : 'i'.$tpP.'ss';
        $args  = $orfaos ? array_merge($perfilIDs, [$data, $fim]) : array_merge([$uID], $perfilIDs, [$data, $fim]);
        $itens = array_merge($itens, pesquisarComBindParam($sql, $tipos, ...$args) ?: []);
    }

    if($tipo !== 'conteudos'){
        $joinResp = $orfaos ? '' : 'INNER JOIN tarefa_usuario tu ON tu.TarefaID = t.ID AND tu.UsuarioID = ?';
        $condResp = $orfaos ? 'AND NOT EXISTS (SELECT 1 FROM tarefa_usuario x WHERE x.TarefaID = t.ID)' : '';
        $sql = "
            SELECT 'tarefa' AS Kind, t.ID, 0 AS Numero, t.Titulo, 0 AS PerfisID, t.DataFim AS Quando,
                   0 AS AgendarInstagram, 0 AS AgendarFacebook, 0 AS AgendarTiktok, 0 AS AgendarLinkedIn, 0 AS AgendarYoutube,
                   e.Titulo AS Cliente, t.EspacoID, t.ListaID, el.Titulo AS ListaNome,
                   NULL AS TipoNome, ".sqlTarefaStatusNome('t', 'els', 'el')." AS StatusNome,
                   ".sqlTarefaStatusCor('t', 'els')." AS StatusCor,
                   ".sqlTarefaConcluida('t', 'els')." AS Feito,
                   NULL AS Thumb, NULL AS ThumbExt
            FROM tarefa t
            $joinResp
            INNER JOIN espaco e ON e.ID = t.EspacoID
            LEFT JOIN espaco_lista el ON el.ID = t.ListaID
            LEFT JOIN espaco_lista_status els ON els.ID = t.StatusID
            WHERE t.Deletado = 0 AND t.Arquivado = 0$condTarefa
              $condResp
              AND t.DataFim >= ? AND t.DataFim < ?
              AND EXISTS (SELECT 1 FROM tarefa_perfis tpf WHERE tpf.TarefaID = t.ID AND tpf.PerfisID IN ($phP))
            ORDER BY t.DataFim ASC
        ";
        $tipos = $orfaos ? 'ss'.$tpP : 'iss'.$tpP;
        $args  = $orfaos ? array_merge([$data, $fim], $perfilIDs) : array_merge([$uID, $data, $fim], $perfilIDs);
        $itens = array_merge($itens, pesquisarComBindParam($sql, $tipos, ...$args) ?: []);
    }

    if($orfaos){
        $nome = 'Sem responsável';
    } else {
        $u = pesquisarUmComBindParam("SELECT Nome FROM usuario WHERE ID = ?", 'i', $uID);
        $nome = $u['Nome'] ?? ('#'.$uID);
    }

    $lst = equipeItensParaJson($itens);
    return [
        'ok'     => 1,
        'titulo' => $nome.' · '.date('d/m/Y', strtotime($data)),
        'itens'  => $lst,
        'resumo' => equipeItensResumo($lst),
    ];
}

// Drill-down dos totais do painel da pessoa (matriz / chips / KPI de avanços).
function equipeDadosPessoaItens($equipeID, $uID, $filtros, $userID, $ano = 0, $mes = 0){
    $equipeID = (int)$equipeID; $uID = (int)$uID;
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    $j = equipeJanelaMes($ano, $mes);

    $itens = equipePessoaItens($equipeID, $uID, $filtros, $j['ini'], $j['fim']);

    // Título do contexto a partir dos filtros aplicados.
    $etapaID = (int)($filtros['etapa'] ?? 0); $tipoID = (int)($filtros['tipo'] ?? 0);
    $perfil  = (int)($filtros['perfil'] ?? 0); $canal = (string)($filtros['canal'] ?? '');
    $partes = [];
    if($etapaID > 0){ $sp  = pesquisarUmComBindParam("SELECT Titulo FROM statusPost WHERE ID = ?", 'i', $etapaID); $partes[] = $sp['Titulo']  ?? ('Etapa #'.$etapaID); }
    if($tipoID  > 0){ $tpo = pesquisarUmComBindParam("SELECT Titulo FROM tipoPost WHERE ID = ?",  'i', $tipoID);  $partes[] = $tpo['Titulo'] ?? ('Tipo #'.$tipoID); }
    if($perfil  > 0){ $pf  = pesquisarUmComBindParam("SELECT Nome FROM perfis WHERE ID = ?",      'i', $perfil);  $partes[] = $pf['Nome']    ?? ('Cliente #'.$perfil); }
    if($canal !== ''){
        $cm = ['instagram'=>'Instagram','facebook'=>'Facebook','tiktok'=>'TikTok','linkedin'=>'LinkedIn','youtube'=>'YouTube'];
        $partes[] = ($cm[$canal] ?? $canal);
    }

    $lst = equipeItensParaJson($itens);
    return [
        'ok'     => 1,
        'titulo' => empty($partes) ? 'Todos os avanços' : implode(' · ', $partes),
        'itens'  => $lst,
        'resumo' => equipeItensResumo($lst),
    ];
}

// Drill-down do bloco Clientes: conteúdos EM ANDAMENTO em que um cliente está
// marcado, numa marca específica (= o "N aguardando" do bloco).
function equipeDadosClienteItens($equipeID, $perfilID, $clienteUID, $userID){
    $equipeID = (int)$equipeID; $perfilID = (int)$perfilID; $clienteUID = (int)$clienteUID;
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    $perfilIDs = equipeEscopo($equipeID)['ids'];
    if(!in_array($perfilID, $perfilIDs, true) || $clienteUID <= 0)
        return equipeErro('Cliente/marca inválida.');

    $itens = pesquisarComBindParam("
        SELECT pp.ID, pp.Numero, pp.Titulo, pp.PerfisID, p.Nome AS Cliente,
               pp.DataAgendado AS Quando,
               pp.AgendarInstagram, pp.AgendarFacebook, pp.AgendarTiktok, pp.AgendarLinkedIn, pp.AgendarYoutube,
               p.InstagramFoto, p.FacebookID, p.TiktokFoto, p.ImagemFonte,
               ".sqlLogoPerfilSubquery()." AS LogoPerfil,
               tp.Titulo AS TipoNome, sp.Titulo AS StatusNome, sp.CorTexto AS StatusCor, sp.Concluido AS Feito,
               (SELECT CONCAT('https://', s2.Dominio, ppu2.Arquivo)
                  FROM perfis_posts_upload ppu2 INNER JOIN servers s2 ON s2.ID = ppu2.ServerID
                  WHERE ppu2.PostID = pp.ID AND ppu2.TipoID = 0 AND ppu2.Deletado = 0 AND ppu2.DuplicadoRefID IS NULL
                  ORDER BY ppu2.Versao DESC, ppu2.ID DESC LIMIT 1) AS Thumb,
               (SELECT ppu3.Extensao FROM perfis_posts_upload ppu3
                  WHERE ppu3.PostID = pp.ID AND ppu3.TipoID = 0 AND ppu3.Deletado = 0 AND ppu3.DuplicadoRefID IS NULL
                  ORDER BY ppu3.Versao DESC, ppu3.ID DESC LIMIT 1) AS ThumbExt
        FROM perfis_posts pp
        INNER JOIN perfis p ON p.ID = pp.PerfisID
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        INNER JOIN perfis_posts_usuarios ppu ON ppu.PostID = pp.ID AND ppu.UserID = ? AND ppu.PerfisID = ?
        LEFT JOIN tipoPost tp ON tp.ID = pp.TipoID
        WHERE pp.Deletado = 0 AND pp.PerfisID = ?
        GROUP BY pp.ID, pp.Numero, pp.Titulo, pp.PerfisID, p.Nome, pp.DataAgendado,
                 pp.AgendarInstagram, pp.AgendarFacebook, pp.AgendarTiktok, pp.AgendarLinkedIn, pp.AgendarYoutube,
                 p.InstagramFoto, p.FacebookID, p.TiktokFoto, p.ImagemFonte, p.LogoPerfilID,
                 tp.Titulo, sp.Titulo, sp.CorTexto, sp.Concluido
        ORDER BY pp.DataAtualizado DESC
    ", 'iii', $clienteUID, $perfilID, $perfilID) ?: [];

    $cli = pesquisarUmComBindParam("SELECT Nome FROM usuario WHERE ID = ?", 'i', $clienteUID);
    $pf  = pesquisarUmComBindParam("SELECT Nome FROM perfis WHERE ID = ?", 'i', $perfilID);

    $lst = equipeItensParaJson($itens);
    return [
        'ok'      => 1,
        'titulo'  => ($cli['Nome'] ?? ('#'.$clienteUID)).' · '.($pf['Nome'] ?? ('#'.$perfilID)),
        'prefixo' => 'aguardando o cliente',
        'itens'   => $lst,
        'resumo'  => equipeItensResumo($lst),
    ];
}

// ---- Aba "Pessoas": produtividade por membro (escopo da equipe) -------------
function equipeDadosPessoas($equipeID, $userID, $ano = 0, $mes = 0){
    if(!equipePodeGerenciar($equipeID, $userID))
        return equipeErro('Sem permissão para ver o painel desta equipe.');

    $esc = equipeEscopo($equipeID);
    if(empty($esc['ids']))
        return equipeAviso('Adicione marcas à equipe (aba <b>Configurações › Marcas</b>) para ver a produtividade das pessoas.');
    $perfilIDs = $esc['ids'];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));

    $j   = equipeJanelaMes($ano, $mes);
    $ini = $j['ini']; $fim = $j['fim']; $ano = $j['ano']; $mes = $j['mes'];

    $fluxo    = equipeFluxo($equipeID, $ini, $fim);
    $porPess  = $fluxo['porPessoa'];
    $wipUser  = equipeWipPorUsuario($perfilIDs);
    $prod     = equipeProducaoPorPessoa($equipeID, $ini, $fim);
    $prodPess = $prod['porPessoa'];
    $pessoas  = equipePessoasDaEquipe($equipeID);
    $cargosBy = equipeCargosPorUsuario($equipeID);

    $acoesRows = pesquisarComBindParam("
        SELECT pl.UserID AS UID, COUNT(*) AS Acoes
        FROM perfis_log pl
        WHERE pl.PerfisID IN ($phP) AND pl.Data >= ? AND pl.Data < ?
        GROUP BY pl.UserID
    ", $tp.'ss', ...array_merge($perfilIDs, [$ini, $fim])) ?: [];
    $acoesUser = [];
    foreach($acoesRows as $a){ $acoesUser[(int)$a['UID']] = (int)$a['Acoes']; }

    // KPIs do topo: snapshot atual (WIP/Atrasados/Parados), não do mês navegado.
    $snap = pesquisarUmComBindParam("
        SELECT
          COUNT(DISTINCT pp.ID) AS WIP,
          COUNT(DISTINCT CASE WHEN pp.DataAgendado IS NOT NULL AND pp.DataAgendado < NOW() THEN pp.ID END) AS Atrasados,
          COUNT(DISTINCT CASE WHEN pp.DataAtualizado < DATE_SUB(NOW(), INTERVAL 3 DAY) THEN pp.ID END) AS Parados
        FROM perfis_posts pp
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
    ", $tp, ...$perfilIDs);

    $cargaNow = equipeCargaPorPessoa($perfilIDs, array_keys($pessoas));
    $capInfo  = equipeCapacidade($equipeID, $perfilIDs, array_keys($pessoas));

    $linhas = []; $totalEnt = 0; $totalProd = 0; $maxProd = 0;
    foreach($pessoas as $uid => $p){
        $uid  = (int)$uid;
        $ent  = (int)($porPess[$uid]['entregas']   ?? 0);
        $ret  = (int)($porPess[$uid]['retrabalho'] ?? 0);
        $pr   = (int)($prodPess[$uid]['total']     ?? 0);
        $cn   = $cargaNow[$uid] ?? ['semana'=>0];
        $ut   = equipeFaixaUtilizacao($cn['semana'], $capInfo['cap'][$uid] ?? $capInfo['sugerida']);
        $d    = $prodPess[$uid] ?? ['porEtapa'=>[], 'porTipo'=>[], 'porCanal'=>[], 'porCliente'=>[], 'porEtapaTipo'=>[]];
        $totalEnt += $ent; $totalProd += $pr; if($pr > $maxProd) $maxProd = $pr;
        $linhas[] = [
            'uid'=>$uid, 'nome'=>(string)$p['Nome'], 'img'=>(string)$p['ImagemPerfil'],
            'cargos'=>array_values($cargosBy[$uid] ?? []),
            'prod'=>$pr, 'ent'=>$ent, 'ret'=>$ret,
            'wip'=>(int)($wipUser[$uid]['WIP'] ?? 0),
            'atr'=>(int)($wipUser[$uid]['Atrasados'] ?? 0),
            'aco'=>(int)($acoesUser[$uid] ?? 0),
            'taxa'=>(($ent + $ret) > 0 ? (int)round(($ret / ($ent + $ret)) * 100) : 0),
            'util'=>$ut,
            'detalhe'=>[
                'matriz'    => (object)($d['porEtapaTipo'] ?? []),
                'porTipo'   => (object)($d['porTipo'] ?? []),
                'porCanal'  => (object)($d['porCanal'] ?? []),
                'porCliente'=> (object)($d['porCliente'] ?? []),
            ],
        ];
    }
    usort($linhas, function($a, $b){
        if($b['prod'] !== $a['prod']) return $b['prod'] - $a['prod'];
        return $b['ent'] - $a['ent'];
    });
    if($maxProd < 1) $maxProd = 1;
    foreach($linhas as &$L){
        $L['pctBarra'] = (int)round(($L['prod'] / $maxProd) * 100);
        $L['share']    = $totalProd > 0 ? (int)round(($L['prod'] / $totalProd) * 100) : 0;
    }
    unset($L);

    // Top da equipe (concluídas no mês).
    $rank = [];
    foreach($porPess as $uid => $m){ if((int)($m['entregas'] ?? 0) > 0) $rank[(int)$uid] = (int)$m['entregas']; }
    arsort($rank); $rank = array_slice($rank, 0, 6, true);
    $rankInfo = [];
    if(!empty($rank)){
        $rk = array_keys($rank);
        $phRk = implode(',', array_fill(0, count($rk), '?'));
        $us = pesquisarComBindParam("SELECT ID, Nome, ImagemPerfil FROM usuario WHERE ID IN ($phRk)",
            str_repeat('i', count($rk)), ...$rk) ?: [];
        foreach($us as $u){ $rankInfo[(int)$u['ID']] = $u; }
    }
    $maxRk = 1; foreach($rank as $v){ if($v > $maxRk) $maxRk = $v; }
    $lstRank = [];
    foreach($rank as $uid => $qt){
        $info = $rankInfo[$uid] ?? ($pessoas[$uid] ?? ['Nome'=>'?', 'ImagemPerfil'=>'']);
        $lstRank[] = ['uid'=>(int)$uid, 'nome'=>(string)$info['Nome'], 'img'=>(string)$info['ImagemPerfil'],
                      'qtd'=>(int)$qt, 'pct'=>(int)round(($qt / $maxRk) * 100)];
    }

    // Throughput: entregas por mês nos últimos 6 meses (ancorado no mês ref).
    $mesesN = ['','Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro'];
    $sAno = $ano; $sMes = $mes - 5; while($sMes < 1){ $sMes += 12; $sAno--; }
    $serieIni = sprintf('%04d-%02d-01 00:00:00', $sAno, $sMes);
    $eAno = $ano; $eMes = $mes + 1; if($eMes > 12){ $eMes = 1; $eAno++; }
    $serieFim = sprintf('%04d-%02d-01 00:00:00', $eAno, $eMes);
    $buckets = []; $bA = $sAno; $bM = $sMes;
    for($i = 0; $i < 6; $i++){ $buckets[sprintf('%04d-%02d', $bA, $bM)] = 0; $bM++; if($bM > 12){ $bM = 1; $bA++; } }
    $serieRows = pesquisarComBindParam("
        SELECT DATE_FORMAT(pl.Data, '%Y-%m') AS Mes, COUNT(DISTINCT pl.PostID) AS Total
        FROM perfis_log pl
        INNER JOIN statusPost spc ON UPPER(spc.Titulo) = UPPER(pl.Para) AND spc.Concluido = 1
        INNER JOIN perfis_posts pp ON pp.ID = pl.PostID AND pp.Deletado = 0
        WHERE pl.Campo = 'Status' AND pl.Data >= ? AND pl.Data < ? AND pl.PerfisID IN ($phP)
        GROUP BY DATE_FORMAT(pl.Data, '%Y-%m')
    ", 'ss'.$tp, $serieIni, $serieFim, ...$perfilIDs) ?: [];
    foreach($serieRows as $sr){ if(isset($buckets[$sr['Mes']])) $buckets[$sr['Mes']] = (int)$sr['Total']; }
    $maxS = 1; foreach($buckets as $v){ if($v > $maxS) $maxS = $v; }
    $serie = [];
    foreach($buckets as $ym => $v){
        $mn = (int)substr($ym, 5, 2);
        $serie[] = ['ym'=>$ym, 'label'=>mb_substr($mesesN[$mn], 0, 3, 'UTF-8'), 'nome'=>$mesesN[$mn],
                    'valor'=>$v, 'pct'=>(int)round(($v / $maxS) * 100),
                    'atual'=>($mn === $mes && (int)substr($ym, 0, 4) === $ano)];
    }

    return [
        'ok'       => 1,
        'equipeID' => (int)$equipeID,
        'ano'      => $ano,
        'mes'      => $mes,
        'kpis'     => [
            'avancos'   => $totalProd,
            'concluidas'=> $totalEnt,
            'wip'       => (int)($snap['WIP'] ?? 0),
            'atrasados' => (int)($snap['Atrasados'] ?? 0),
            'parados'   => (int)($snap['Parados'] ?? 0),
        ],
        'linhas'   => $linhas,
        'etapaInfo'=> (object)$prod['etapaInfo'],
        'tipoNomes'=> (object)($prod['tipoNomes'] ?? []),
        'rank'     => $lstRank,
        'serie'    => $serie,
    ];
}

// ---- Aba "Etapas": saúde do fluxo por status (gargalo + retrabalho) ---------
function equipeDadosEtapas($equipeID, $userID, $ano = 0, $mes = 0){
    if(!equipePodeGerenciar($equipeID, $userID))
        return equipeErro('Sem permissão para ver o painel desta equipe.');

    $esc = equipeEscopo($equipeID);
    if(empty($esc['ids']))
        return equipeAviso('Adicione marcas à equipe (aba <b>Configurações › Marcas</b>) para ver as etapas.');
    $perfilIDs = $esc['ids'];
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $tp  = str_repeat('i', count($perfilIDs));

    $j = equipeJanelaMes($ano, $mes);

    $wipRows = pesquisarComBindParam("
        SELECT pp.StatusID AS SID,
          COUNT(DISTINCT pp.ID) AS Qtd,
          COUNT(DISTINCT CASE WHEN pp.DataAtualizado < DATE_SUB(NOW(), INTERVAL 3 DAY) THEN pp.ID END) AS Parados
        FROM perfis_posts pp
        INNER JOIN statusPost sp ON sp.ID = pp.StatusID AND sp.Concluido = 0
        WHERE pp.Deletado = 0 AND pp.PerfisID IN ($phP)
        GROUP BY pp.StatusID
    ", $tp, ...$perfilIDs) ?: [];
    $wipById = [];
    foreach($wipRows as $w){ $wipById[(int)$w['SID']] = ['Qtd'=>(int)$w['Qtd'], 'Parados'=>(int)$w['Parados']]; }

    $fluxo     = equipeFluxo($equipeID, $j['ini'], $j['fim']);
    $porStatus = $fluxo['porStatus'];
    $respById  = $fluxo['respById'];
    $wipUser   = equipeWipPorUsuario($perfilIDs);

    $uids = [];
    foreach($respById as $set){ foreach(array_keys($set) as $u){ $uids[$u] = true; } }
    $userInfo = [];
    if(!empty($uids)){
        $uidList = array_keys($uids);
        $phU = implode(',', array_fill(0, count($uidList), '?'));
        $us = pesquisarComBindParam("SELECT ID, Nome, ImagemPerfil FROM usuario WHERE ID IN ($phU)",
            str_repeat('i', count($uidList)), ...$uidList) ?: [];
        foreach($us as $u){ $userInfo[(int)$u['ID']] = $u; }
    }

    $statusList = pesquisarComBindParam(
        "SELECT ID, Titulo, CorTexto, Ordem, Concluido FROM statusPost WHERE ID > ? ORDER BY Ordem ASC", 'i', 0) ?: [];

    // Gargalo: peso maior para "parados" (item sem toque há +3 dias é o sintoma
    // real), volume como desempate.
    $gargaloID = 0; $gargaloScore = -1;
    foreach($statusList as $s){
        $sid = (int)$s['ID'];
        $par = (int)($wipById[$sid]['Parados'] ?? 0);
        if($par === 0) continue;
        $score = ($par * 10) + (int)($wipById[$sid]['Qtd'] ?? 0);
        if($score > $gargaloScore){ $gargaloScore = $score; $gargaloID = $sid; }
    }

    $linhas = [];
    foreach($statusList as $s){
        $sid = (int)$s['ID'];
        $wip = (int)($wipById[$sid]['Qtd'] ?? 0);
        $par = (int)($wipById[$sid]['Parados'] ?? 0);
        $thr = (int)($porStatus[$sid]['throughput'] ?? 0);
        $ret = (int)($porStatus[$sid]['retrabalho'] ?? 0);
        $rej = (int)($porStatus[$sid]['rejeicoes'] ?? 0);
        $tN  = (int)($porStatus[$sid]['tempoN'] ?? 0);
        $temResp = isset($respById[$sid]);
        // Só etapas relevantes: sem WIP, sem movimento e sem responsável não diz nada.
        if($wip === 0 && $thr === 0 && $ret === 0 && $rej === 0 && !$temResp) continue;

        $resp = [];
        if($temResp){
            foreach(array_keys($respById[$sid]) as $uid){
                $info = $userInfo[$uid] ?? ['Nome'=>'?', 'ImagemPerfil'=>''];
                $resp[] = ['uid'=>(int)$uid, 'nome'=>(string)$info['Nome'], 'img'=>(string)$info['ImagemPerfil'],
                           'carga'=>(int)($wipUser[$uid]['WIP'] ?? 0)];
            }
        }
        $linhas[] = [
            'sid'=>$sid, 'nome'=>(string)$s['Titulo'], 'cor'=>((string)$s['CorTexto'] ?: '#888'),
            'wip'=>$wip, 'parados'=>$par, 'throughput'=>$thr, 'retrabalho'=>$ret, 'rejeicoes'=>$rej,
            'tempoMed'=>($tN > 0 ? intdiv((int)$porStatus[$sid]['tempoSoma'], $tN) : 0),
            'gargalo'=>($sid === $gargaloID),
            'resp'=>$resp,
        ];
    }

    $barras = function($lst){
        $max = 1; foreach($lst as $x){ $max = max($max, (int)$x['Qtd']); }
        $out = [];
        foreach($lst as $x){
            $out[] = ['nome'=>(string)$x['Nome'], 'qtd'=>(int)$x['Qtd'],
                      'pct'=>(int)round(((int)$x['Qtd'] / $max) * 100)];
        }
        return $out;
    };

    return [
        'ok'       => 1,
        'equipeID' => (int)$equipeID,
        'ano'      => $j['ano'],
        'mes'      => $j['mes'],
        'linhas'   => $linhas,
        'porTipo'  => $barras(equipeWipPorTipo($perfilIDs)),
        'porCanal' => $barras(equipeWipPorCanal($perfilIDs)),
    ];
}

// ---- Painel da pessoa (tela cheia, aberto pela aba Pessoas) -----------------
function equipeDadosPessoaPainel($equipeID, $uID, $userID, $ano = 0, $mes = 0, $janela = 14){
    $equipeID = (int)$equipeID; $uID = (int)$uID;
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão para ver esta pessoa.');

    $u = pesquisarUmComBindParam("SELECT ID, Nome, ImagemPerfil FROM usuario WHERE ID = ?", 'i', $uID);
    if(empty($u)) return equipeErro('Pessoa não encontrada.');

    $j = equipeJanelaMes($ano, $mes);
    $ano = $j['ano']; $mes = $j['mes']; $ini = $j['ini']; $fim = $j['fim'];
    $janela = in_array((int)$janela, [14, 30, 60], true) ? (int)$janela : 14;

    $perfilIDs = equipeEscopo($equipeID)['ids'];
    $phP = implode(',', array_fill(0, max(1, count($perfilIDs)), '?'));
    $tp  = str_repeat('i', count($perfilIDs));

    $prod = equipeProducaoPorPessoa($equipeID, $ini, $fim);
    $d    = $prod['porPessoa'][$uID] ?? ['total'=>0,'porEtapa'=>[],'porTipo'=>[],'porCanal'=>[],'porCliente'=>[],'porEtapaTipo'=>[]];
    $fluxo = equipeFluxo($equipeID, $ini, $fim);
    $pf    = $fluxo['porPessoa'][$uID] ?? ['entregas'=>0, 'retrabalho'=>0];

    $acoes = 0;
    if(!empty($perfilIDs)){
        $ac = pesquisarUmComBindParam(
            "SELECT COUNT(*) AS N FROM perfis_log WHERE UserID = ? AND PerfisID IN ($phP) AND Data >= ? AND Data < ?",
            'i'.$tp.'ss', ...array_merge([$uID], $perfilIDs, [$ini, $fim]));
        $acoes = (int)($ac['N'] ?? 0);
    }

    // Carga e capacidade de AGORA (independem do mês navegado).
    $carga   = equipeCargaPorPessoa($perfilIDs, [$uID]);
    $cu      = $carga[$uID] ?? ['wip'=>0,'hoje'=>0,'semana'=>0,'atrasados'=>0,'parados'=>0,'ultimo'=>null,'conteudos'=>0,'tarefas'=>0];
    $capInfo = equipeCapacidade($equipeID, $perfilIDs, [$uID]);
    $capU    = (int)($capInfo['cap'][$uID] ?? $capInfo['sugerida']);
    $util    = equipeFaixaUtilizacao($cu['semana'], $capU);

    $cargosU = equipeCargosPorUsuario($equipeID)[$uID] ?? [];
    $tabVal  = equipeTabelaValores($equipeID);
    $ehExterno = !empty($tabVal[$uID]['externo']);

    // "Pela frente": a mesma grade da Agenda, uma linha só. Altura é barata aqui,
    // então cabe mais item por dia.
    $dataIni  = date('Y-m-d');
    $diasLst  = [];
    for($i = 0; $i < $janela; $i++) $diasLst[] = date('Y-m-d', strtotime('+'.$i.' day'));
    $dataFimJ = date('Y-m-d', strtotime('+'.$janela.' day'));
    $demandaJ = equipeDemandaPorDia($perfilIDs, [$uID], $dataIni, $dataFimJ, 'tudo', ['maxItens' => 10]);

    // Diário de ações do mês.
    $ativ    = pessoaAtividadePorDia($perfilIDs, $uID, $ini, $fim);
    $diasMes = (int)date('t', strtotime($ini));
    $maxAtiv = 1;
    foreach($ativ as $a) $maxAtiv = max($maxAtiv, (int)$a['acoes']);
    $diario = [];
    for($dia = 1; $dia <= $diasMes; $dia++){
        $key = sprintf('%04d-%02d-%02d', $ano, $mes, $dia);
        $a   = $ativ[$key] ?? ['acoes'=>0, 'avancos'=>0];
        $diario[] = ['dia'=>$dia, 'data'=>$key, 'acoes'=>(int)$a['acoes'], 'avancos'=>(int)$a['avancos'],
                     'pct'=>(int)round(((int)$a['acoes'] / $maxAtiv) * 100)];
    }

    $hist = pessoaSerieMeses($perfilIDs, $uID, $ano, $mes, 12);
    $maxH = 1;
    foreach($hist as $h) $maxH = max($maxH, (int)$h['avancos'], (int)$h['entregas']);
    foreach($hist as &$h){
        $h['pctAvancos']  = (int)round(((int)$h['avancos']  / $maxH) * 100);
        $h['pctEntregas'] = (int)round(((int)$h['entregas'] / $maxH) * 100);
    }
    unset($h);

    $r = [
        'ok'       => 1,
        'equipeID' => $equipeID,
        'uid'      => $uID,
        'ano'      => $ano,
        'mes'      => $mes,
        'janela'   => $janela,
        'nome'     => (string)$u['Nome'],
        'img'      => (string)$u['ImagemPerfil'],
        'cargos'   => array_values($cargosU),
        'externo'  => $ehExterno,
        'agora'    => [
            'wip'=>(int)$cu['wip'], 'hoje'=>(int)$cu['hoje'], 'semana'=>(int)$cu['semana'],
            'atrasados'=>(int)$cu['atrasados'], 'parados'=>(int)$cu['parados'],
            'conteudos'=>(int)$cu['conteudos'], 'tarefas'=>(int)$cu['tarefas'],
            'cap'=>$capU, 'pct'=>$util['pct'], 'faixa'=>$util['faixa'],
        ],
        'frente'   => [
            'dias'    => $diasLst,
            'total'   => (int)($demandaJ[$uID]['total'] ?? 0),
            'demanda' => (object)$demandaJ,
            'cap'     => (object)$capInfo['cap'],
        ],
        'mesAtividade' => [
            'diario'    => $diario,
            'acoes'     => $acoes,
            'avancos'   => (int)($d['total'] ?? 0),
            'entregas'  => (int)($pf['entregas'] ?? 0),
            'retrabalho'=> (int)($pf['retrabalho'] ?? 0),
        ],
        'historico' => $hist,
        'producao'  => [
            'matriz'    => (object)($d['porEtapaTipo'] ?? []),
            'porTipo'   => (object)($d['porTipo'] ?? []),
            'porCanal'  => (object)($d['porCanal'] ?? []),
            'porCliente'=> (object)($d['porCliente'] ?? []),
        ],
        'etapaInfo' => (object)$prod['etapaInfo'],
        'tipoNomes' => (object)($prod['tipoNomes'] ?? []),
    ];

    // Externo: o fechamento do mês dela, com atalho para auditar entrega a entrega.
    if($ehExterno){
        $execs = equipeExecucoesPessoa($equipeID, $uID, $ini, $fim);
        $pagav = 0; $ignor = 0; $total = 0.0; $semValor = 0;
        foreach($execs as $e){
            if(!empty($e['ignorado'])){ $ignor++; continue; }
            $pagav++;
            $total += (float)($e['valor'] ?? 0);
            if((float)($e['valor'] ?? 0) <= 0) $semValor++;
        }
        $r['pagamento'] = ['pagaveis'=>$pagav, 'ignoradas'=>$ignor, 'total'=>$total, 'semValor'=>$semValor];
    }

    return $r;
}

// ---- Aba "Pagamentos": fechamento do mês dos membros EXTERNOS ---------------
function equipeDadosCustos($equipeID, $userID, $ano = 0, $mes = 0){
    if(!equipePodeGerenciar($equipeID, $userID))
        return equipeErro('Sem permissão para ver o painel desta equipe.');
    $j = equipeJanelaMes($ano, $mes);

    if(!equipeTemExternos()){
        return ['ok'=>1, 'migration'=>'O pagamento por demanda ainda não está habilitado no banco. Aplique a migration '
            .'<b>BIN/migrations/2026-07-30_equipe_cargos_externos.sql</b>.'];
    }

    $cst = equipeCustosMes($equipeID, $j['ano'], $j['mes']);
    $linhas = [];
    foreach($cst['linhas'] as $L){
        $nomesCargo = [];
        foreach($L['cargos'] as $cg) $nomesCargo[] = (string)$cg['Nome'];
        $linhas[] = [
            'uid'=>(int)$L['uid'], 'nome'=>(string)$L['p']['Nome'], 'img'=>(string)$L['p']['ImagemPerfil'],
            'cargos'=>$nomesCargo,
            'execucoes'=>(int)$L['execucoes'], 'ignoradas'=>(int)$L['ignoradas'], 'pagaveis'=>(int)$L['pagaveis'],
            'total'=>(float)$L['total'],
            'valorMin'=>(float)$L['valorMin'], 'valorMax'=>(float)$L['valorMax'],
            'porStatus'=>$L['porStatus'],
        ];
    }
    return [
        'ok'        => 1,
        'equipeID'  => (int)$equipeID,
        'ano'       => $j['ano'],
        'mes'       => $j['mes'],
        'total'     => (float)$cst['total'],
        'execucoes' => (int)$cst['execucoes'],
        'ignoradas' => (int)$cst['ignoradas'],
        'semValor'  => (int)$cst['semValor'],
        'temValorStatus' => equipeTemValorStatus(),
        'linhas'    => $linhas,
    ];
}

// Auditoria: cada entrega pagável da pessoa no mês, com o preço do status dela.
function equipeDadosCustosPessoa($equipeID, $uID, $userID, $ano = 0, $mes = 0){
    $equipeID = (int)$equipeID; $uID = (int)$uID;
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    $u = pesquisarUmComBindParam("SELECT ID, Nome, ImagemPerfil FROM usuario WHERE ID = ?", 'i', $uID);
    if(empty($u)) return equipeErro('Pessoa não encontrada.');

    $j     = equipeJanelaMes($ano, $mes);
    $execs = equipeExecucoesPessoa($equipeID, $uID, $j['ini'], $j['fim']);

    $pag = 0; $ign = 0; $total = 0.0; $semValor = 0;
    foreach($execs as $e){
        if(!empty($e['ignorado'])){ $ign++; continue; }
        $pag++;
        $total += (float)($e['valor'] ?? 0);
        if((float)($e['valor'] ?? 0) <= 0) $semValor++;
    }

    return [
        'ok'       => 1,
        'equipeID' => $equipeID,
        'uid'      => $uID,
        'ano'      => $j['ano'],
        'mes'      => $j['mes'],
        'nome'     => (string)$u['Nome'],
        'img'      => (string)$u['ImagemPerfil'],
        'pagaveis' => $pag,
        'ignoradas'=> $ign,
        'total'    => $total,
        'semValor' => $semValor,
        'execucoes'=> $execs,
    ];
}

// ---- Configurações › Capacidade e valores ----------------------------------
// Uma linha por pessoa reunindo TUDO que é "regra dela" (cargo, capacidade,
// vínculo, valor fixo) + a tabela de PREÇOS POR STATUS da equipe.
function equipeDadosTabCapacidade($equipeID, $userID){
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');

    $perfilIDs = equipeEscopo($equipeID)['ids'];
    $pessoas   = equipePessoasDaEquipe($equipeID);
    if(empty($pessoas))
        return equipeAviso('Adicione pessoas à equipe (aba <b>Membros</b>) para definir capacidade.');

    $uIDs     = array_keys($pessoas);
    $capInfo  = equipeCapacidade($equipeID, $perfilIDs, $uIDs);
    $carga    = equipeCargaPorPessoa($perfilIDs, $uIDs);
    $cargosBy = equipeCargosPorUsuario($equipeID);
    $valores  = equipeValoresPorPessoa($equipeID);
    $temCap   = equipeTemColunaCapacidade();
    $temExt   = equipeTemExternos();

    $linhas = [];
    foreach($pessoas as $uid => $p){
        $uid = (int)$uid;
        $cap = (int)($capInfo['cap'][$uid] ?? $capInfo['sugerida']);
        $c   = $carga[$uid] ?? ['semana'=>0];
        $u   = equipeFaixaUtilizacao($c['semana'], $cap);
        $v   = $valores[$uid] ?? ['externo'=>false, 'valor'=>0.0, 'fonte'=>'', 'valorCargo'=>0.0];
        $linhas[] = [
            'uid'=>$uid, 'nome'=>(string)$p['Nome'], 'img'=>(string)$p['ImagemPerfil'],
            'ehDono'=>!empty($p['ehDono']),
            // Capacidade/externo moram em equipe_usuarios: sem vínculo o campo
            // fica travado (e explicado) em vez de aceitar valor que o save recusa.
            'podeCap'=>($temCap && !empty($p['podeConfig'])),
            'podeExt'=>($temExt && !empty($p['podeConfig'])),
            'podeCargo'=>(equipeTemCargos() && !empty($p['podeConfig'])),
            'cargos'=>array_values($cargosBy[$uid] ?? []),
            'capacidade'=>$cap,
            'configurado'=>!empty($capInfo['configurado'][$uid]),
            'semana'=>(int)$c['semana'], 'pct'=>$u['pct'], 'faixa'=>$u['faixa'],
            'externo'=>!empty($v['externo']),
            'valor'=>(float)$v['valor'], 'fonteValor'=>(string)$v['fonte'], 'valorCargo'=>(float)$v['valorCargo'],
        ];
    }

    $precos = equipeValoresStatus($equipeID);
    return [
        'ok'          => 1,
        'equipeID'    => (int)$equipeID,
        'sugerida'    => (int)$capInfo['sugerida'],
        'temCapacidade'=> $temCap,
        'temExternos' => $temExt,
        'temCargos'   => equipeTemCargos(),
        'temValorStatus' => equipeTemValorStatus(),
        'linhas'      => $linhas,
        // Tabela de preços da EQUIPE (CargoID = 0). O cargo sobrepõe no editor dele.
        'statusValor' => equipeLinhasPrecificaveis(),
        'valores'     => (object)$precos['equipe'],
    ];
}

// ---- Configurações › Cargos ------------------------------------------------
function equipeDadosTabCargos($equipeID, $userID){
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    if(!equipeTemCargos()){
        return ['ok'=>1, 'migration'=>'As tabelas de <b>cargos</b> ainda não existem no banco. Aplique a migration '
            .'<b>BIN/migrations/2026-07-30_equipe_cargos_externos.sql</b> para liberar cargos, membros externos '
            .'e o fechamento de pagamentos.'];
    }

    $cargos   = equipeCargos($equipeID);
    $pessoas  = equipePessoasDaEquipe($equipeID);
    $cargosBy = equipeCargosPorUsuario($equipeID);
    $precos   = equipeValoresStatus($equipeID);

    $porCargo = [];
    foreach($cargosBy as $uid => $lst){
        foreach($lst as $c){ $porCargo[(int)$c['ID']][] = (int)$uid; }
    }

    $lstCargos = [];
    foreach($cargos as $c){
        $cid  = (int)$c['ID'];
        $uids = $porCargo[$cid] ?? [];
        $avs  = [];
        foreach($uids as $uid){
            $p = $pessoas[$uid] ?? null;
            if(!$p) continue;
            $avs[] = ['uid'=>(int)$uid, 'nome'=>(string)$p['Nome'], 'img'=>(string)$p['ImagemPerfil']];
        }
        $vals = $precos['cargo'][$cid] ?? [];
        $lstCargos[] = [
            'id'=>$cid, 'nome'=>(string)$c['Nome'], 'cor'=>trim((string)($c['Cor'] ?? '')),
            'descricao'=>(string)($c['Descricao'] ?? ''),
            'valor'=>($c['ValorDemanda'] === null ? null : (float)$c['ValorDemanda']),
            'status'=>array_values($c['status']),
            'statusIDs'=>array_map(function($s){ return (int)$s['ID']; }, $c['status']),
            // regras cruas (status × tipo × canal) p/ o editor reabrir marcado
            'regras'=>array_values($c['regras'] ?? []),
            'detalhado'=>!empty($c['detalhado']),
            'pessoas'=>$avs,
            'valores'=>(object)$vals,
            'temPreco'=>!empty($vals),
        ];
    }

    $lstPessoas = [];
    foreach($pessoas as $uid => $p){
        $lstPessoas[] = equipePessoaJson($p, $cargosBy[(int)$uid] ?? []);
    }

    // Tipo/canal viajam com a aba (igual ao data-membros da aba Responsáveis):
    // o editor de cargo monta o painel de marcar sem uma ida extra ao servidor.
    $temDimCargo = equipeTemCargoDimensao();
    $tiposLst  = $temDimCargo ? (pesquisarComBindParam("SELECT ID, Titulo FROM tipoPost WHERE Ativo = 1 ORDER BY Titulo ASC", '', []) ?: []) : [];
    $locaisLst = $temDimCargo ? (pesquisarComBindParam("SELECT ID, Titulo FROM localPost ORDER BY Titulo ASC", '', []) ?: []) : [];

    return [
        'ok'         => 1,
        'equipeID'   => (int)$equipeID,
        'nPerfis'    => count(equipePerfis($equipeID) ?: []),
        'cargos'     => $lstCargos,
        'pessoas'    => $lstPessoas,
        'statusLst'  => equipeLinhasPrecificaveis(),
        'temValorStatus' => equipeTemValorStatus(),
        'temDimCargo'=> ($temDimCargo ? 1 : 0),
        'tipos'      => array_map(function($t){ return ['id'=>(int)$t['ID'], 'nome'=>(string)$t['Titulo']]; }, $tiposLst),
        'locais'     => array_map(function($l){ return ['id'=>(int)$l['ID'], 'nome'=>(string)$l['Titulo']]; }, $locaisLst),
    ];
}

// Sub-popup: escolher os cargos de UMA pessoa.
function equipeDadosCargosPessoa($equipeID, $uID, $userID){
    $equipeID = (int)$equipeID; $uID = (int)$uID;
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    if(!equipeTemCargos())  return equipeErro('Cargos não habilitados (migration pendente).');
    $u = pesquisarUmComBindParam("SELECT ID, Nome, ImagemPerfil FROM usuario WHERE ID = ?", 'i', $uID);
    if(empty($u)) return equipeErro('Pessoa não encontrada.');

    $meus = [];
    foreach((equipeCargosPorUsuario($equipeID)[$uID] ?? []) as $c){ $meus[(int)$c['ID']] = true; }

    $lst = [];
    foreach(equipeCargos($equipeID) as $c){
        $lst[] = [
            'id'=>(int)$c['ID'], 'nome'=>(string)$c['Nome'],
            'status'=>array_values($c['status']),
            'ativo'=>isset($meus[(int)$c['ID']]),
        ];
    }
    return ['ok'=>1, 'equipeID'=>$equipeID, 'uid'=>$uID, 'nome'=>(string)$u['Nome'], 'cargos'=>$lst];
}

// ---- Configurações › Acessos: matriz membros × marcas ----------------------
function equipeDadosTabAcessos($equipeID, $userID){
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    $perfis  = equipePerfis($equipeID) ?: [];
    $membros = equipeMembros($equipeID) ?: [];
    if(count($perfis) === 0)  return equipeAviso('Adicione marcas à equipe (aba <b>Marcas</b>) para gerenciar os acessos.');
    if(count($membros) === 0) return equipeAviso('Adicione pessoas à equipe (aba <b>Membros</b>) para gerenciar os acessos.');

    $perfilIDs  = array_map(function($p){ return (int)$p['ID']; }, $perfis);
    $usuarioIDs = array_map(function($m){ return (int)$m['UsuarioID']; }, $membros);
    $phP = implode(',', array_fill(0, count($perfilIDs), '?'));
    $phU = implode(',', array_fill(0, count($usuarioIDs), '?'));
    $liberados = pesquisarComBindParam(
        "SELECT PerfisID, UsuarioID FROM perfis_usuario WHERE PerfisID IN ($phP) AND UsuarioID IN ($phU)",
        str_repeat('i', count($perfilIDs) + count($usuarioIDs)), ...array_merge($perfilIDs, $usuarioIDs)) ?: [];
    $set = [];
    foreach($liberados as $l){ $set[(int)$l['UsuarioID']][(int)$l['PerfisID']] = true; }

    $lstMembros = [];
    foreach($membros as $m){
        $uid = (int)$m['UsuarioID'];
        $lstMembros[] = [
            'uid'=>$uid, 'nome'=>(string)$m['Nome'], 'img'=>(string)$m['ImagemPerfil'],
            'isAdmin'=>((int)$m['IsAdmin'] === 1),
            'liberados'=>array_map('intval', array_keys($set[$uid] ?? [])),
        ];
    }
    return [
        'ok'       => 1,
        'equipeID' => (int)$equipeID,
        'perfis'   => array_map('equipePerfilJson', $perfis),
        'membros'  => $lstMembros,
    ];
}

// ---- Configurações › Responsáveis por status -------------------------------
// $perfilSel = 0 -> config GERAL (todas as marcas, tier Geral=1)
// $perfilSel > 0 -> modo avançado: override só daquela marca (tier Geral=0)
//
// UMA REGRA = (Status, Usuário, Tipo, Canal) — é uma linha da tabela e um chip
// na tela. Tipo/Canal em 0 = "todos". A MESMA pessoa pode ter mais de uma regra
// no mesmo status ("Diego no vídeo" e "Diego no impresso"), por isso a chave do
// chip é a tripla usuário+tipo+canal, nunca o usuário sozinho.
//
// A tela devolve a lista INTEIRA de regras do status e o backend substitui o
// status todo — mesmo contrato de sempre, só que cada item agora carrega o
// recorte junto.

// "Vídeo · Stories" — o rótulo do recorte no chip. Vazio = vale para todos.
function respRegraRotulo($tipoID, $localID, $tipos, $locais){
    $p = [];
    if((int)$tipoID  > 0) $p[] = $tipos[(int)$tipoID]   ?? ('Tipo #'.(int)$tipoID);
    if((int)$localID > 0) $p[] = $locais[(int)$localID] ?? ('Canal #'.(int)$localID);
    return implode(' · ', $p);
}

// Payload de UMA linha para o front repintar depois de gravar. Carrega junto o
// contexto que o render da linha precisa (nPerfis p/ o "parcial", temDim p/ o
// badge de recorte) — sem ele o JS teria que adivinhar e a linha repintada
// sairia diferente das outras.
function equipeRespLinhaJson($equipeID, $userID, $perfilSel, $statusID){
    $d = equipeDadosTabResponsaveis($equipeID, $userID, (int)$perfilSel, (int)$statusID);
    if(empty($d['ok']) || empty($d['linhas'])) return [];
    return [
        'linha'   => $d['linhas'][0],
        'nPerfis' => $d['nPerfis'],
        'temDim'  => $d['temDim'],
    ];
}

// Normaliza o que a tela mandou: [['uid'=>1,'tipo'=>3,'local'=>0], ...] sem
// duplicata da tripla e sem uid inválido. É o que as duas funções de gravação
// recebem — a validação de PERTENCER à equipe é do endpoint.
function respRegrasNormalizar($regras){
    $out = []; $vistos = [];
    foreach((array)$regras as $r){
        $uid = (int)($r['uid'] ?? 0);
        if($uid <= 0) continue;
        $t = (int)($r['tipo'] ?? 0);  if($t < 0) $t = 0;
        $l = (int)($r['local'] ?? 0); if($l < 0) $l = 0;
        $chave = $uid.'-'.$t.'-'.$l;
        if(isset($vistos[$chave])) continue;
        $vistos[$chave] = true;
        $out[] = ['uid'=>$uid, 'tipo'=>$t, 'local'=>$l];
    }
    return $out;
}

// $statusFiltro > 0 devolve SÓ a linha daquele status — é o que o save usa para o
// front repintar uma linha em vez da aba inteira. Mesmo código, mesma forma: uma
// função paralela só para isso divergiria da tela na primeira mudança.
function equipeDadosTabResponsaveis($equipeID, $userID, $perfilSel = 0, $statusFiltro = 0){
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    $perfis = equipePerfis($equipeID) ?: [];
    if(count($perfis) === 0)
        return equipeAviso('Adicione marcas à equipe (aba <b>Marcas</b>) para definir responsáveis por status.');

    $perfilSel  = (int)$perfilSel;
    $perfilIDs  = array_map(function($p){ return (int)$p['ID']; }, $perfis);
    $nPerfis    = count($perfilIDs);
    if($perfilSel > 0 && !in_array($perfilSel, $perfilIDs, true)) $perfilSel = 0;
    $statusFiltro = (int)$statusFiltro;
    $statusList = $statusFiltro > 0
        ? (pesquisarComBindParam(
            "SELECT ID, Titulo, CorFundo, CorTexto, Ordem, Concluido FROM statusPost WHERE ID = ?", 'i', $statusFiltro) ?: [])
        : (pesquisarComBindParam(
            "SELECT ID, Titulo, CorFundo, CorTexto, Ordem, Concluido FROM statusPost WHERE ID > ? ORDER BY Ordem ASC", 'i', 0) ?: []);

    // Membros escolhíveis: os da equipe + o dono (que pode não constar em
    // equipe_usuarios; a validação do backend já o aceita).
    $membrosJs = []; $vistos = [];
    foreach(equipeMembros($equipeID) ?: [] as $m){
        $uid = (int)$m['UsuarioID'];
        $membrosJs[] = ['id'=>$uid, 'nome'=>(string)$m['Nome'], 'img'=>(string)$m['ImagemPerfil']];
        $vistos[$uid] = true;
    }
    $eqDono = equipeBuscar($equipeID);
    $donoID = (int)($eqDono['UsuarioID'] ?? 0);
    if($donoID > 0 && !isset($vistos[$donoID])){
        $du = pesquisarUmComBindParam("SELECT ID, Nome, ImagemPerfil FROM usuario WHERE ID = ?", 'i', $donoID);
        if(!empty($du)) $membrosJs[] = ['id'=>$donoID, 'nome'=>(string)$du['Nome'], 'img'=>(string)$du['ImagemPerfil']];
    }

    // Catálogos das dimensões (globais). Sem a migration a tela nem oferece o
    // recorte — a coluna não existe e toda regra é a genérica.
    $temDim = respStatusTemDimensao();
    $colDim = ($temDim ? 'psr.TipoID, psr.LocalID' : '0 AS TipoID, 0 AS LocalID')
        . (equipeTemCargoDimensao() ? ', psr.CargoID' : ', 0 AS CargoID');
    $tiposLst  = $temDim ? (pesquisarComBindParam("SELECT ID, Titulo FROM tipoPost WHERE Ativo = 1 ORDER BY Titulo ASC", '', []) ?: []) : [];
    $locaisLst = $temDim ? (pesquisarComBindParam("SELECT ID, Titulo FROM localPost ORDER BY Titulo ASC", '', []) ?: []) : [];
    $tiposMap = []; foreach($tiposLst  as $t){ $tiposMap[(int)$t['ID']]  = (string)$t['Titulo']; }
    $locaisMap = []; foreach($locaisLst as $l){ $locaisMap[(int)$l['ID']] = (string)$l['Titulo']; }

    // Nome dos cargos, para o chip dizer DE ONDE a regra veio.
    $cargoNomes = [];
    if(equipeTemCargoDimensao()){
        foreach(pesquisarComBindParam("SELECT ID, Nome FROM equipe_cargo WHERE EquipeID = ?", 'i', $equipeID) ?: [] as $c){
            $cargoNomes[(int)$c['ID']] = (string)$c['Nome'];
        }
    }

    // Um chip por PESSOA. As regras dela no status são o produto TIPOS x CANAIS
    // que a tela marca de uma vez, então o chip devolve os dois conjuntos (e não
    // as linhas soltas): é assim que o painel de edição reabre já marcado.
    // `cargos` diz quais cargos escreveram aquilo — o chip vira só-leitura e
    // aponta para o cargo em vez de deixar editar e ser desfeito na próxima
    // sincronização.
    $montarChips = function($fonte, $herdado, $modoPerfil) use ($tiposMap, $locaisMap, $nPerfis, $cargoNomes){
        $porUid = [];
        foreach($fonte as $r){
            $uid = (int)$r['uid'];
            if(!isset($porUid[$uid])){
                $porUid[$uid] = ['uid'=>$uid, 'nome'=>$r['nome'], 'img'=>$r['img'],
                                 'tipos'=>[], 'locais'=>[], 'n'=>0, 'nMin'=>PHP_INT_MAX,
                                 'cargos'=>[], 'manual'=>false];
            }
            $t = (int)$r['tipo']; $l = (int)$r['local'];
            if($t > 0) $porUid[$uid]['tipos'][$t]  = true;
            if($l > 0) $porUid[$uid]['locais'][$l] = true;
            $cid = (int)($r['cargo'] ?? 0);
            if($cid > 0) $porUid[$uid]['cargos'][$cid] = true;
            else         $porUid[$uid]['manual'] = true;
            $porUid[$uid]['n']++;
            $porUid[$uid]['nMin'] = min($porUid[$uid]['nMin'], (int)$r['n']);
        }

        $out = [];
        foreach($porUid as $p){
            $tipos  = array_map('intval', array_keys($p['tipos']));
            $locais = array_map('intval', array_keys($p['locais']));
            sort($tipos); sort($locais);

            // "Vídeo, Arte · Feed" — os dois eixos separados, do jeito que o painel
            // de edição mostra. Junta tudo numa lista só ficaria ilegível com 3+.
            $partes = [];
            if($tipos)  $partes[] = implode(', ', array_map(function($t) use ($tiposMap){  return $tiposMap[$t]  ?? ('Tipo #'.$t);  }, $tipos));
            if($locais) $partes[] = implode(', ', array_map(function($l) use ($locaisMap){ return $locaisMap[$l] ?? ('Canal #'.$l); }, $locais));

            $cargos = [];
            foreach(array_keys($p['cargos']) as $cid){
                $cargos[] = ['id'=>(int)$cid, 'nome'=>($cargoNomes[(int)$cid] ?? ('Cargo #'.$cid))];
            }

            $out[] = [
                'uid'=>$p['uid'], 'nome'=>$p['nome'], 'img'=>$p['img'],
                'tipos'=>$tipos, 'locais'=>$locais,
                'rotulo'=>implode(' · ', $partes),
                'nRegras'=>$p['n'],
                'cargos'=>$cargos, 'manual'=>$p['manual'],
                'herdado'=>$herdado,
                'parcial'=>(!$modoPerfil && $p['nMin'] < $nPerfis), 'em'=>$p['nMin'],
            ];
        }
        // Quem vale para tudo primeiro; os recortes são a EXCEÇÃO e vêm depois.
        usort($out, function($a, $b){
            $ra = ($a['rotulo'] !== '') ? 1 : 0;
            $rb = ($b['rotulo'] !== '') ? 1 : 0;
            if($ra !== $rb) return $ra - $rb;
            return strcmp($a['nome'], $b['nome']);
        });
        return $out;
    };

    // [sid => [chaveRegra => linha]] — a chave é uid+tipo+local, então a mesma
    // pessoa em dois recortes são DUAS regras e o COUNT por marca não se mistura.
    $agrupar = function($rows) {
        $map = [];
        foreach($rows as $x){
            $sid = (int)$x['StatusID']; $uid = (int)$x['UsuarioID'];
            $t = (int)$x['TipoID']; $l = (int)$x['LocalID'];
            $chave = $uid.'-'.$t.'-'.$l;
            if(!isset($map[$sid][$chave])){
                $map[$sid][$chave] = ['uid'=>$uid, 'tipo'=>$t, 'local'=>$l, 'n'=>0,
                                      'cargo'=>(int)($x['CargoID'] ?? 0),
                                      'nome'=>(string)$x['Nome'], 'img'=>(string)$x['ImagemPerfil']];
            }
            $map[$sid][$chave]['n']++;
        }
        return $map;
    };

    $linhas = [];
    if($perfilSel > 0){
        // Override da marca (Geral=0) com fallback herdado (Geral=1). O override é do
        // STATUS INTEIRO: personalizar um status nesta marca substitui a lista dele.
        // LEFT JOIN: a sentinela de "override vazio" (sem usuário) também marca override ativo.
        $ovRows = pesquisarComBindParam(
            "SELECT psr.StatusID, psr.UsuarioID, $colDim, u.Nome, u.ImagemPerfil
             FROM perfil_status_responsavel psr LEFT JOIN usuario u ON u.ID = psr.UsuarioID
             WHERE psr.PerfisID = ? AND psr.Geral = 0", 'i', $perfilSel) ?: [];
        $ovStatus = [];
        foreach($ovRows as $x){ $ovStatus[(int)$x['StatusID']] = true; }
        $ovMap = $agrupar(array_values(array_filter($ovRows, function($x){ return (int)$x['UsuarioID'] > 0; })));

        $geRows = pesquisarComBindParam(
            "SELECT psr.StatusID, psr.UsuarioID, $colDim, u.Nome, u.ImagemPerfil
             FROM perfil_status_responsavel psr INNER JOIN usuario u ON u.ID = psr.UsuarioID
             WHERE psr.PerfisID = ? AND psr.UsuarioID > 0 AND psr.Geral = 1", 'i', $perfilSel) ?: [];
        $geMap = $agrupar($geRows);

        $cliGe = pesquisarComBindParam(
            "SELECT StatusID FROM perfil_status_responsavel WHERE PerfisID = ? AND UsuarioID = ? AND Geral = 1",
            'ii', $perfilSel, STATUS_RESP_CLIENTE) ?: [];
        $cliPerfil = [];
        foreach($cliGe as $x){ $cliPerfil[(int)$x['StatusID']] = true; }

        foreach($statusList as $s){
            $sid = (int)$s['ID'];
            $temOverride = isset($ovStatus[$sid]);
            $fonte = $temOverride ? ($ovMap[$sid] ?? []) : ($geMap[$sid] ?? []);
            $linhas[] = [
                'sid'=>$sid, 'titulo'=>(string)$s['Titulo'], 'cor'=>((string)$s['CorTexto'] ?: '#888'),
                'chips'=>$montarChips($fonte, !$temOverride, true),
                'cliente'=>isset($cliPerfil[$sid]),
                'clienteHerdado'=>true, 'clienteParcial'=>false,
                'temOverride'=>$temOverride,
            ];
        }
    } else {
        // Modo Geral (agregado nas marcas, tier Geral=1).
        $phP = implode(',', array_fill(0, $nPerfis, '?'));
        $rows = pesquisarComBindParam(
            "SELECT psr.StatusID, psr.UsuarioID, $colDim, u.Nome, u.ImagemPerfil
             FROM perfil_status_responsavel psr INNER JOIN usuario u ON u.ID = psr.UsuarioID
             WHERE psr.PerfisID IN ($phP) AND psr.UsuarioID > 0 AND psr.Geral = 1",
            str_repeat('i', $nPerfis), ...$perfilIDs) ?: [];
        $map = $agrupar($rows);

        $cliStatus = [];
        $cliRows = pesquisarComBindParam(
            "SELECT StatusID, COUNT(*) AS N FROM perfil_status_responsavel
             WHERE PerfisID IN ($phP) AND UsuarioID = ? AND Geral = 1 GROUP BY StatusID",
            str_repeat('i', $nPerfis).'i', ...array_merge($perfilIDs, [STATUS_RESP_CLIENTE])) ?: [];
        foreach($cliRows as $x){ $cliStatus[(int)$x['StatusID']] = (int)$x['N']; }

        foreach($statusList as $s){
            $sid  = (int)$s['ID'];
            $cliN = (int)($cliStatus[$sid] ?? 0);
            $linhas[] = [
                'sid'=>$sid, 'titulo'=>(string)$s['Titulo'], 'cor'=>((string)$s['CorTexto'] ?: '#888'),
                'chips'=>$montarChips($map[$sid] ?? [], false, false),
                'cliente'=>($cliN > 0),
                'clienteHerdado'=>false,
                'clienteParcial'=>($cliN > 0 && $cliN < $nPerfis),
                'clienteEm'=>$cliN,
                'temOverride'=>false,
            ];
        }
    }

    $nomeSel = '';
    foreach($perfis as $p){ if((int)$p['ID'] === $perfilSel){ $nomeSel = (string)$p['Nome']; break; } }

    return [
        'ok'        => 1,
        'equipeID'  => (int)$equipeID,
        'perfilSel' => $perfilSel,
        'nomeSel'   => $nomeSel,
        'nPerfis'   => $nPerfis,
        'perfis'    => array_map('equipePerfilJson', $perfis),
        'membros'   => $membrosJs,
        'linhas'    => $linhas,
        'temDim'    => ($temDim ? 1 : 0),
        'tipos'     => array_map(function($t){ return ['id'=>(int)$t['ID'], 'nome'=>(string)$t['Titulo']]; }, $tiposLst),
        'locais'    => array_map(function($l){ return ['id'=>(int)$l['ID'], 'nome'=>(string)$l['Titulo']]; }, $locaisLst),
    ];
}

// ---- Sub-popups de membros/marcas ------------------------------------------
function equipeDadosAddPerfil($equipeID, $userID){
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    return [
        'ok'       => 1,
        'equipeID' => (int)$equipeID,
        'perfis'   => array_map('equipePerfilJson', equipePerfisDisponiveis($equipeID, $userID) ?: []),
    ];
}

function equipeDadosAddMembro($equipeID, $userID){
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    return [
        'ok'       => 1,
        'equipeID' => (int)$equipeID,
        'perfis'   => array_map('equipePerfilJson', equipePerfis($equipeID) ?: []),
    ];
}

// Preview de acesso do e-mail escolhido no popup "adicionar pessoa".
function equipeDadosPreviewMembro($equipeID, $email){
    $email = trim(strtolower($email));
    if($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) return ['ok'=>1, 'vazio'=>1];

    $u = pesquisarUmComBindParam("SELECT ID, Nome, ImagemPerfil FROM usuario WHERE Email = ?", 's', $email);
    if(empty($u)) return ['ok'=>1, 'novo'=>1, 'email'=>$email];

    $usuarioID = (int)$u['ID'];
    $perfis    = equipeMembroPerfisDetalhe($equipeID, $usuarioID) ?: [];
    if(count($perfis) === 0) return ['ok'=>1, 'vazio'=>1];

    $lib = []; $falt = [];
    foreach($perfis as $p){
        $j = equipePerfilJson($p);
        if((int)$p['Liberado'] === 1) $lib[] = $j; else $falt[] = $j;
    }
    return [
        'ok'        => 1,
        'usuarioID' => $usuarioID,
        'nome'      => (string)$u['Nome'],
        'img'       => (string)$u['ImagemPerfil'],
        'total'     => count($perfis),
        'liberados' => $lib,
        'faltantes' => $falt,
    ];
}

// Popup do badge "Acesso limitado": marcas da equipe com toggle por pessoa.
function equipeDadosAcessoPerfis($equipeID, $usuarioID, $userID){
    if(!equipePodeGerenciar($equipeID, $userID)) return equipeErro('Sem permissão.');
    $u  = pesquisarUmComBindParam("SELECT ID, Nome, Email, ImagemPerfil FROM usuario WHERE ID = ?", 'i', $usuarioID);
    $eu = pesquisarUmComBindParam("SELECT TipoAcesso, IsAdmin FROM equipe_usuarios WHERE EquipeID = ? AND UsuarioID = ?",
        'ii', $equipeID, $usuarioID);
    if(empty($u) || empty($eu)) return equipeErro('Membro não encontrado.');

    $itens = [];
    foreach(equipeMembroPerfisDetalhe($equipeID, $usuarioID) ?: [] as $p){
        $j = equipePerfilJson($p);
        $j['liberado'] = ((int)$p['Liberado'] === 1);
        $itens[] = $j;
    }
    return [
        'ok'        => 1,
        'equipeID'  => (int)$equipeID,
        'usuarioID' => (int)$usuarioID,
        'nome'      => (string)$u['Nome'],
        'email'     => (string)$u['Email'],
        'img'       => (string)$u['ImagemPerfil'],
        'ehTotal'   => ((int)$eu['TipoAcesso'] === 0),
        'perfis'    => $itens,
    ];
}

// =====================================================================
// E-MAILS DE COBRANCA (assinaturas PIX/Boleto)
// ---------------------------------------------------------------------
// Disparados pelo robo browse/webhook/asaas_robo_pagamentos.php em cada
// estagio do ciclo. Templates editaveis no admin (tabela p_email_cobranca)
// e controle de envio idempotente (usuario_assinatura_fatura_email).
// NAO altera enviaEmail()/fluxos existentes: monta proprio envio multipart.
// =====================================================================

// Cor de destaque (botao/topo) por estagio. Vai esquentando conforme o prazo.
function emailCobrancaAccent($tipoID){
    switch((int)$tipoID){
        case 1: return '#2563eb'; // gerada      - azul (informativo)
        case 2: return '#2563eb'; // perto venc.  - azul
        case 3: return '#f59e0b'; // vencida       - laranja
        case 4: return '#ef4444'; // perto bloqueio- vermelho
        case 5: return '#dc2626'; // bloqueada     - vermelho forte
        default: return '#2563eb';
    }
}

// Busca o template (ativo ou nao — o caller decide). Retorna array ou null.
function emailCobrancaTemplate($tipoID){
    return pesquisarUmComBindParam(
        "SELECT ID, TipoID, Slug, Nome, Assunto, Corpo, Ativo FROM p_email_cobranca WHERE TipoID = ?",
        'i', (int)$tipoID
    );
}

// Substitui os placeholders {{...}} num texto (assunto ou corpo).
function emailCobrancaSubstituir($texto, $dados){
    $map = array(
        '{{nome}}'            => $dados['nome'],
        '{{valor}}'           => $dados['valor'],
        '{{vencimento}}'      => $dados['vencimento'],
        '{{forma_pagamento}}' => $dados['forma_pagamento'],
        '{{dias}}'            => $dados['dias'],
        '{{link}}'            => $dados['link'],
    );
    return strtr($texto, $map);
}

// Monta o HTML bonito (layout fixo, estilo ClickUp). $corpoHtml = miolo editavel.
function emailCobrancaWrapHtml($preheader, $corpoHtml, $ctaLabel, $ctaUrl, $accent){
    $logo = 'https://airpost.com.br/comum/img/logo-512.png';
    $h  = '<!DOCTYPE html><html lang="pt-BR"><head>';
    $h .= '<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">';
    $h .= '<meta name="x-apple-disable-message-reformatting">';
    $h .= '<title>Airpost</title></head>';
    $h .= '<body style="margin:0;padding:0;background-color:#f4f7fb;-webkit-text-size-adjust:100%;">';
    // pre-header (texto oculto que aparece na previa da caixa de entrada)
    $h .= '<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:#f4f7fb;font-size:1px;line-height:1px;">'.htmlspecialchars($preheader).'</div>';
    $h .= '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f7fb;padding:24px 12px;"><tr><td align="center">';
    $h .= '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px;max-width:100%;background-color:#ffffff;border-radius:14px;overflow:hidden;border:1px solid #e6eaf0;font-family:Arial,Helvetica,sans-serif;">';
    // topo
    $h .= '<tr><td style="background-color:'.$accent.';padding:22px 28px;">';
    $h .= '<img src="'.$logo.'" width="36" height="36" alt="Airpost" style="vertical-align:middle;border-radius:8px;display:inline-block;">';
    $h .= '<span style="color:#ffffff;font-size:18px;font-weight:bold;letter-spacing:.2px;vertical-align:middle;margin-left:10px;">Airpost</span>';
    $h .= '</td></tr>';
    // corpo
    $h .= '<tr><td style="padding:32px 28px 8px 28px;color:#1f2a37;font-size:15px;line-height:1.6;">';
    $h .= $corpoHtml;
    $h .= '</td></tr>';
    // botao CTA
    $h .= '<tr><td style="padding:18px 28px 32px 28px;">';
    $h .= '<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:10px;background-color:'.$accent.';">';
    $h .= '<a href="'.$ctaUrl.'" target="_blank" style="display:inline-block;padding:14px 30px;color:#ffffff;font-size:15px;font-weight:bold;text-decoration:none;border-radius:10px;font-family:Arial,Helvetica,sans-serif;">'.htmlspecialchars($ctaLabel).'</a>';
    $h .= '</td></tr></table>';
    $h .= '</td></tr>';
    // rodape
    $h .= '<tr><td style="padding:22px 28px;background-color:#fafbfc;border-top:1px solid #eef1f5;color:#8a94a6;font-size:12px;line-height:1.6;">';
    $h .= 'Voce esta recebendo este e-mail porque possui uma assinatura na Airpost.<br>';
    $h .= 'Duvidas? Responda este e-mail ou fale com a gente em <a href="mailto:contato@airpost.com.br" style="color:#8a94a6;">contato@airpost.com.br</a>.<br>';
    $h .= '&copy; '.date('Y').' Airpost &middot; <a href="https://airpost.com.br" style="color:#8a94a6;">airpost.com.br</a>';
    $h .= '</td></tr>';
    $h .= '</table></td></tr></table></body></html>';
    return $h;
}

// Versao texto-plano (alternativa do multipart) a partir do miolo HTML.
function emailCobrancaTextoPlano($corpoHtml, $ctaLabel, $ctaUrl){
    $t = preg_replace('/<\s*br\s*\/?>/i', "\n", $corpoHtml);
    $t = preg_replace('/<\/\s*p\s*>/i', "\n\n", $t);
    $t = strip_tags($t);
    $t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
    $t = preg_replace("/\n{3,}/", "\n\n", trim($t));
    $t .= "\n\n".$ctaLabel.": ".$ctaUrl;
    $t .= "\n\n--\nAirpost - airpost.com.br\nDuvidas: contato@airpost.com.br";
    return $t;
}

// Funcao principal: envia 1 e-mail de cobranca para a fatura, de forma idempotente.
// $emailOverride: SOMENTE para teste manual (admin). Quando informado, envia para esse
//   e-mail, ignora a idempotencia e NAO grava no log — assim o cron real fica intacto.
// Retorna: 1 enviado | 0 nao enviado (ja enviado / template off / sem dados).
function enviaEmailCobranca($faturaID, $tipoID, $emailOverride = null){
    $faturaID = (int)$faturaID;
    $tipoID   = (int)$tipoID;
    if($faturaID <= 0 || $tipoID < 1 || $tipoID > 5) return 0;

    $ehTeste = ($emailOverride !== null && trim($emailOverride) !== '');

    // 1) Idempotencia: ja enviamos este estagio para esta fatura? (pulada em teste)
    if(!$ehTeste){
        $jaEnviado = pesquisarUmComBindParam(
            "SELECT ID FROM usuario_assinatura_fatura_email WHERE FaturaID = ? AND TipoID = ?",
            'ii', $faturaID, $tipoID
        );
        if(!empty($jaEnviado)) return 0;
    }

    // 2) Template (respeita kill-switch Ativo)
    $template = emailCobrancaTemplate($tipoID);
    if(empty($template) || (int)$template['Ativo'] !== 1) return 0;

    // 3) Dados da fatura + usuario
    $fatura = pesquisarUmComBindParam(
        "SELECT uaf.ID, uaf.ValorTotal, uaf.DataVencimento, uaf.FormaPagamentoID,
                pfo.Titulo AS FormaPagamentoNome,
                u.Nome AS UsuarioNome, u.Email AS UsuarioEmail
           FROM usuario_assinatura_fatura uaf
           INNER JOIN usuario_assinatura ua ON ua.ID = uaf.AssinaturaID
           INNER JOIN usuario u ON u.ID = ua.UsuarioID
           LEFT JOIN p_formaPagamento pfo ON pfo.ID = uaf.FormaPagamentoID
          WHERE uaf.ID = ?",
        'i', $faturaID
    );
    if(empty($fatura) || empty($fatura['UsuarioEmail'])) return 0;

    // Destinatario: e-mail do dono da fatura — ou o override (teste).
    $email = $ehTeste ? trim($emailOverride) : trim($fatura['UsuarioEmail']);
    if(!filter_var($email, FILTER_VALIDATE_EMAIL)) return 0;

    // dias relativos ao vencimento (positivo = faltam X / negativo = atraso de X)
    $dias = '';
    if(!empty($fatura['DataVencimento'])){
        $hoje = new DateTime('today');
        $venc = new DateTime($fatura['DataVencimento']);
        $diffDias = (int)$hoje->diff($venc)->format('%r%a');
        $dias = (string)abs($diffDias);
    }

    $dados = array(
        'nome'            => htmlspecialchars($fatura['UsuarioNome'] ?? ''),
        'valor'           => 'R$ '.number_format((float)$fatura['ValorTotal'], 2, ',', '.'),
        'vencimento'      => !empty($fatura['DataVencimento']) ? date('d/m/Y', strtotime($fatura['DataVencimento'])) : '',
        'forma_pagamento' => htmlspecialchars($fatura['FormaPagamentoNome'] ?? 'PIX/Boleto'),
        'dias'            => $dias,
        'link'            => 'https://airpost.com.br/browse/?abrirFaturas=1',
    );

    // 4) Monta assunto + corpo com placeholders
    $assunto   = emailCobrancaSubstituir($template['Assunto'], $dados);
    $corpoHtml = emailCobrancaSubstituir($template['Corpo'], $dados);

    $ctaLabel = ($tipoID >= 4) ? 'Pagar e reativar agora' : 'Pagar minha fatura';
    $ctaUrl   = $dados['link'];
    $accent   = emailCobrancaAccent($tipoID);

    $preheader = 'Fatura '.$dados['valor'].' - vencimento '.$dados['vencimento'];
    $html  = emailCobrancaWrapHtml($preheader, $corpoHtml, $ctaLabel, $ctaUrl, $accent);
    $texto = emailCobrancaTextoPlano($corpoHtml, $ctaLabel, $ctaUrl);

    // 5) Envio multipart/alternative (text + html) com headers de boa entrega.
    //    Isolado de enviaEmail() para nao afetar os demais fluxos.
    $remetenteEmail = 'notificacao@airpost.com.br';
    $remetenteNome  = 'Airpost';
    $boundary = 'airpost_'.md5(uniqid((string)$faturaID, true));

    $headers  = "MIME-Version: 1.0\r\n";
    $headers .= "From: ".$remetenteNome." <".$remetenteEmail.">\r\n";
    $headers .= "Reply-To: contato@airpost.com.br\r\n";
    $headers .= "Return-Path: ".$remetenteEmail."\r\n";
    $headers .= "List-Unsubscribe: <mailto:".$remetenteEmail."?subject=unsubscribe>\r\n";
    $headers .= "Content-Type: multipart/alternative; boundary=\"".$boundary."\"\r\n";

    $corpoMsg  = "--".$boundary."\r\n";
    $corpoMsg .= "Content-Type: text/plain; charset=UTF-8\r\n";
    $corpoMsg .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
    $corpoMsg .= $texto."\r\n\r\n";
    $corpoMsg .= "--".$boundary."\r\n";
    $corpoMsg .= "Content-Type: text/html; charset=UTF-8\r\n";
    $corpoMsg .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
    $corpoMsg .= $html."\r\n\r\n";
    $corpoMsg .= "--".$boundary."--";

    $assuntoCodificado = '=?UTF-8?B?'.base64_encode($assunto).'?=';

    $enviado = @mail($email, $assuntoCodificado, $corpoMsg, $headers, "-r".$remetenteEmail);

    // 6) Registra o envio (mesmo que mail() retorne false em alguns SMTP locais,
    //    consideramos disparado para nao reenviar em loop; o robo roda em ciclo).
    //    Em modo teste (override) NAO grava, para nao bloquear o envio real.
    if(!$ehTeste){
        insertComBindParam(
            "INSERT INTO usuario_assinatura_fatura_email (FaturaID, TipoID, Email, DataEnvio) VALUES (?, ?, ?, NOW())",
            'iis', $faturaID, $tipoID, $email
        );
    }

    return $enviado ? 1 : 0;
}

// Preview do e-mail (admin): monta o HTML final com dados ficticios.
function emailCobrancaPreview($tipoID, $assunto = null, $corpo = null){
    $tipoID = (int)$tipoID;
    $template = emailCobrancaTemplate($tipoID);
    if(empty($template) && $corpo === null) return '';

    $assunto = ($assunto !== null) ? $assunto : ($template['Assunto'] ?? '');
    $corpo   = ($corpo   !== null) ? $corpo   : ($template['Corpo'] ?? '');

    $dados = array(
        'nome'            => 'Maria Souza',
        'valor'           => 'R$ 149,90',
        'vencimento'      => date('d/m/Y', strtotime('+5 days')),
        'forma_pagamento' => 'PIX',
        'dias'            => '5',
        'link'            => 'https://airpost.com.br/browse/?abrirFaturas=1',
    );

    $assuntoR = emailCobrancaSubstituir($assunto, $dados);
    $corpoR   = emailCobrancaSubstituir($corpo, $dados);
    $ctaLabel = ($tipoID >= 4) ? 'Pagar e reativar agora' : 'Pagar minha fatura';
    $accent   = emailCobrancaAccent($tipoID);

    return emailCobrancaWrapHtml($assuntoR, $corpoR, $ctaLabel, $dados['link'], $accent);
}

// ============================================================================
// TEMPLATES DE LISTA (estilo ClickUp) — estrutura de status reutilizável.
// Templates do SISTEMA são definidos aqui (curados p/ o domínio de conteúdo).
// Cada template: id (string), nome, descricao, statusInicioTitulo,
// statusFimTitulo e um array de status intermediários
// (titulo, cor, tipoid 0=não iniciado/1=iniciado/2=finalizado, porcentagem).
// O status inicial (TipoID 0) e o final (TipoID 2) são implícitos na lista
// (espaco_lista.StatusInicioTitulo / StatusFimTitulo); os intermediários
// vão para espaco_lista_status. Reaproveitado pelo front (galeria) e pelo
// back (aplicar template em insertEspacoLista).
// ============================================================================
function templatesListaSistema(){
    return array(
        array(
            'id' => 'planejamento_conteudo',
            'nome' => 'Planejamento de conteúdo',
            'descricao' => 'Da ideia à publicação: produção, revisão, aprovação e agendamento.',
            'cor' => '#2588ff',
            'statusInicioTitulo' => 'Ideia',
            'statusFimTitulo' => 'Publicado',
            'status' => array(
                array('titulo' => 'Em produção', 'cor' => '#f5a623', 'tipoid' => 1, 'porcentagem' => 25),
                array('titulo' => 'Em revisão',  'cor' => '#9b59b6', 'tipoid' => 1, 'porcentagem' => 50),
                array('titulo' => 'Aprovado',    'cor' => '#2588ff', 'tipoid' => 1, 'porcentagem' => 70),
                array('titulo' => 'Agendado',    'cor' => '#1abc9c', 'tipoid' => 1, 'porcentagem' => 90),
            ),
        ),
        array(
            'id' => 'aprovacao_cliente',
            'nome' => 'Aprovação de cliente',
            'descricao' => 'Fluxo de validação: revisão interna, envio ao cliente e ajustes.',
            'cor' => '#9b59b6',
            'statusInicioTitulo' => 'Rascunho',
            'statusFimTitulo' => 'Aprovado',
            'status' => array(
                array('titulo' => 'Revisão interna',    'cor' => '#f5a623', 'tipoid' => 1, 'porcentagem' => 30),
                array('titulo' => 'Aguardando cliente', 'cor' => '#3498db', 'tipoid' => 1, 'porcentagem' => 55),
                array('titulo' => 'Em ajustes',         'cor' => '#e74c3c', 'tipoid' => 1, 'porcentagem' => 80),
            ),
        ),
        array(
            'id' => 'campanha',
            'nome' => 'Campanha',
            'descricao' => 'Do briefing ao ar: criação, design, revisão e aprovação.',
            'cor' => '#e67e22',
            'statusInicioTitulo' => 'Briefing',
            'statusFimTitulo' => 'No ar',
            'status' => array(
                array('titulo' => 'Criação',  'cor' => '#f5a623', 'tipoid' => 1, 'porcentagem' => 25),
                array('titulo' => 'Design',   'cor' => '#9b59b6', 'tipoid' => 1, 'porcentagem' => 50),
                array('titulo' => 'Revisão',  'cor' => '#3498db', 'tipoid' => 1, 'porcentagem' => 70),
                array('titulo' => 'Aprovado', 'cor' => '#2588ff', 'tipoid' => 1, 'porcentagem' => 90),
            ),
        ),
        array(
            'id' => 'kanban_simples',
            'nome' => 'Kanban simples',
            'descricao' => 'Três colunas diretas: a fazer, fazendo e feito.',
            'cor' => '#1abc9c',
            'statusInicioTitulo' => 'A fazer',
            'statusFimTitulo' => 'Feito',
            'status' => array(
                array('titulo' => 'Fazendo', 'cor' => '#f5a623', 'tipoid' => 1, 'porcentagem' => 50),
            ),
        ),
    );
}

// Devolve um template do SISTEMA pelo id (ou null).
function templateListaSistemaPorId($id){
    foreach(templatesListaSistema() as $t){
        if($t['id'] === $id)
            return $t;
    }
    return null;
}

// Resolve o dono da conta (UsuarioID) de um espaço — escopo dos templates custom.
function espacoDonoUsuarioID($espacoID){
    $espacoID = (int)$espacoID;
    if($espacoID <= 0)
        return 0;
    $r = pesquisarUmComBindParam("SELECT UsuarioID FROM espaco WHERE ID = ?", 'i', $espacoID);
    return $r ? (int)$r['UsuarioID'] : 0;
}

// Aplica a estrutura de um template (array no formato de templatesListaSistema /
// galeria custom) numa lista recém-criada: define títulos de início/fim e insere
// os status intermediários em espaco_lista_status. Não cria tarefas.
function aplicarTemplateNaLista($listaID, $tpl){
    $listaID = (int)$listaID;
    if($listaID <= 0 || empty($tpl))
        return;

    $ini = isset($tpl['statusInicioTitulo']) ? $tpl['statusInicioTitulo'] : '';
    $fim = isset($tpl['statusFimTitulo'])    ? $tpl['statusFimTitulo']    : '';

    updateComBindParam("
        UPDATE espaco_lista
        SET StatusInicioTitulo = ?, StatusFimTitulo = ?, DataAtualizado = NOW()
        WHERE ID = ?
    ", 'ssi', $ini, $fim, $listaID);

    if(!empty($tpl['status']) && is_array($tpl['status'])){
        $ordem = 1;
        foreach($tpl['status'] as $st){
            $titulo = isset($st['titulo']) ? $st['titulo'] : (isset($st['Titulo']) ? $st['Titulo'] : '');
            $cor    = isset($st['cor'])    ? $st['cor']    : (isset($st['Cor'])    ? $st['Cor']    : '#2588ff');
            $tipoid = isset($st['tipoid']) ? (int)$st['tipoid'] : (isset($st['TipoID']) ? (int)$st['TipoID'] : 1);
            $porc   = isset($st['porcentagem']) ? (int)$st['porcentagem'] : (isset($st['Porcentagem']) ? (int)$st['Porcentagem'] : 0);

            if($titulo === '')
                continue;

            insertComBindParam("
                INSERT INTO espaco_lista_status (ListaID, Titulo, Cor, TipoID, Ordem, Porcentagem)
                VALUES (?, ?, ?, ?, ?, ?)
            ", 'issiii', $listaID, $titulo, $cor, $tipoid, $ordem, $porc);

            $ordem++;
        }
    }
}

// Carrega um template custom (DB) por ID já no formato da galeria, escopado pelo
// dono da conta — evita aplicar template de outra conta. Retorna null se inválido.
function templateListaCustomPorId($templateID, $donoUsuarioID){
    $templateID = (int)$templateID;
    $donoUsuarioID = (int)$donoUsuarioID;
    if($templateID <= 0 || $donoUsuarioID <= 0)
        return null;

    $tpl = pesquisarUmComBindParam("
        SELECT ID, Titulo, Descricao, StatusInicioTitulo, StatusFimTitulo
        FROM espaco_lista_template
        WHERE ID = ? AND UsuarioID = ? AND SituacaoID = 1
    ", 'ii', $templateID, $donoUsuarioID);

    if(!$tpl)
        return null;

    $status = pesquisarComBindParam("
        SELECT Titulo, Cor, TipoID, Porcentagem
        FROM espaco_lista_template_status
        WHERE TemplateID = ?
        ORDER BY Ordem ASC
    ", 'i', $templateID);

    return array(
        'statusInicioTitulo' => $tpl['StatusInicioTitulo'],
        'statusFimTitulo' => $tpl['StatusFimTitulo'],
        'status' => is_array($status) ? $status : array(),
    );
}


/* ============================================================================
 * SANITIZACAO DO HTML DO EDITOR DE BLOCOS (editor-text.js)
 *
 * A descricao da tarefa e as paginas de documento gravam HTML CRU e o front
 * reinjeta esse HTML direto no DOM (template literal em footer.php). Sem
 * limpeza, um POST direto em save-page.php com
 *     valor=<img src=x onerror=alert(document.cookie)>
 * executa para TODO membro do espaco que abrir a tarefa. O paste do editor
 * blinda no cliente, mas blindagem de cliente nao e blindagem.
 *
 * Mesma regra do bind param no SQL: nada de confiar no que chega do POST.
 * Allowlist de tags e atributos — o que nao esta na lista sai.
 *
 * Usar SEMPRE que HTML de editor for gravado (tarefa.Texto, pagina de
 * documento, e qualquer campo novo que use o mesmo editor).
 * ========================================================================== */

// Tags que o editor de blocos realmente produz.
function htmlEditorTagsPermitidas(){
    return array(
        'div','p','span','br','hr','b','strong','i','em','u','s','strike','del',
        'h1','h2','h3','h4','h5','h6','blockquote','pre','code','sub','sup',
        'ul','ol','li','a','img','table','thead','tbody','tr','td','th',
        'input','button','font','figure','figcaption'
    );
}

/* Atributos permitidos por tag ('*' vale para todas). Qualquer `data-*` passa:
 * sao inertes (nao executam nada) e o editor cria varios — data-block, data-act
 * dos botoes da tabela, data-size do card OG, data-mencao, data-tarefa-id,
 * data-anexo-id. Listar um a um so garantiria quebrar o proximo que aparecer
 * (foi assim que os botoes +linha/-coluna morreriam depois de um reload). */
function htmlEditorAtributosPermitidos(){
    return array(
        '*'      => array('class','style','contenteditable','title','dir','lang'),
        'a'      => array('href','target','rel'),
        'img'    => array('src','alt','width','height'),
        'input'  => array('type','checked','disabled'),
        'button' => array('type'),
        'font'   => array('color','face','size'),
        'td'     => array('colspan','rowspan','align','valign'),
        'th'     => array('colspan','rowspan','align','valign'),
        'table'  => array('border','cellpadding','cellspacing'),
    );
}

// Piso de seguranca quando ext-dom nao esta disponivel: nao e parser, mas corta
// os vetores diretos em vez de gravar cru.
function htmlEditorFallbackRegex($html){
    $html = preg_replace('#<\s*(script|style|iframe|object|embed|form|svg|math)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', (string)$html);
    $html = preg_replace('#<\s*/?\s*(script|style|iframe|object|embed|form|svg|math|base|link)\b[^>]*>#i', '', $html);
    $html = preg_replace('#\son[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html);
    $html = preg_replace('#(href|src)\s*=\s*("|\')?\s*(javascript|vbscript|data)\s*:#i', '$1=$2#', $html);
    return $html;
}

/* Um valor de `style` so passa com propriedades da lista branca. expression() e
 * escapes CSS sao vetores conhecidos e caem fora; `position:fixed` fica de fora da
 * lista porque permite sobrepor a interface inteira (clickjacking).
 *
 * url() NAO pode ser barrado em bloco: a miniatura do card OG e exatamente
 * `background-image:url(https://...)` — barrar apagaria a imagem de todo bookmark
 * ja salvo no primeiro save seguinte. Entao a URL DE DENTRO do url() e validada
 * (so http(s)), que e o que realmente importa contra `url(javascript:...)`. */
function htmlEditorLimpaStyle($style){
    $propsOk = array('color','background-color','background','background-image','font-weight','font-style',
                     'text-decoration','text-align','width','height','margin-left','margin',
                     'padding','float','max-width','min-width','vertical-align','background-size',
                     'background-position','background-repeat');
    $saida = array();
    foreach(explode(';', (string)$style) as $decl){
        if(strpos($decl, ':') === false) continue;
        list($prop, $val) = explode(':', $decl, 2);
        $prop = strtolower(trim($prop));
        $val  = trim($val);
        if($val === '' || !in_array($prop, $propsOk, true)) continue;
        if(preg_match('/expression\s*\(|@import|\\\\/i', $val)) continue;

        if(stripos($val, 'url(') !== false){
            // Aceita um unico url(...) com URL http(s) limpa; qualquer outra forma sai.
            if(!preg_match('/^url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)$/i', $val, $m)) continue;
            $alvo = trim($m[1]);
            if(!preg_match('#^(https?:)?//#i', $alvo) && substr($alvo, 0, 1) !== '/') continue;
            if(preg_match('/[\x00-\x1f]|javascript\s*:/i', $alvo)) continue;
            $val = 'url('.$alvo.')';
        }
        $saida[] = $prop.':'.$val;
    }
    return implode(';', $saida);
}

// href/src: so http(s), mailto e caminho relativo. Corta javascript:, data:, vbscript:.
function htmlEditorUrlSegura($url){
    $u = trim((string)$url);
    if($u === '') return '';
    // Remove caracteres de controle usados pra esconder o esquema (java\0script:).
    $u = preg_replace('/[\x00-\x20]/', '', $u);
    if(preg_match('#^(https?:)?//#i', $u)) return $u;
    if(preg_match('#^mailto:#i', $u))      return $u;
    if(substr($u, 0, 1) === '/' || substr($u, 0, 1) === '#') return $u;
    if(preg_match('#^[a-z][a-z0-9+.-]*:#i', $u)) return '';  // qualquer outro esquema
    return $u;                                               // relativo simples
}

/* Limpa o HTML vindo do editor. Devolve string vazia para entrada vazia. */
function sanitizarHtmlEditor($html){
    $html = (string)$html;
    if(trim($html) === '') return '';
    if(!class_exists('DOMDocument')) return htmlEditorFallbackRegex($html);

    // Sem isto o DOMDocument emite warning em HTML5 e polui o retorno do AJAX.
    $libxmlAnterior = libxml_use_internal_errors(true);

    $doc = new DOMDocument('1.0', 'UTF-8');
    // A PI de encoding + meta charset evitam que acentos virem mojibake
    // (DOMDocument assume ISO-8859-1 quando nao ha declaracao).
    $ok = $doc->loadHTML(
        '<?xml encoding="UTF-8"?><!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div data-ap-raiz="1">'
        . $html .
        '</div></body></html>',
        LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING
    );
    libxml_clear_errors();
    libxml_use_internal_errors($libxmlAnterior);

    if(!$ok) return '';   // HTML irrecuperavel: melhor gravar vazio do que cru

    // XPath em vez de getElementById: sem DTD o libxml nao registra `id` como ID
    // e o getElementById devolve null de forma intermitente.
    $xp = new DOMXPath($doc);
    $raiz = $xp->query('//div[@data-ap-raiz="1"]')->item(0);
    if(!$raiz) return '';

    $tagsOk    = htmlEditorTagsPermitidas();
    $attrsOk   = htmlEditorAtributosPermitidos();
    $estrutura = array('html','head','body','meta');
    $mortais   = array('script','style','iframe','object','embed','link','base','form','svg','math','applet','frame','frameset');

    // De tras pra frente: assim os filhos sao tratados antes dos pais e remover
    // um no no meio nao pula o seguinte (a HTMLCollection original e viva).
    $todos = iterator_to_array($doc->getElementsByTagName('*'), false);
    for($i = count($todos) - 1; $i >= 0; $i--){
        $el  = $todos[$i];
        $tag = strtolower($el->nodeName);

        // A propria raiz e um <div> permitido; ela e desembrulhada no fim.
        if(in_array($tag, $estrutura, true)) continue;

        if(!in_array($tag, $tagsOk, true)){
            if(in_array($tag, $mortais, true)){
                // Some com o conteudo junto — o "texto" de um <script> e o payload.
                if($el->parentNode) $el->parentNode->removeChild($el);
            } else if($el->parentNode){
                // Tag so desconhecida: desembrulha, preservando o que a pessoa escreveu.
                while($el->firstChild) $el->parentNode->insertBefore($el->firstChild, $el);
                $el->parentNode->removeChild($el);
            }
            continue;
        }

        // Checkbox do checklist e o unico <input> aceito.
        if($tag === 'input' && strtolower($el->getAttribute('type')) !== 'checkbox'){
            if($el->parentNode) $el->parentNode->removeChild($el);
            continue;
        }

        $permitidos = array_merge($attrsOk['*'], isset($attrsOk[$tag]) ? $attrsOk[$tag] : array());
        $attrs = iterator_to_array($el->attributes, false);
        foreach($attrs as $attr){
            $nome = strtolower($attr->nodeName);

            // on* (onerror/onclick/onload...) e o vetor mais comum — fora, sempre,
            // antes de qualquer outra checagem.
            if(strpos($nome, 'on') === 0){ $el->removeAttribute($attr->nodeName); continue; }
            $ehData = (strpos($nome, 'data-') === 0 && preg_match('/^data-[a-z0-9-]+$/', $nome));
            if(!$ehData && !in_array($nome, $permitidos, true)){
                $el->removeAttribute($attr->nodeName);
                continue;
            }
            if($nome === 'href' || $nome === 'src'){
                $limpa = htmlEditorUrlSegura($attr->nodeValue);
                if($limpa === '') $el->removeAttribute($attr->nodeName);
                else $el->setAttribute($nome, $limpa);
            } else if($nome === 'style'){
                $limpa = htmlEditorLimpaStyle($attr->nodeValue);
                if($limpa === '') $el->removeAttribute('style');
                else $el->setAttribute('style', $limpa);
            }
        }

        // Link do editor sempre abre fora e sem window.opener (reverse tabnabbing).
        if($tag === 'a' && $el->hasAttribute('href')) $el->setAttribute('rel', 'noopener noreferrer');
    }

    // Desembrulha a raiz e serializa o <body> inteiro. Serializar so os filhos da
    // raiz perderia conteudo quando o HTML chega mal formado: um </div> a mais
    // fecha a raiz antes da hora e o parser joga o resto pra fora dela.
    $body = $doc->getElementsByTagName('body')->item(0);
    if(!$body) return '';
    if($raiz->parentNode){
        while($raiz->firstChild) $raiz->parentNode->insertBefore($raiz->firstChild, $raiz);
        $raiz->parentNode->removeChild($raiz);
    }

    $saida = '';
    foreach($body->childNodes as $filho) $saida .= $doc->saveHTML($filho);

    return trim($saida);
}

// ===========================================================================
// URL DA IMAGEM DE UM USUÁRIO — resolvedor ÚNICO.
//
// Existem dois formatos vivos em usuario_upload.Arquivo (e em
// usuario.ImagemPerfil), e vão continuar existindo:
//   - LEGADO: só o nome do arquivo, servido da pasta local -> /upload/<nome>
//   - NOVO:   a URL completa do R2 (https://...), usada como está
//
// Nada foi migrado de propósito: reescrever milhares de linhas antigas para
// ganhar uniformidade é risco sem retorno. Quem decide é o prefixo, aqui, num
// lugar só — qualquer tela que monte "/upload/" na mão volta a divergir assim
// que um usuário trocar a foto.
//
// Devolve '' quando não há imagem, para o chamador poder cair nas iniciais.
// ===========================================================================
function urlImagemUsuario($arquivo){
    $arquivo = trim((string) $arquivo);
    if($arquivo === '') return '';

    // Já é URL absoluta (R2 ou qualquer CDN): usa como veio.
    if(stripos($arquivo, 'http://') === 0 || stripos($arquivo, 'https://') === 0)
        return $arquivo;

    // Legado. basename() corta qualquer tentativa de "../" chegar na montagem
    // do caminho — o valor vem do banco, mas quem o gravou foi um POST.
    return '/upload/' . basename($arquivo);
}
