#!/usr/bin/env node

import axios from 'axios';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';

const SERVER_NAME = 'link-manager-mcp';
const SERVER_VERSION = '0.1.2';
const USER_AGENT = `${SERVER_NAME}/${SERVER_VERSION}`;
const OPENAI_API_BASE_URL = 'https://api.openai.com/v1/chat/completions';
const OPENAI_DEFAULT_MODEL = 'gpt-4o-mini';

class ApiError extends Error {
  constructor(message, status, payload) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.payload = payload;
  }
}

const toInt = (value, fallback) => {
  const parsed = Number.parseInt(String(value), 10);
  return Number.isFinite(parsed) ? parsed : fallback;
};

const normalizeBaseUrl = raw => {
  const value = String(raw || '').trim();
  if (!value) return null;
  return value.replace(/\/+$/, '');
};

const pickErrorMessage = payload => {
  if (!payload) return null;

  if (typeof payload === 'string') {
    const trimmed = payload.trim();
    return trimmed || null;
  }

  if (typeof payload === 'object') {
    if (typeof payload.error === 'string' && payload.error.trim()) return payload.error.trim();
    if (typeof payload.message === 'string' && payload.message.trim()) return payload.message.trim();
    if (typeof payload.details === 'string' && payload.details.trim()) return payload.details.trim();
  }

  return null;
};

const stripHtml = html =>
  String(html || '')
    .replace(/<style[\s\S]*?<\/style>/gi, ' ')
    .replace(/<script[\s\S]*?<\/script>/gi, ' ')
    .replace(/<[^>]+>/g, ' ')
    .replace(/&nbsp;/gi, ' ')
    .replace(/&amp;/gi, '&')
    .replace(/\s+/g, ' ')
    .trim();

const getWordCount = html => {
  const text = stripHtml(html);
  if (!text) return 0;
  return text.split(' ').filter(Boolean).length;
};

const getExternalLinks = html => {
  const links = [];
  const matches = [...String(html || '').matchAll(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>/gi)];

  for (const match of matches) {
    const href = String(match[1] || '').trim();
    if (!href) continue;

    if (!/^https?:\/\//i.test(href)) {
      links.push({ href, isRelative: true });
      continue;
    }

    links.push({ href, isRelative: false });
  }

  return links;
};

const countHeadingTags = (html, tagName) => {
  const safeTag = String(tagName || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  if (!safeTag) return 0;
  const regex = new RegExp(`<\\s*${safeTag}\\b[^>]*>`, 'gi');
  return [...String(html || '').matchAll(regex)].length;
};

const containsDisallowedRel = html =>
  /rel\s*=\s*["'][^"']*\b(?:nofollow|noopener)\b[^"']*["']/i.test(String(html || ''));

const slugify = value =>
  String(value || '')
    .toLowerCase()
    .replace(/<[^>]*>/g, ' ')
    .replace(/[^a-z0-9\s-]/g, ' ')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '')
    .slice(0, 96);

const validateGeneratedArticleForApi = articleHtml => {
  const wordCount = getWordCount(articleHtml);
  const links = getExternalLinks(articleHtml);
  const relativeLinks = links.filter(link => link.isRelative).length;
  const externalLinks = links.filter(link => !link.isRelative).map(link => link.href);
  const headingCounts = {
    h2: countHeadingTags(articleHtml, 'h2'),
    h3: countHeadingTags(articleHtml, 'h3'),
    h4: countHeadingTags(articleHtml, 'h4')
  };

  const errors = [];

  if (wordCount < 1000 || wordCount > 5000) {
    errors.push(`word_count_out_of_range:${wordCount}`);
  }

  if (relativeLinks > 0) {
    errors.push(`relative_links_not_allowed:${relativeLinks}`);
  }

  if (externalLinks.length > 2) {
    errors.push(`external_links_limit_exceeded:${externalLinks.length}`);
  }

  if (headingCounts.h2 < 1) {
    errors.push('missing_required_heading:h2');
  }

  if (headingCounts.h3 < 1) {
    errors.push('missing_required_heading:h3');
  }

  if (headingCounts.h4 < 1) {
    errors.push('missing_required_heading:h4');
  }

  return {
    valid: errors.length === 0,
    errors,
    wordCount,
    externalLinks,
    headingCounts
  };
};

const openAiChatCompletion = async ({ apiKey, model, messages, timeoutMs }) => {
  if (!apiKey) {
    throw new Error(
      'Missing OpenAI API key. Set LM_OPENAI_API_KEY or OPENAI_API_KEY for article generation.'
    );
  }

  const response = await axios({
    method: 'POST',
    url: OPENAI_API_BASE_URL,
    timeout: timeoutMs,
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
      'User-Agent': USER_AGENT
    },
    data: {
      model: model || OPENAI_DEFAULT_MODEL,
      messages,
      response_format: { type: 'json_object' }
    },
    validateStatus: () => true
  });

  if (response.status >= 400) {
    throw new ApiError(
      pickErrorMessage(response.data) || `OpenAI request failed with status ${response.status}`,
      response.status,
      response.data
    );
  }

  const content = String(response.data?.choices?.[0]?.message?.content || '').trim();
  if (!content) {
    throw new Error('OpenAI returned empty response content.');
  }

  let parsed;
  try {
    parsed = JSON.parse(content);
  } catch {
    throw new Error('OpenAI returned non-JSON output; expected JSON object.');
  }

  return parsed;
};

const buildArticleGenerationMessages = ({
  userPrompt,
  sourceHtml,
  articleIndex,
  totalArticles,
  retryErrors
}) => {
  const systemPrompt = [
    'You are an SEO content assistant.',
    'Generate one article based on the user prompt.',
    'Return strictly JSON with keys:',
    'title, meta_title, meta_description, content_html, featured_image.',
    'content_html must be an HTML fragment only (not full page HTML).',
    'No markdown fences.',
    'Hard API constraints:',
    '- 1000 to 5000 words.',
    '- At most 2 external links.',
    '- External links must be absolute URLs (http/https).',
    '- Do not add rel values containing nofollow or noopener.',
    '- Must include heading hierarchy with at least one <h2>, one <h3>, and one <h4> in content_html.'
  ].join('\n');

  const userSections = [
    `Article number ${articleIndex} of ${totalArticles}.`,
    'Follow the user prompt exactly, including style and structure requirements.',
    'User prompt:',
    userPrompt
  ];

  if (sourceHtml && sourceHtml.trim()) {
    userSections.push('Source text for rewrite/reference (HTML):');
    userSections.push(sourceHtml);
  }

  if (retryErrors && retryErrors.length > 0) {
    userSections.push('Fix these validation errors from previous attempt:');
    userSections.push(retryErrors.join(', '));
  }

  return [
    { role: 'system', content: systemPrompt },
    { role: 'user', content: userSections.join('\n\n') }
  ];
};

const resolveSessionFilePath = raw => {
  const value = String(raw || '').trim();
  if (!value) {
    return path.join(os.homedir(), '.link-manager-mcp-session.json');
  }

  if (path.isAbsolute(value)) return value;
  return path.resolve(process.cwd(), value);
};

class LinkManagerClient {
  constructor(env = process.env) {
    this.baseUrl = normalizeBaseUrl(env.LM_API_BASE_URL || env.LINK_MANAGER_API_BASE_URL);
    this.timeoutMs = toInt(env.LM_API_TIMEOUT_MS, 30000);
    this.openAiApiKey = String(env.LM_OPENAI_API_KEY || env.OPENAI_API_KEY || '').trim() || null;
    this.openAiModel = String(env.LM_OPENAI_MODEL || env.OPENAI_MODEL || '').trim() || OPENAI_DEFAULT_MODEL;

    this.username = String(env.LM_API_USERNAME || env.LINK_MANAGER_USERNAME || '').trim() || null;
    this.password = String(env.LM_API_PASSWORD || env.LINK_MANAGER_PASSWORD || '').trim() || null;

    const staticToken =
      String(env.LM_API_TOKEN || env.LINK_MANAGER_API_TOKEN || '').trim() || null;
    const persistSessionRaw = String(env.LM_PERSIST_SESSION || 'true').toLowerCase();
    const sessionFileRaw = String(env.LM_SESSION_FILE || env.LINK_MANAGER_SESSION_FILE || '').trim();

    this.persistSession = persistSessionRaw !== 'false';
    this.sessionFilePath = this.persistSession ? resolveSessionFilePath(sessionFileRaw) : null;

    this.accessToken = staticToken;
    this.cookies = new Map();
    this.nextLoginAllowedAt = 0;

    if (!this.accessToken && this.persistSession) {
      this._loadSessionFromDisk();
    } else if (this.accessToken) {
      this.cookies.set('lm_access_token', this.accessToken);
    }

    this.authReady = false;
    this.roleChecked = false;
    this.currentRole = null;
    this.enforceNonAdmin = String(env.LM_MCP_ENFORCE_NON_ADMIN || 'true').toLowerCase() !== 'false';
    this.enableAdminTools = String(env.LM_MCP_ENABLE_ADMIN_TOOLS || 'false').toLowerCase() === 'true';
  }

  _loadSessionFromDisk() {
    if (!this.sessionFilePath) return;

    try {
      if (!fs.existsSync(this.sessionFilePath)) return;

      const raw = fs.readFileSync(this.sessionFilePath, 'utf8').trim();
      if (!raw) return;
      const session = JSON.parse(raw);
      if (!session || typeof session !== 'object') return;

      if (session.cookies && typeof session.cookies === 'object') {
        for (const [name, value] of Object.entries(session.cookies)) {
          if (typeof name !== 'string' || typeof value !== 'string') continue;
          const normalizedName = name.trim();
          const normalizedValue = value.trim();
          if (!normalizedName || !normalizedValue) continue;
          this.cookies.set(normalizedName, normalizedValue);
        }
      }

      if (!this.accessToken && typeof session.accessToken === 'string' && session.accessToken.trim()) {
        this.accessToken = session.accessToken.trim();
      }

      const cookieAccessToken = this.cookies.get('lm_access_token');
      if (cookieAccessToken) {
        this.accessToken = cookieAccessToken;
      }
    } catch {
      // Ignore broken session files and continue with normal auth flow.
    }
  }

  _persistSessionToDisk() {
    if (!this.persistSession || !this.sessionFilePath) return;

    const payload = {
      savedAt: new Date().toISOString(),
      accessToken: this.accessToken || null,
      cookies: Object.fromEntries(this.cookies.entries())
    };

    try {
      fs.mkdirSync(path.dirname(this.sessionFilePath), { recursive: true });
      fs.writeFileSync(this.sessionFilePath, JSON.stringify(payload), 'utf8');
    } catch {
      // Persist errors are non-fatal for MCP runtime.
    }
  }

  validateConfig() {
    if (!this.baseUrl) {
      throw new Error('Missing LM_API_BASE_URL (or LINK_MANAGER_API_BASE_URL).');
    }

    const hasToken = !!this.accessToken;
    const hasLoginPair = !!(this.username && this.password);

    if (!hasToken && !hasLoginPair) {
      throw new Error(
        'Missing credentials. Provide either LM_API_TOKEN or LM_API_USERNAME + LM_API_PASSWORD.'
      );
    }
  }

  _getCookieHeader() {
    if (this.cookies.size === 0) return null;
    return Array.from(this.cookies.entries())
      .map(([key, value]) => `${key}=${value}`)
      .join('; ');
  }

  _setCookies(setCookieHeader) {
    if (!setCookieHeader) return;
    const rawHeaders = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];

    for (const header of rawHeaders) {
      if (!header || typeof header !== 'string') continue;
      const firstPart = header.split(';')[0];
      const delimiterIndex = firstPart.indexOf('=');
      if (delimiterIndex <= 0) continue;

      const name = firstPart.slice(0, delimiterIndex).trim();
      const value = firstPart.slice(delimiterIndex + 1).trim();

      if (!name) continue;
      if (value.length === 0) {
        this.cookies.delete(name);
      } else {
        this.cookies.set(name, value);
      }
    }

    const cookieAccessToken = this.cookies.get('lm_access_token');
    if (cookieAccessToken) {
      this.accessToken = cookieAccessToken;
    }

    this._persistSessionToDisk();
  }

  async _httpRequest({ method, path, params, data, includeAuthHeader = true }) {
    const url = `${this.baseUrl}${path}`;
    const methodUpper = String(method || 'GET').toUpperCase();
    const headers = {
      Accept: 'application/json',
      'User-Agent': USER_AGENT
    };

    if (data !== undefined) {
      headers['Content-Type'] = 'application/json';
    }

    const cookieHeader = this._getCookieHeader();
    if (cookieHeader) {
      headers.Cookie = cookieHeader;
    }

    if (includeAuthHeader && this.accessToken) {
      // Authorization header bypasses same-origin checks in backend middleware.
      headers.Authorization = `Bearer ${this.accessToken}`;
    }

    if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(methodUpper)) {
      try {
        const requestOrigin = new URL(this.baseUrl).origin;
        headers.Origin = requestOrigin;
        headers.Referer = `${requestOrigin}/`;
      } catch {
        // Ignore invalid base URL here; request execution will fail later with a clearer network error.
      }
    }

    let response;

    try {
      response = await axios({
        method,
        url,
        params,
        data,
        headers,
        timeout: this.timeoutMs,
        validateStatus: () => true
      });
    } catch (error) {
      throw new ApiError(
        error?.message || 'Network request failed',
        null,
        error?.response?.data || null
      );
    }

    this._setCookies(response.headers?.['set-cookie']);

    return response;
  }

  _toApiError(response) {
    let message =
      pickErrorMessage(response?.data) || `Link Manager API request failed with status ${response.status}`;

    if (response?.status === 429) {
      const retryAfterRaw = Array.isArray(response?.headers?.['retry-after'])
        ? response.headers['retry-after'][0]
        : response?.headers?.['retry-after'];
      const retryAfterSec = toInt(retryAfterRaw, 0);

      if (retryAfterSec > 0) {
        message = `${message} Retry after ${retryAfterSec} seconds.`;
      }
    }

    return new ApiError(message, response?.status || null, response?.data || null);
  }

  async login() {
    if (!this.username || !this.password) {
      throw new Error('Username/password are not configured for login.');
    }

    const now = Date.now();
    if (this.nextLoginAllowedAt > now) {
      const retryAfterSec = Math.ceil((this.nextLoginAllowedAt - now) / 1000);
      throw new ApiError(
        `Login is temporarily rate-limited. Retry after ${retryAfterSec} seconds.`,
        429,
        { retry_after: retryAfterSec }
      );
    }

    const response = await this._httpRequest({
      method: 'POST',
      path: '/api/auth/login',
      data: {
        username: this.username,
        password: this.password,
        rememberMe: true
      },
      includeAuthHeader: false
    });

    if (response.status === 429) {
      const retryAfterRaw = Array.isArray(response?.headers?.['retry-after'])
        ? response.headers['retry-after'][0]
        : response?.headers?.['retry-after'];
      const retryAfterSec = Math.max(1, toInt(retryAfterRaw, 60));
      this.nextLoginAllowedAt = Date.now() + retryAfterSec * 1000;
    }

    if (response.status >= 400) {
      throw this._toApiError(response);
    }

    const bodyToken = typeof response.data?.token === 'string' ? response.data.token.trim() : '';
    if (bodyToken) {
      this.accessToken = bodyToken;
      this.cookies.set('lm_access_token', bodyToken);
    }

    if (!this.accessToken && !this.cookies.get('lm_access_token')) {
      throw new Error('Login succeeded but no access token cookie was returned.');
    }

    this.nextLoginAllowedAt = 0;
    this._persistSessionToDisk();
    this.authReady = true;
  }

  async refreshSession() {
    const hasRefreshCookie = this.cookies.has('lm_refresh_token');
    if (!hasRefreshCookie) {
      return false;
    }

    const response = await this._httpRequest({
      method: 'POST',
      path: '/api/auth/refresh',
      data: {},
      includeAuthHeader: true
    });

    return response.status < 400;
  }

  async reauthenticate() {
    const refreshed = await this.refreshSession();
    if (refreshed) return;

    if (this.username && this.password) {
      await this.login();
      return;
    }

    throw new Error('Session expired and automatic re-authentication is not available.');
  }

  async _requestAuthed({ method, path, params, data, retry = true }) {
    const response = await this._httpRequest({ method, path, params, data, includeAuthHeader: true });

    if (response.status === 401 && retry) {
      await this.reauthenticate();
      return this._requestAuthed({ method, path, params, data, retry: false });
    }

    if (response.status >= 400) {
      throw this._toApiError(response);
    }

    return response.data;
  }

  async ensureReady() {
    this.validateConfig();

    if (!this.authReady) {
      if (this.accessToken) {
        this.authReady = true;
      } else {
        const refreshed = await this.refreshSession();
        if (refreshed) {
          this.authReady = true;
        } else {
          await this.login();
        }
      }
    }

    if (this.enforceNonAdmin && !this.roleChecked) {
      const profile = await this._requestAuthed({ method: 'GET', path: '/api/users/profile', retry: true });
      const role = String(profile?.data?.role || '').trim().toLowerCase();
      this.currentRole = role || null;
      this.roleChecked = true;

      if (role === 'admin') {
        throw new Error(
          'Authenticated account has role=admin. For safe MCP sharing use a non-admin account (or set LM_MCP_ENFORCE_NON_ADMIN=false).'
        );
      }
    }
  }

  async request({ method, path, params, data }) {
    await this.ensureReady();
    return this._requestAuthed({ method, path, params, data, retry: true });
  }

}

const normalizeHttpMethod = value => {
  const method = String(value || 'GET')
    .trim()
    .toUpperCase();

  if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
    throw new Error('Unsupported HTTP method. Allowed: GET, POST, PUT, PATCH, DELETE.');
  }

  return method;
};

const assertApiPath = path => {
  const normalizedPath = String(path || '').trim();

  if (!normalizedPath) {
    throw new Error('path is required.');
  }

  if (!normalizedPath.startsWith('/api/')) {
    throw new Error('path must start with /api/.');
  }

  return normalizedPath;
};

const assertPathScope = ({ client, path, requireAdminPath = false }) => {
  const isAdminPath = path.startsWith('/api/admin/');

  if (requireAdminPath && !isAdminPath) {
    throw new Error('Admin tool requires path that starts with /api/admin/.');
  }

  if (isAdminPath && !client.enableAdminTools) {
    throw new Error(
      'Admin endpoints are disabled. Set LM_MCP_ENABLE_ADMIN_TOOLS=true and use an admin account.'
    );
  }
};

const tools = [
  {
    name: 'link_manager_get_profile',
    description: 'Get current user profile and verify MCP account role.',
    inputSchema: {
      type: 'object',
      properties: {},
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_list_projects',
    description: 'List your SEO projects.',
    inputSchema: {
      type: 'object',
      properties: {
        page: { type: 'integer', minimum: 1 },
        limit: { type: 'integer', minimum: 1, maximum: 5000 }
      },
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_create_project',
    description: 'Create a new project.',
    inputSchema: {
      type: 'object',
      properties: {
        name: { type: 'string', minLength: 1 },
        description: { type: 'string' },
        main_site_url: { type: 'string' }
      },
      required: ['name'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_list_project_links',
    description: 'List links for a project.',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 }
      },
      required: ['project_id'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_create_project_link',
    description: 'Create one link inside a project.',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 },
        url: { type: 'string', minLength: 1 },
        anchor_text: { type: 'string' },
        position: { type: 'integer', minimum: 1 },
        usage_limit: { type: 'integer', minimum: 1 },
        html_context: { type: 'string' },
        image_url: { type: 'string' },
        auto_sidebar_image: { type: 'boolean' }
      },
      required: ['project_id', 'url'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_bulk_create_project_links',
    description: 'Create multiple project links in one request.',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 },
        links: {
          type: 'array',
          minItems: 1,
          maxItems: 200,
          items: {
            type: 'object',
            properties: {
              url: { type: 'string', minLength: 1 },
              anchor_text: { type: 'string' },
              position: { type: 'integer', minimum: 1 },
              usage_limit: { type: 'integer', minimum: 1 },
              html_context: { type: 'string' },
              image_url: { type: 'string' },
              auto_sidebar_image: { type: 'boolean' }
            },
            required: ['url'],
            additionalProperties: false
          }
        }
      },
      required: ['project_id', 'links'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_generate_link_contexts',
    description: 'Generate AI HTML contexts for project links.',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 },
        entries: {
          type: 'array',
          minItems: 1,
          maxItems: 30,
          items: {
            type: 'object',
            properties: {
              url: { type: 'string', minLength: 1 },
              anchor_text: { type: 'string', minLength: 1 }
            },
            required: ['url', 'anchor_text'],
            additionalProperties: false
          }
        },
        dilute_anchors: { type: 'boolean' },
        variants_per_entry: { type: 'integer', minimum: 1, maximum: 15 },
        dilution_language: { type: 'string' }
      },
      required: ['project_id', 'entries'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_list_project_articles',
    description: 'List project articles (guest-post content inventory).',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 }
      },
      required: ['project_id'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_create_project_article',
    description:
      'Create a project article (guest post draft). Use HTML content (usually 1000-5000 words). API constraints: 1 to 2 external links, absolute URLs only (no relative links). Table tags are supported (table/thead/tbody/tfoot/tr/td/th). Prefer topic-specific titles and avoid placeholders like "content article", "guest post", "article 1".',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 },
        title: { type: 'string', minLength: 1 },
        content: { type: 'string', minLength: 1 },
        excerpt: { type: 'string' },
        meta_title: { type: 'string' },
        meta_description: { type: 'string' },
        featured_image: { type: 'string' },
        slug: { type: 'string' },
        tags: { type: 'array', items: { type: 'string' } },
        category: { type: 'string' },
        auto_featured: { type: 'boolean' }
      },
      required: ['project_id', 'title', 'content'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_generate_article_title',
    description:
      'Generate AI title for guest-post/article content. Returns one concise, topic-specific title (no placeholder patterns like "content article" or "batch 3").',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 },
        content: { type: 'string', minLength: 1 }
      },
      required: ['project_id', 'content'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_generate_and_create_project_articles',
    description:
      'Generate article(s) from a custom user prompt via OpenAI API and add them to a project. Uses your prompt as primary instruction (no rigid template). Enforces API limits: 1000-5000 words, 1 to 2 external links, absolute URLs only, and structured headings (h2/h3/h4).',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 },
        prompt: { type: 'string', minLength: 1 },
        source_html: { type: 'string' },
        count: { type: 'integer', minimum: 1, maximum: 10 },
        model: { type: 'string' },
        create_in_project: { type: 'boolean' }
      },
      required: ['project_id', 'prompt'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_list_marketplace_sites',
    description: 'List marketplace sites available for buying placements.',
    inputSchema: {
      type: 'object',
      properties: {},
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_bulk_update_site_limits',
    description:
      'Bulk update site limits for current user. Supports max_links and max_articles with site_type filter (all/static_php/wordpress), dry-run mode, and retry on 429 rate limit.',
    inputSchema: {
      type: 'object',
      properties: {
        site_type: { type: 'string', enum: ['all', 'static_php', 'wordpress'] },
        target_max_links: { type: 'integer', minimum: 0 },
        target_max_articles: { type: 'integer', minimum: 0 },
        only_if_below: { type: 'boolean' },
        dry_run: { type: 'boolean' },
        site_ids: {
          type: 'array',
          minItems: 1,
          maxItems: 2000,
          items: { type: 'integer', minimum: 1 }
        },
        max_retries: { type: 'integer', minimum: 1, maximum: 10 },
        retry_delay_ms: { type: 'integer', minimum: 200, maximum: 10000 },
        allow_static_article_limit_change: { type: 'boolean' }
      },
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_get_balance',
    description: 'Get billing balance and discount info.',
    inputSchema: {
      type: 'object',
      properties: {},
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_get_pricing',
    description: 'Get current pricing for link and article placements.',
    inputSchema: {
      type: 'object',
      properties: {},
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_purchase_link',
    description:
      'Buy a link placement for selected project + site + link content. For delayed batches, set distinct scheduled_date per day (do not reuse one date for all placements).',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 },
        site_id: { type: 'integer', minimum: 1 },
        link_id: { type: 'integer', minimum: 1 },
        scheduled_date: { type: 'string' },
        auto_renewal: { type: 'boolean' },
        use_rental_slot: { type: 'boolean' },
        idempotency_key: { type: 'string' }
      },
      required: ['project_id', 'site_id', 'link_id'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_purchase_guest_post',
    description:
      'Buy an article placement (guest-post publication) for selected project + site + article. Before bulk article buys, verify site REST endpoint health (if /wp-json/... create-article returns 301/302, publication can fail until site/plugin redirect rules are fixed). For delayed batches, set distinct scheduled_date per day (do not reuse one date for all placements).',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 },
        site_id: { type: 'integer', minimum: 1 },
        article_id: { type: 'integer', minimum: 1 },
        scheduled_date: { type: 'string' },
        auto_renewal: { type: 'boolean' },
        use_rental_slot: { type: 'boolean' },
        idempotency_key: { type: 'string' }
      },
      required: ['project_id', 'site_id', 'article_id'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_list_placements',
    description: 'List purchased placements with optional filters.',
    inputSchema: {
      type: 'object',
      properties: {
        page: { type: 'integer', minimum: 1 },
        limit: { type: 'integer', minimum: 1, maximum: 5000 },
        project_id: { type: 'integer', minimum: 1 },
        site_id: { type: 'integer', minimum: 1 },
        type: { type: 'string', enum: ['link', 'article'] },
        status: { type: 'string' },
        placement_origin: { type: 'string', enum: ['paid', 'exchange', 'rental'] }
      },
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_get_available_sites_for_project',
    description: 'Get sites available for purchase for a project.',
    inputSchema: {
      type: 'object',
      properties: {
        project_id: { type: 'integer', minimum: 1 }
      },
      required: ['project_id'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_call_api',
    description:
      'Universal Link Manager API caller for full endpoint coverage. Works with any /api/* path; /api/admin/* requires LM_MCP_ENABLE_ADMIN_TOOLS=true.',
    inputSchema: {
      type: 'object',
      properties: {
        method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
        path: { type: 'string', minLength: 5 },
        query: { type: 'object', additionalProperties: true },
        body: {
          anyOf: [
            { type: 'object', additionalProperties: true },
            { type: 'array' },
            { type: 'string' },
            { type: 'number' },
            { type: 'boolean' },
            { type: 'null' }
          ]
        }
      },
      required: ['method', 'path'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_call_admin_api',
    description:
      'Universal admin API caller for full admin coverage. Requires /api/admin/* path and LM_MCP_ENABLE_ADMIN_TOOLS=true.',
    inputSchema: {
      type: 'object',
      properties: {
        method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
        path: { type: 'string', minLength: 10 },
        query: { type: 'object', additionalProperties: true },
        body: {
          anyOf: [
            { type: 'object', additionalProperties: true },
            { type: 'array' },
            { type: 'string' },
            { type: 'number' },
            { type: 'boolean' },
            { type: 'null' }
          ]
        }
      },
      required: ['method', 'path'],
      additionalProperties: false
    }
  },
  {
    name: 'link_manager_mcp_status',
    description: 'Show MCP auth/status flags (base URL, role, admin-tools mode).',
    inputSchema: {
      type: 'object',
      properties: {},
      additionalProperties: false
    }
  }
];

const toolByName = new Map(tools.map(tool => [tool.name, tool]));

const compactUndefined = object => {
  const output = {};
  for (const [key, value] of Object.entries(object || {})) {
    if (value !== undefined) {
      output[key] = value;
    }
  }
  return output;
};

const createPurchasePayload = ({
  projectId,
  siteId,
  type,
  contentId,
  scheduledDate,
  autoRenewal,
  useRentalSlot,
  idempotencyKey
}) =>
  compactUndefined({
    projectId,
    siteId,
    type,
    contentIds: [contentId],
    scheduledDate,
    autoRenewal,
    useRentalSlot,
    idempotencyKey
  });

const extractRowsArray = payload => {
  if (Array.isArray(payload?.data)) return payload.data;
  if (Array.isArray(payload)) return payload;
  return [];
};

const executeTool = async (client, name, args = {}) => {
  switch (name) {
    case 'link_manager_get_profile': {
      return client.request({ method: 'GET', path: '/api/users/profile' });
    }

    case 'link_manager_list_projects': {
      const params = compactUndefined({
        page: args.page,
        limit: args.limit
      });

      return client.request({ method: 'GET', path: '/api/projects', params });
    }

    case 'link_manager_create_project': {
      const data = compactUndefined({
        name: args.name,
        description: args.description,
        main_site_url: args.main_site_url
      });

      return client.request({ method: 'POST', path: '/api/projects', data });
    }

    case 'link_manager_list_project_links': {
      return client.request({
        method: 'GET',
        path: `/api/projects/${args.project_id}/links`
      });
    }

    case 'link_manager_create_project_link': {
      const data = compactUndefined({
        url: args.url,
        anchor_text: args.anchor_text,
        position: args.position,
        usage_limit: args.usage_limit,
        html_context: args.html_context,
        image_url: args.image_url,
        auto_sidebar_image: args.auto_sidebar_image
      });

      return client.request({
        method: 'POST',
        path: `/api/projects/${args.project_id}/links`,
        data
      });
    }

    case 'link_manager_bulk_create_project_links': {
      return client.request({
        method: 'POST',
        path: `/api/projects/${args.project_id}/links/bulk`,
        data: { links: args.links }
      });
    }

    case 'link_manager_generate_link_contexts': {
      const data = compactUndefined({
        entries: args.entries,
        dilute_anchors: args.dilute_anchors,
        variants_per_entry: args.variants_per_entry,
        dilution_language: args.dilution_language
      });

      return client.request({
        method: 'POST',
        path: `/api/projects/${args.project_id}/links/ai-generate`,
        data
      });
    }

    case 'link_manager_list_project_articles': {
      return client.request({
        method: 'GET',
        path: `/api/projects/${args.project_id}/articles`
      });
    }

    case 'link_manager_create_project_article': {
      const data = compactUndefined({
        title: args.title,
        content: args.content,
        excerpt: args.excerpt,
        meta_title: args.meta_title,
        meta_description: args.meta_description,
        featured_image: args.featured_image,
        slug: args.slug,
        tags: args.tags,
        category: args.category,
        auto_featured: args.auto_featured
      });

      return client.request({
        method: 'POST',
        path: `/api/projects/${args.project_id}/articles`,
        data
      });
    }

    case 'link_manager_generate_article_title': {
      return client.request({
        method: 'POST',
        path: `/api/projects/${args.project_id}/articles/generate-title`,
        data: { content: args.content }
      });
    }

    case 'link_manager_generate_and_create_project_articles': {
      const prompt = String(args.prompt || '').trim();
      if (!prompt) {
        throw new Error('prompt is required.');
      }

      const sourceHtml = String(args.source_html || '');
      const count = toInt(args.count, 1);
      const total = Math.min(Math.max(count, 1), 10);
      const createInProject = args.create_in_project !== false;
      const model = String(args.model || '').trim() || client.openAiModel || OPENAI_DEFAULT_MODEL;

      const generatedItems = [];

      for (let index = 1; index <= total; index += 1) {
        let parsed = null;
        let validation = null;
        let retryErrors = [];
        let attempts = 0;

        while (attempts < 3) {
          attempts += 1;
          const messages = buildArticleGenerationMessages({
            userPrompt: prompt,
            sourceHtml,
            articleIndex: index,
            totalArticles: total,
            retryErrors
          });

          parsed = await openAiChatCompletion({
            apiKey: client.openAiApiKey,
            model,
            messages,
            timeoutMs: client.timeoutMs
          });

          const title = String(parsed?.title || '').trim();
          const contentHtml = String(parsed?.content_html || '').trim();

          if (!title || !contentHtml) {
            retryErrors = ['missing_required_fields:title_or_content_html'];
            continue;
          }

          validation = validateGeneratedArticleForApi(contentHtml);
          if (validation.valid) {
            break;
          }

          retryErrors = validation.errors;
        }

        if (!parsed || !validation || !validation.valid) {
          throw new Error(
            `Failed to generate valid article ${index}/${total}. Last errors: ${retryErrors.join(', ')}`
          );
        }

        const titleRaw = String(parsed.title || '').trim();
        const finalTitle = titleRaw.slice(0, 60) || `Article ${Date.now()}-${index}`;
        const contentHtml = String(parsed.content_html || '').trim();
        const featuredImageRaw = String(parsed.featured_image || '').trim();
        const isFeaturedHttp = /^https?:\/\//i.test(featuredImageRaw);
        const slugBase = slugify(finalTitle) || `article-${Date.now()}-${index}`;
        const finalSlug = `${slugBase}-${Date.now()}-${index}`.slice(0, 120);
        const metaTitleRaw = String(parsed.meta_title || '').trim();
        const metaDescriptionRaw = String(parsed.meta_description || '').trim();

        let createdArticle = null;
        if (createInProject) {
          const data = compactUndefined({
            title: finalTitle,
            content: contentHtml,
            meta_title: metaTitleRaw || finalTitle,
            meta_description: metaDescriptionRaw || undefined,
            featured_image: isFeaturedHttp ? featuredImageRaw : undefined,
            slug: finalSlug
          });

          createdArticle = await client.request({
            method: 'POST',
            path: `/api/projects/${args.project_id}/articles`,
            data
          });
        }

        generatedItems.push({
          generated_index: index,
          title: finalTitle,
          meta_title: metaTitleRaw || finalTitle,
          meta_description: metaDescriptionRaw || null,
          featured_image: isFeaturedHttp ? featuredImageRaw : null,
          word_count: validation.wordCount,
          heading_counts: validation.headingCounts,
          external_links: validation.externalLinks,
          content_html: createInProject ? undefined : contentHtml,
          article: createdArticle
        });
      }

      return {
        project_id: args.project_id,
        count_requested: total,
        created_in_project: createInProject,
        model,
        items: generatedItems
      };
    }

    case 'link_manager_list_marketplace_sites': {
      return client.request({ method: 'GET', path: '/api/sites/marketplace' });
    }

    case 'link_manager_bulk_update_site_limits': {
      const targetMaxLinks =
        args.target_max_links === undefined || args.target_max_links === null
          ? null
          : toInt(args.target_max_links, NaN);
      const targetMaxArticles =
        args.target_max_articles === undefined || args.target_max_articles === null
          ? null
          : toInt(args.target_max_articles, NaN);

      if (
        (targetMaxLinks === null || !Number.isFinite(targetMaxLinks)) &&
        (targetMaxArticles === null || !Number.isFinite(targetMaxArticles))
      ) {
        throw new Error('Provide at least one target limit: target_max_links and/or target_max_articles.');
      }

      const siteType = String(args.site_type || 'all')
        .trim()
        .toLowerCase();
      if (!['all', 'static_php', 'wordpress'].includes(siteType)) {
        throw new Error('site_type must be one of: all, static_php, wordpress.');
      }

      const onlyIfBelow = args.only_if_below !== false;
      const dryRun = args.dry_run === true;
      const allowStaticArticleLimitChange = args.allow_static_article_limit_change === true;
      const maxRetries = Math.min(Math.max(toInt(args.max_retries, 4), 1), 10);
      const retryDelayMs = Math.min(Math.max(toInt(args.retry_delay_ms, 1200), 200), 10000);
      const siteIdsFilter = Array.isArray(args.site_ids)
        ? new Set(args.site_ids.map(id => toInt(id, 0)).filter(id => Number.isFinite(id) && id > 0))
        : null;

      // Fetch all user sites (paginated API).
      const allSites = [];
      let page = 1;
      const limit = 5000;

      while (page <= 100) {
        const response = await client.request({
          method: 'GET',
          path: '/api/sites',
          params: { page, limit }
        });

        const rows = extractRowsArray(response);
        allSites.push(...rows);

        const hasNext = response?.pagination?.hasNext === true;
        if (!hasNext) break;
        page += 1;
      }

      let filteredSites = allSites;
      if (siteType !== 'all') {
        filteredSites = filteredSites.filter(
          site => String(site?.site_type || '').toLowerCase() === siteType
        );
      }
      if (siteIdsFilter && siteIdsFilter.size > 0) {
        filteredSites = filteredSites.filter(site => siteIdsFilter.has(toInt(site?.id, 0)));
      }

      const updatePlans = [];
      const unchangedSites = [];

      for (const site of filteredSites) {
        const siteId = toInt(site?.id, 0);
        const currentLinks = toInt(site?.max_links, 0);
        const currentArticles = toInt(site?.max_articles, 0);
        const siteTypeValue = String(site?.site_type || '').toLowerCase();

        const patch = {};
        const notes = [];

        if (targetMaxLinks !== null && Number.isFinite(targetMaxLinks)) {
          const shouldChangeLinks = onlyIfBelow ? currentLinks < targetMaxLinks : currentLinks !== targetMaxLinks;
          if (shouldChangeLinks) patch.max_links = targetMaxLinks;
        }

        if (targetMaxArticles !== null && Number.isFinite(targetMaxArticles)) {
          if (siteTypeValue === 'static_php' && !allowStaticArticleLimitChange) {
            notes.push('target_max_articles skipped for static_php');
          } else {
            const shouldChangeArticles = onlyIfBelow
              ? currentArticles < targetMaxArticles
              : currentArticles !== targetMaxArticles;
            if (shouldChangeArticles) patch.max_articles = targetMaxArticles;
          }
        }

        if (Object.keys(patch).length === 0) {
          unchangedSites.push({
            id: siteId,
            site_url: site?.site_url || null,
            site_type: siteTypeValue || null,
            max_links: currentLinks,
            max_articles: currentArticles,
            notes
          });
          continue;
        }

        updatePlans.push({
          id: siteId,
          site_url: site?.site_url || null,
          site_type: siteTypeValue || null,
          from_max_links: currentLinks,
          from_max_articles: currentArticles,
          patch,
          notes
        });
      }

      if (dryRun) {
        return {
          dry_run: true,
          site_type_filter: siteType,
          only_if_below: onlyIfBelow,
          target_max_links: targetMaxLinks,
          target_max_articles: targetMaxArticles,
          total_sites_loaded: allSites.length,
          eligible_sites_count: filteredSites.length,
          planned_updates_count: updatePlans.length,
          unchanged_count: unchangedSites.length,
          planned_updates: updatePlans,
          unchanged_sites: unchangedSites
        };
      }

      const updatedSites = [];
      const failedSites = [];
      let rateLimitRetries = 0;

      for (const plan of updatePlans) {
        let attempt = 0;
        let done = false;

        while (attempt < maxRetries && !done) {
          attempt += 1;
          try {
            const response = await client.request({
              method: 'PUT',
              path: `/api/sites/${plan.id}`,
              data: plan.patch
            });

            const updated = response?.data || response;
            updatedSites.push({
              id: plan.id,
              site_url: plan.site_url,
              site_type: plan.site_type,
              from_max_links: plan.from_max_links,
              from_max_articles: plan.from_max_articles,
              to_max_links: toInt(updated?.max_links, plan.patch.max_links ?? plan.from_max_links),
              to_max_articles: toInt(
                updated?.max_articles,
                plan.patch.max_articles ?? plan.from_max_articles
              ),
              patch: plan.patch,
              attempt
            });
            done = true;
          } catch (error) {
            const status = error instanceof ApiError ? error.status : null;
            if (status === 429 && attempt < maxRetries) {
              rateLimitRetries += 1;
              await sleep(retryDelayMs * attempt);
              continue;
            }

            failedSites.push({
              id: plan.id,
              site_url: plan.site_url,
              site_type: plan.site_type,
              patch: plan.patch,
              attempt,
              status,
              error: error?.message || 'Unknown error'
            });
            done = true;
          }
        }
      }

      return {
        dry_run: false,
        site_type_filter: siteType,
        only_if_below: onlyIfBelow,
        target_max_links: targetMaxLinks,
        target_max_articles: targetMaxArticles,
        total_sites_loaded: allSites.length,
        eligible_sites_count: filteredSites.length,
        planned_updates_count: updatePlans.length,
        unchanged_count: unchangedSites.length,
        updated_count: updatedSites.length,
        failed_count: failedSites.length,
        rate_limit_retries: rateLimitRetries,
        updated_sites: updatedSites,
        failed_sites: failedSites,
        unchanged_sites: unchangedSites
      };
    }

    case 'link_manager_get_balance': {
      return client.request({ method: 'GET', path: '/api/billing/balance' });
    }

    case 'link_manager_get_pricing': {
      return client.request({ method: 'GET', path: '/api/billing/pricing' });
    }

    case 'link_manager_purchase_link': {
      const data = createPurchasePayload({
        projectId: args.project_id,
        siteId: args.site_id,
        type: 'link',
        contentId: args.link_id,
        scheduledDate: args.scheduled_date,
        autoRenewal: args.auto_renewal,
        useRentalSlot: args.use_rental_slot,
        idempotencyKey: args.idempotency_key
      });

      return client.request({ method: 'POST', path: '/api/billing/purchase', data });
    }

    case 'link_manager_purchase_guest_post': {
      const data = createPurchasePayload({
        projectId: args.project_id,
        siteId: args.site_id,
        type: 'article',
        contentId: args.article_id,
        scheduledDate: args.scheduled_date,
        autoRenewal: args.auto_renewal,
        useRentalSlot: args.use_rental_slot,
        idempotencyKey: args.idempotency_key
      });

      return client.request({ method: 'POST', path: '/api/billing/purchase', data });
    }

    case 'link_manager_list_placements': {
      const params = compactUndefined({
        page: args.page,
        limit: args.limit,
        project_id: args.project_id,
        site_id: args.site_id,
        type: args.type,
        status: args.status,
        placement_origin: args.placement_origin
      });

      return client.request({ method: 'GET', path: '/api/placements', params });
    }

    case 'link_manager_get_available_sites_for_project': {
      return client.request({
        method: 'GET',
        path: `/api/placements/available-sites/${args.project_id}`
      });
    }

    case 'link_manager_call_api': {
      const method = normalizeHttpMethod(args.method);
      const path = assertApiPath(args.path);
      assertPathScope({ client, path, requireAdminPath: false });

      return client.request({
        method,
        path,
        params: args.query,
        data: args.body
      });
    }

    case 'link_manager_call_admin_api': {
      const method = normalizeHttpMethod(args.method);
      const path = assertApiPath(args.path);
      assertPathScope({ client, path, requireAdminPath: true });

      return client.request({
        method,
        path,
        params: args.query,
        data: args.body
      });
    }

    case 'link_manager_mcp_status': {
      await client.ensureReady();
      return {
        base_url: client.baseUrl,
        role: client.currentRole,
        enforce_non_admin: client.enforceNonAdmin,
        admin_tools_enabled: client.enableAdminTools,
        openai_configured: !!client.openAiApiKey,
        openai_model: client.openAiModel,
        has_access_token: !!client.accessToken,
        cookies_present: Array.from(client.cookies.keys())
      };
    }

    default:
      throw new Error(`Unknown tool: ${name}`);
  }
};

const jsonTextContent = payload => ({
  content: [
    {
      type: 'text',
      text: JSON.stringify(payload, null, 2)
    }
  ]
});

const createServer = () => {
  const client = new LinkManagerClient();

  const server = new Server(
    {
      name: SERVER_NAME,
      version: SERVER_VERSION
    },
    {
      capabilities: {
        tools: {}
      }
    }
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));

  server.setRequestHandler(CallToolRequestSchema, async request => {
    const { name, arguments: args = {} } = request.params;

    if (!toolByName.has(name)) {
      return {
        ...jsonTextContent({
          ok: false,
          error: `Unknown tool: ${name}`
        }),
        isError: true
      };
    }

    try {
      const result = await executeTool(client, name, args);

      return {
        ...jsonTextContent({ ok: true, tool: name, data: result }),
        isError: false
      };
    } catch (error) {
      const isApiError = error instanceof ApiError;

      return {
        ...jsonTextContent({
          ok: false,
          tool: name,
          error: error?.message || 'Unknown error',
          status: isApiError ? error.status : null,
          details: isApiError ? error.payload : null
        }),
        isError: true
      };
    }
  });

  return server;
};

const main = async () => {
  if (process.argv.includes('--list-tools')) {
    process.stdout.write(`${JSON.stringify(tools, null, 2)}\n`);
    return;
  }

  const server = createServer();
  const transport = new StdioServerTransport();
  await server.connect(transport);
};

process.on('unhandledRejection', error => {
  const message = error instanceof Error ? error.stack || error.message : String(error);
  process.stderr.write(`[${SERVER_NAME}] Unhandled rejection: ${message}\n`);
});

process.on('uncaughtException', error => {
  const message = error instanceof Error ? error.stack || error.message : String(error);
  process.stderr.write(`[${SERVER_NAME}] Uncaught exception: ${message}\n`);
  process.exit(1);
});

main().catch(error => {
  const message = error instanceof Error ? error.stack || error.message : String(error);
  process.stderr.write(`[${SERVER_NAME}] Fatal error: ${message}\n`);
  process.exit(1);
});
