import * as http from 'http';
import * as https from 'https';
import * as fs from 'fs';
import * as path from 'path';
import * as childProcess from 'child_process';
import { createLogger } from '../logger';
import {
  TsukihimeApiResponse,
  TsukihimeDownloadResult,
  TsukihimeEntry,
  TsukihimeSubtitleFile,
} from '../types';

// Backend: TsukiHime (https://tsukihime.org), the community successor to
// Animetosho (read-only from May 2026). TsukiHime imported the Animetosho
// index, so "animetosho" still appears here where it names the upstream
// service: the imported-entry flag, the /tosho/ storage mirror, and the
// redirect host that mirror currently points at.
const logger = createLogger('main:tsukihime');

export const TSUKIHIME_API_BASE_URL = 'https://api.tsukihime.org/v1';
export const TSUKIHIME_STORAGE_BASE_URL = 'https://storage.tsukihime.org';

// Entries imported from the Animetosho index sit in this id range and their
// attachments live under the /tosho/ mirror path on the storage host.
const IMPORTED_ANIMETOSHO_ID_FLOOR = 1_000_000_000;

const TEXT_SUBTITLE_EXTENSIONS: Record<string, string> = {
  ass: '.ass',
  ssa: '.ssa',
  srt: '.srt',
  subrip: '.srt',
};

const XZ_MAX_SUBTITLE_BYTES = 64 * 1024 * 1024;
const TSUKIHIME_REQUEST_TIMEOUT_MS = 15000;
const TSUKIHIME_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;

export {
  tsukihimeLangToFilenameSuffix,
  tsukihimeTrackMatchesLanguages,
  describeTsukihimeTabLanguages,
  normalizeTsukihimeLangCode,
} from './lang';

export function isTsukihimeDownloadUrl(url: string | URL): boolean {
  try {
    const parsed = typeof url === 'string' ? new URL(url) : url;
    if (parsed.protocol !== 'https:') return false;
    // The /tosho/ mirror currently 302s to storage.animetosho.org, so both
    // hosts must stay allowed for the redirect hop.
    const allowedHosts = ['tsukihime.org', 'animetosho.org'];
    return allowedHosts.some(
      (host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`),
    );
  } catch {
    return false;
  }
}

export function buildTsukihimeAttachmentUrl(
  attachmentId: number,
  options?: { imported?: boolean },
): string | null {
  if (!Number.isInteger(attachmentId) || attachmentId <= 0) return null;
  const hexId = attachmentId.toString(16).padStart(8, '0');
  const attachPath = options?.imported ? '/tosho/attach/' : '/attach/';
  return `${TSUKIHIME_STORAGE_BASE_URL}${attachPath}${hexId}/${attachmentId}.xz`;
}

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function asFiniteNumber(value: unknown): number | null {
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

export function mapTsukihimeSearchResults(payload: unknown, maxResults: number): TsukihimeEntry[] {
  if (!isObject(payload) || !Array.isArray(payload.results)) return [];
  const entries: TsukihimeEntry[] = [];
  for (const item of payload.results) {
    if (entries.length >= maxResults) break;
    if (!isObject(item)) continue;
    const id = item.id;
    const title = item.name;
    if (!Number.isInteger(id) || typeof title !== 'string' || !title) continue;
    const sublangs = Array.isArray(item.sublangs)
      ? item.sublangs.filter((lang): lang is string => typeof lang === 'string')
      : [];
    entries.push({
      id: id as number,
      title,
      timestamp: asFiniteNumber(item.added_date),
      totalSize: asFiniteNumber(item.totalsize),
      numFiles: asFiniteNumber(item.filecount),
      sublangs,
    });
  }
  return entries;
}

function stripFileExtension(name: string): string {
  const ext = path.extname(name);
  return ext ? name.slice(0, -ext.length) : name;
}

function slugifyTrackName(value: string): string {
  return value
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

function subtitleLangRank(lang: string): number {
  if (lang.startsWith('en')) return 0;
  if (!lang || lang === 'und') return 1;
  return 2;
}

interface RawSubtitleAttachment {
  attachmentId: number;
  lang: string;
  trackName: string | null;
  size: number;
  url: string;
  sourceFilename: string;
  extension: string;
}

const SUBTITLE_ATTACHMENT_TYPE = 1;

function isImportedAnimetoshoEntry(payload: Record<string, unknown>): boolean {
  if (payload.animetosho === true) return true;
  const id = asFiniteNumber(payload.id);
  return id !== null && id >= IMPORTED_ANIMETOSHO_ID_FLOOR;
}

function collectSubtitleAttachments(payload: unknown): RawSubtitleAttachment[] {
  if (!isObject(payload) || !Array.isArray(payload.files)) return [];
  const imported = isImportedAnimetoshoEntry(payload);
  const collected: RawSubtitleAttachment[] = [];
  for (const file of payload.files) {
    if (!isObject(file) || !Array.isArray(file.attachments)) continue;
    const sourceFilename = typeof file.filename === 'string' && file.filename ? file.filename : '';
    for (const attachment of file.attachments) {
      if (!isObject(attachment) || attachment.type !== SUBTITLE_ATTACHMENT_TYPE) continue;
      const attachmentId = attachment.id;
      if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) continue;
      const info = isObject(attachment.info) ? attachment.info : {};
      const codec = typeof info.codec === 'string' ? info.codec.toLowerCase() : '';
      const extension = TEXT_SUBTITLE_EXTENSIONS[codec];
      if (!extension) continue;
      const url = buildTsukihimeAttachmentUrl(attachmentId as number, { imported });
      if (!url) continue;
      collected.push({
        attachmentId: attachmentId as number,
        lang: typeof info.lang === 'string' ? info.lang.toLowerCase() : '',
        trackName: typeof info.name === 'string' && info.name ? info.name : null,
        size: asFiniteNumber(attachment.size) ?? 0,
        url,
        sourceFilename,
        extension,
      });
    }
  }
  return collected;
}

export function extractTsukihimeSubtitleFiles(payload: unknown): TsukihimeSubtitleFile[] {
  const collected = collectSubtitleAttachments(payload);

  const duplicateKeyCounts = new Map<string, number>();
  const duplicateTrackSlugCounts = new Map<string, number>();
  for (const attachment of collected) {
    const key = `${attachment.sourceFilename} ${attachment.lang}`;
    duplicateKeyCounts.set(key, (duplicateKeyCounts.get(key) ?? 0) + 1);
    const trackSlug = attachment.trackName ? slugifyTrackName(attachment.trackName) : '';
    const trackKey = `${key} ${trackSlug}`;
    duplicateTrackSlugCounts.set(trackKey, (duplicateTrackSlugCounts.get(trackKey) ?? 0) + 1);
  }

  const files = collected.map((attachment) => {
    const base = attachment.sourceFilename
      ? stripFileExtension(attachment.sourceFilename)
      : 'subtitle';
    const langPart = attachment.lang ? `.${attachment.lang}` : '';
    const key = `${attachment.sourceFilename} ${attachment.lang}`;
    // A CJK-only track name (e.g. 日本語字幕) slugifies to an empty string, which
    // would leave a bare dot in the filename.
    const trackSlug = attachment.trackName ? slugifyTrackName(attachment.trackName) : '';
    const needsTrackSuffix = (duplicateKeyCounts.get(key) ?? 0) > 1;
    const repeatedTrackSlug = (duplicateTrackSlugCounts.get(`${key} ${trackSlug}`) ?? 0) > 1;
    const trackPart = needsTrackSuffix
      ? `.${!trackSlug || repeatedTrackSlug ? attachment.attachmentId : trackSlug}`
      : '';
    return {
      attachmentId: attachment.attachmentId,
      filename: `${base}${langPart}${trackPart}${attachment.extension}`,
      lang: attachment.lang,
      trackName: attachment.trackName,
      size: attachment.size,
      url: attachment.url,
      sourceFilename: attachment.sourceFilename,
    };
  });

  return files
    .map((file, index) => ({ file, index }))
    .sort((a, b) => {
      const rankDiff = subtitleLangRank(a.file.lang) - subtitleLangRank(b.file.lang);
      if (rankDiff !== 0) return rankDiff;
      return a.index - b.index;
    })
    .map(({ file }) => file);
}

export async function tsukihimeFetchJson<T>(
  endpoint: string,
  query: Record<string, string | number | boolean | null | undefined>,
  options: { baseUrl: string; timeoutMs?: number; maxResponseBytes?: number },
): Promise<TsukihimeApiResponse<T>> {
  // Concatenate instead of new URL(endpoint, base): the API base ends in a
  // path segment (/v1) that URL resolution would drop for absolute endpoints.
  // baseUrl comes from user config, so a bad value must not throw here.
  let url: URL;
  try {
    url = new URL(`${options.baseUrl.replace(/\/+$/, '')}${endpoint}`);
  } catch {
    logger.error(`Invalid TsukiHime API base URL: ${options.baseUrl}`);
    return { ok: false, error: { error: 'Invalid TsukiHime API base URL.' } };
  }
  if (url.protocol !== 'https:' && url.protocol !== 'http:') {
    logger.error(`Unsupported TsukiHime API protocol: ${url.protocol}`);
    return { ok: false, error: { error: 'TsukiHime API URL must use HTTP or HTTPS.' } };
  }
  for (const [key, value] of Object.entries(query)) {
    if (value === null || value === undefined) continue;
    url.searchParams.set(key, String(value));
  }

  const timeoutMs = options.timeoutMs ?? TSUKIHIME_REQUEST_TIMEOUT_MS;
  const maxResponseBytes = options.maxResponseBytes ?? TSUKIHIME_MAX_RESPONSE_BYTES;

  logger.debug(`GET ${url.toString()}`);
  const transport = url.protocol === 'https:' ? https : http;

  return new Promise((resolve) => {
    let settled = false;
    let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
    const settle = (result: TsukihimeApiResponse<T>): void => {
      if (settled) return;
      settled = true;
      if (timeoutHandle) clearTimeout(timeoutHandle);
      resolve(result);
    };

    const req = transport.request(
      url,
      {
        method: 'GET',
        headers: { 'User-Agent': 'SubMiner' },
      },
      (res) => {
        // Buffer raw chunks and decode once: a multi-byte UTF-8 character
        // (e.g. a Japanese title) can straddle a chunk boundary.
        const chunks: Buffer[] = [];
        let receivedBytes = 0;
        res.on('data', (chunk: Buffer) => {
          receivedBytes += chunk.length;
          if (receivedBytes > maxResponseBytes) {
            logger.error(`TsukiHime response exceeded ${maxResponseBytes} bytes; aborting.`);
            // Settle before destroying: destroy can emit 'end' synchronously.
            settle({
              ok: false,
              error: { error: 'TsukiHime response was too large.' },
            });
            req.destroy();
            return;
          }
          chunks.push(chunk);
        });
        res.on('end', () => {
          if (settled) return;
          const status = res.statusCode || 0;
          logger.debug(`Response HTTP ${status} for ${endpoint}`);
          if (status >= 200 && status < 300) {
            const data = Buffer.concat(chunks).toString('utf8');
            try {
              const parsed = JSON.parse(data) as T;
              settle({ ok: true, data: parsed });
            } catch {
              logger.error(`JSON parse error: ${data.slice(0, 200)}`);
              settle({
                ok: false,
                error: { error: 'Failed to parse TsukiHime response JSON.' },
              });
            }
            return;
          }
          logger.error(`TsukiHime API error (HTTP ${status})`);
          settle({
            ok: false,
            error: { error: `TsukiHime API error (HTTP ${status})`, code: status || undefined },
          });
        });
      },
    );

    timeoutHandle = setTimeout(() => {
      logger.error(`TsukiHime request timed out after ${timeoutMs}ms: ${url.toString()}`);
      settle({
        ok: false,
        error: { error: `TsukiHime request timed out after ${timeoutMs}ms.` },
      });
      req.destroy();
    }, timeoutMs);

    req.on('error', (err) => {
      logger.error(`Network error: ${(err as Error).message}`);
      settle({
        ok: false,
        error: { error: `TsukiHime request failed: ${(err as Error).message}` },
      });
    });

    req.end();
  });
}

export async function decompressXzFile(
  srcPath: string,
  destPath: string,
): Promise<TsukihimeDownloadResult> {
  return new Promise((resolve) => {
    childProcess.execFile(
      'xz',
      ['-dc', srcPath],
      { encoding: 'buffer', maxBuffer: XZ_MAX_SUBTITLE_BYTES },
      async (err, stdout) => {
        if (err) {
          const reason =
            (err as NodeJS.ErrnoException).code === 'ENOENT'
              ? 'xz binary not found. Install xz-utils to download TsukiHime subtitles.'
              : `Failed to decompress subtitle with xz: ${err.message}`;
          logger.error(reason);
          resolve({ ok: false, error: { error: reason } });
          return;
        }
        try {
          await fs.promises.writeFile(destPath, stdout);
          resolve({ ok: true, path: destPath });
        } catch (writeErr) {
          resolve({
            ok: false,
            error: { error: `Failed to save subtitle: ${(writeErr as Error).message}` },
          });
        }
      },
    );
  });
}
