<?php
// app/services/ScannerService.php

declare(strict_types=1);

namespace ScMedia\Services;

use ScMedia\Db\Db;
use Exception;

final class ScannerService {
  private Db $db;
  private SettingsService $settings;
  private ScanProfilesService $profiles;
  private JobsService $jobs;
  private MkvToolnixService $mkvtoolnix;
  private MediaLibraryService $mediaLibrary;

  public function __construct(
    Db $db,
    SettingsService $settings,
    ScanProfilesService $profiles,
    JobsService $jobs,
    MkvToolnixService $mkvtoolnix,
    MediaLibraryService $mediaLibrary
  ) {
    $this->db = $db;
    $this->settings = $settings;
    $this->profiles = $profiles;
    $this->jobs = $jobs;
    $this->mkvtoolnix = $mkvtoolnix;
    $this->mediaLibrary = $mediaLibrary;
  }

  public function runScanJob(int $jobId): array {
    $all = $this->settings->getAll();

    $scannerDefaults = $all['scanner_defaults'] ?? [];
    $globalExt = is_array($scannerDefaults['video_ext'] ?? null) ? $scannerDefaults['video_ext'] : ['mkv','mp4','avi','mov','m4v','ts','m2ts','wmv'];

    $hardLimits = $this->getHardLimits();
    $defaultMaxDepth = (int)($scannerDefaults['max_depth_default'] ?? 3);
    $defaultMaxFilesPerItem = (int)($scannerDefaults['max_files_per_item'] ?? 3000);
    $defaultMaxItemsPerScan = (int)($scannerDefaults['max_items_per_scan'] ?? 0);

    $defaultMaxDepth = $this->cap($defaultMaxDepth, 1, (int)$hardLimits['max_depth']);
    $defaultMaxFilesPerItem = $this->cap($defaultMaxFilesPerItem, 100, (int)$hardLimits['max_files_per_item']);
    $defaultMaxItemsPerScan = $this->cap($defaultMaxItemsPerScan, 0, (int)$hardLimits['max_items_per_scan']);

    $profiles = $this->profiles->list();
    $profiles = array_values(array_filter($profiles, fn($p) => !empty($p['enabled'])));

    $this->jobs->log($jobId, 'info', 'Scan started');
    $this->jobs->setProgress($jobId, 0, max(1, count($profiles)));

    $totalItemsSeen = 0;
    $profileIndex = 0;

    foreach ($profiles as $p) {
      if ($this->jobs->cancelIfRequested($jobId)) {
        $this->jobs->log($jobId, 'warn', 'Scan canceled by user');
        break;
      }
      $profileIndex++;

      $profileId = (int)$p['id'];
      $root = (string)$p['root_path'];
      $profileType = (string)($p['profile_type'] ?? 'scan');

      $maxDepth = (int)($p['max_depth'] ?? $defaultMaxDepth);
      $maxDepth = $this->cap($maxDepth, 1, (int)$hardLimits['max_depth']);

      $exclude = $this->toStringList($p['exclude_patterns'] ?? []);
      $extMode = (string)($p['include_ext_mode'] ?? 'default');
      $ext = $globalExt;
      if ($extMode === 'custom') {
        $custom = $this->toStringList($p['include_ext'] ?? []);
        if (count($custom) > 0) $ext = $custom;
      }

      $this->jobs->log($jobId, 'info', "Profile #{$profileId} ({$profileType}): {$root}");

      if ($profileType === 'analyze') {
        $result = $this->analyzeProfileRoot(
          $jobId,
          $profileId,
          $root,
          $exclude,
          $ext,
          $maxDepth,
          $defaultMaxItemsPerScan
        );
        $this->jobs->log($jobId, 'info', "Analyze done. Files: {$result['files']} Changed: {$result['changed']} Cached: {$result['cached']}");
      } else {
        $seenAbs = $this->scanProfileRoot(
          $jobId,
          $profileId,
          $root,
          $exclude,
          $ext,
          $maxDepth,
          $defaultMaxFilesPerItem,
          $defaultMaxItemsPerScan,
          $totalItemsSeen
        );

        // Mark gone
        $this->markGone($profileId, $seenAbs);
      }

      $this->db->exec("
        UPDATE scan_profiles
        SET last_scan_at=NOW(), last_result='ok'
        WHERE id=:id
      ", [':id' => $profileId]);

      $this->jobs->setProgress($jobId, $profileIndex, count($profiles));
    }

    if ($this->jobs->isCancelRequested($jobId)) {
      return [
        'profiles' => count($profiles),
        'items_seen' => $totalItemsSeen,
        'canceled' => true,
      ];
    }

    $this->jobs->log($jobId, 'info', "Scan finished. Items seen: {$totalItemsSeen}");
    $this->jobs->setProgress($jobId, count($profiles), count($profiles));

    return [
      'profiles' => count($profiles),
      'items_seen' => $totalItemsSeen,
    ];
  }

  private function analyzeProfileRoot(
    int $jobId,
    int $profileId,
    string $root,
    array $excludePatterns,
    array $videoExt,
    int $maxDepth,
    int $maxFiles
  ): array {
    $root = rtrim($root, "/");

    if ($root === '' || !is_dir($root)) {
      $this->jobs->log($jobId, 'warn', "Analyze root not accessible: {$root}");
      return ['files' => 0, 'changed' => 0, 'cached' => 0];
    }

    if (!$this->mkvtoolnix->isAvailable()) {
      $this->jobs->log($jobId, 'warn', "mkvmerge not available, analyze limited to inventory");
    }

    $extList = $this->normExt($videoExt);
    $files = 0;
    $changed = 0;
    $cached = 0;

    $stack = [[ 'path' => $root, 'depth' => 0 ]];
    $stop = false;

    while (count($stack) > 0 && !$stop) {
      if ($this->jobs->cancelIfRequested($jobId)) {
        $this->jobs->log($jobId, 'warn', 'Analyze canceled by user');
        break;
      }
      $node = array_pop($stack);
      $path = $node['path'];
      $depth = (int)$node['depth'];

      $entries = @scandir($path);
      if (!is_array($entries)) continue;

      foreach ($entries as $name) {
        if ($this->jobs->cancelIfRequested($jobId)) {
          $this->jobs->log($jobId, 'warn', 'Analyze canceled by user');
          $stop = true;
          break;
        }
        if ($name === '.' || $name === '..') continue;
        if ($this->isExcluded($name, $excludePatterns)) continue;

        $abs = $path . '/' . $name;

        if (is_dir($abs) && $depth < $maxDepth) {
          $stack[] = ['path' => $abs, 'depth' => $depth + 1];
          continue;
        }

        if (!is_file($abs)) continue;

        $ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION));
        if ($ext === '' || !in_array($ext, $extList, true)) continue;

        $files++;
        if ($maxFiles > 0 && $files >= $maxFiles) {
          $this->jobs->log($jobId, 'warn', "Analyze max files reached: {$maxFiles}");
          $stop = true;
          break;
        }

        $stat = @stat($abs);
        $res = null;
        if ($ext === 'mkv') {
          $res = $this->mkvtoolnix->readFileData($abs);
        }
        if (is_array($stat)) {
          $this->mediaLibrary->upsertMediaFile(
            $profileId,
            $root,
            $abs,
            $stat,
            ($ext === 'mkv'),
            ($res && isset($res['mkvmerge']['data'])) ? $res['mkvmerge']['data'] : null
          );
        }
        if (is_array($res) && !empty($res['ok'])) {
          if (!empty($res['changed'])) {
            $changed++;
          } else if (!empty($res['from_cache'])) {
            $cached++;
          }
        }
      }
    }

    return ['files' => $files, 'changed' => $changed, 'cached' => $cached];
  }

  private function scanProfileRoot(
    int $jobId,
    int $profileId,
    string $root,
    array $excludePatterns,
    array $videoExt,
    int $maxDepth,
    int $maxFilesPerItem,
    int $maxItemsPerScan,
    int &$totalItemsSeen
  ): array {
    $root = rtrim($root, "/");

    if ($root === '' || !is_dir($root)) {
      $this->jobs->log($jobId, 'warn', "Root not accessible: {$root}");
      $this->db->exec("
        UPDATE scan_profiles
        SET last_scan_at=NOW(), last_result='error'
        WHERE id=:id
      ", [':id' => $profileId]);
      return [];
    }

    $entries = @scandir($root);
    if (!is_array($entries)) {
      $this->jobs->log($jobId, 'error', "Cannot read dir: {$root}");
      $this->db->exec("
        UPDATE scan_profiles
        SET last_scan_at=NOW(), last_result='error'
        WHERE id=:id
      ", [':id' => $profileId]);
      return [];
    }

    $seen = [];

    foreach ($entries as $name) {
      if ($this->jobs->cancelIfRequested($jobId)) {
        $this->jobs->log($jobId, 'warn', 'Scan canceled by user');
        break;
      }
      if ($name === '.' || $name === '..') continue;
      if ($this->isExcluded($name, $excludePatterns)) continue;

      $abs = $root . '/' . $name;

      // Respect max items per scan (if >0)
      if ($maxItemsPerScan > 0 && $totalItemsSeen >= $maxItemsPerScan) {
        $this->jobs->log($jobId, 'warn', "Max items per scan reached: {$maxItemsPerScan}");
        break;
      }

      $info = $this->inspectItem($abs, $videoExt, $maxDepth, $maxFilesPerItem);

      $display = $name;
      $structure = $info['structure'];
      $videoCount = $info['video_count'];
      $fileCount = $info['file_count'];
      $confidence = $info['confidence'];

      $this->upsertItem($profileId, $abs, $name, $display, $structure, $confidence, $videoCount, $fileCount);

      $seen[] = $abs;
      $totalItemsSeen++;
      $seenAbsKey = $abs;
      $seen[$seenAbsKey] = true;
    }

    return array_keys($seen);
  }

  private function inspectItem(string $abs, array $videoExt, int $maxDepth, int $maxFilesPerItem): array {
    if (is_file($abs)) {
      $ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION));
      $isVideo = in_array($ext, $this->normExt($videoExt), true);

      return [
        'structure' => 'file',
        'confidence' => $isVideo ? 70 : 10,
        'video_count' => $isVideo ? 1 : 0,
        'file_count' => 1,
      ];
    }

    if (!is_dir($abs)) {
      return [
        'structure' => 'folder',
        'confidence' => 0,
        'video_count' => 0,
        'file_count' => 0,
      ];
    }

    // detect dvd/bluray by marker folders
    if (is_dir($abs . '/BDMV')) {
      $counts = $this->countFiles($abs, $videoExt, $maxDepth, $maxFilesPerItem);
      return [
        'structure' => 'bluray',
        'confidence' => 90,
        'video_count' => $counts['video'],
        'file_count' => $counts['files'],
      ];
    }

    if (is_dir($abs . '/VIDEO_TS')) {
      $counts = $this->countFiles($abs, $videoExt, $maxDepth, $maxFilesPerItem);
      return [
        'structure' => 'dvd',
        'confidence' => 90,
        'video_count' => $counts['video'],
        'file_count' => $counts['files'],
      ];
    }

    $counts = $this->countFiles($abs, $videoExt, $maxDepth, $maxFilesPerItem);

    return [
      'structure' => 'folder',
      'confidence' => $counts['video'] > 0 ? 60 : 20,
      'video_count' => $counts['video'],
      'file_count' => $counts['files'],
    ];
  }

  private function countFiles(string $dir, array $videoExt, int $maxDepth, int $maxFilesPerItem): array {
    $videoExt = $this->normExt($videoExt);

    $files = 0;
    $video = 0;

    $stack = [[ 'path' => $dir, 'depth' => 0 ]];

    while (count($stack) > 0) {
      $node = array_pop($stack);
      $path = $node['path'];
      $depth = (int)$node['depth'];

      $entries = @scandir($path);
      if (!is_array($entries)) continue;

      foreach ($entries as $name) {
        if ($name === '.' || $name === '..') continue;

        $abs = $path . '/' . $name;

        if (is_file($abs)) {
          $files++;
          $ext = strtolower(pathinfo($abs, PATHINFO_EXTENSION));
          if ($ext !== '' && in_array($ext, $videoExt, true)) $video++;

          if ($files >= $maxFilesPerItem) {
            return ['files' => $files, 'video' => $video];
          }
          continue;
        }

        if (is_dir($abs) && $depth < $maxDepth) {
          $stack[] = ['path' => $abs, 'depth' => $depth + 1];
        }
      }
    }

    return ['files' => $files, 'video' => $video];
  }

  private function upsertItem(
    int $profileId,
    string $absPath,
    string $relPath,
    string $displayName,
    string $structure,
    int $confidence,
    int $videoCount,
    int $fileCount
  ): void {
    $this->db->exec("
      INSERT INTO items
        (scan_profile_id, abs_path, rel_path, display_name, structure, confidence, video_count, file_count, status, last_seen_at)
      VALUES
        (:pid, :abs, :rel, :name, :structure, :conf, :vcount, :fcount, 'active', NOW())
      ON DUPLICATE KEY UPDATE
        rel_path = VALUES(rel_path),
        display_name = VALUES(display_name),
        structure = VALUES(structure),
        confidence = VALUES(confidence),
        video_count = VALUES(video_count),
        file_count = VALUES(file_count),
        status = 'active',
        last_seen_at = NOW(),
        updated_at = NOW()
    ", [
      ':pid' => $profileId,
      ':abs' => $absPath,
      ':rel' => $relPath,
      ':name' => $displayName,
      ':structure' => $structure,
      ':conf' => $confidence,
      ':vcount' => $videoCount,
      ':fcount' => $fileCount,
    ]);
  }

  private function markGone(int $profileId, array $seenAbs): void {
    // If nothing seen, do not mark everything gone (safety)
    if (count($seenAbs) === 0) return;

    // Mark items not seen in this scan as gone
    // We do it by updating those with last_seen_at older than current scan moment.
    // A simple approach: mark all as gone, then set active for seen list (but that’s heavy).
    // Here: set gone for items where abs_path NOT IN (...) within this profile.
    // For large lists, chunk it.

    $chunkSize = 200;
    $seenSet = array_values($seenAbs);

    // First mark all as gone for this profile, then re-activate seen ones (fast and safe).
    $this->db->exec("
      UPDATE items
      SET status='gone', updated_at=NOW()
      WHERE scan_profile_id=:pid
    ", [':pid' => $profileId]);

    for ($i = 0; $i < count($seenSet); $i += $chunkSize) {
      $chunk = array_slice($seenSet, $i, $chunkSize);

      $placeholders = [];
      $params = [':pid' => $profileId];
      foreach ($chunk as $idx => $abs) {
        $k = ':p' . $idx;
        $placeholders[] = $k;
        $params[$k] = $abs;
      }

      $sql = "
        UPDATE items
        SET status='active', updated_at=NOW()
        WHERE scan_profile_id=:pid
          AND abs_path IN (" . implode(',', $placeholders) . ")
      ";

      $this->db->exec($sql, $params);
    }
  }

  private function isExcluded(string $name, array $patterns): bool {
    $n = strtolower($name);
    foreach ($patterns as $p) {
      $p = trim((string)$p);
      if ($p === '') continue;
      if (str_contains($n, strtolower($p))) return true;
    }
    return false;
  }

  private function normExt(array $ext): array {
    $out = [];
    foreach ($ext as $e) {
      $e = strtolower(trim((string)$e));
      if ($e === '') continue;
      $out[] = $e;
    }
    return array_values(array_unique($out));
  }

  private function toStringList($v): array {
    if (!is_array($v)) return [];
    $out = [];
    foreach ($v as $x) {
      $x = trim((string)$x);
      if ($x !== '') $out[] = $x;
    }
    return $out;
  }

  private function getHardLimits(): array {
    // config safety rails (GUI cannot exceed)
    $fromSettings = $this->settings->getAll()['safety'] ?? [];
    $cfg = $this->settings->getConfig();
    $limits = $fromSettings ?: ($cfg['safety']['hard_limits'] ?? []);
    return [
      'max_depth' => (int)($limits['max_depth'] ?? 10),
      'max_files_per_item' => (int)($limits['max_files_per_item'] ?? 200000),
      'max_items_per_scan' => (int)($limits['max_items_per_scan'] ?? 1000000),
    ];
  }

  private function cap(int $v, int $min, int $max): int {
    if ($v < $min) return $min;
    if ($v > $max) return $max;
    return $v;
  }
}
