// Season pack generator: builds stages along a difficulty curve, then
// calibrates each one by making the AutoPlayer bot play it repeatedly with
// the real shipping engine.
//
//   dart run tool/stage_generator/generate.dart content/season_001/spec.json
//
// Outputs pack.json next to the spec (and copies into assets/ if the spec
// says so) plus a human-readable report.md with per-stage win rates.
import 'dart:convert';
import 'dart:io';

import 'package:block_seasons/core/rng.dart';
import 'package:block_seasons/game/engine/auto_player.dart';
import 'package:block_seasons/game/models/cell.dart';
import 'package:block_seasons/game/models/grid.dart';
import 'package:block_seasons/game/models/objective.dart';
import 'package:block_seasons/game/models/season.dart';
import 'package:block_seasons/game/models/stage.dart';

void main(List<String> args) {
  if (args.isEmpty) {
    stderr.writeln('usage: dart run tool/stage_generator/generate.dart <spec.json>');
    exit(64);
  }
  final specFile = File(args[0]);
  final spec = jsonDecode(specFile.readAsStringSync()) as Map<String, dynamic>;
  final outDir = specFile.parent;

  final seasonId = spec['seasonId'] as String;
  final stageCount = spec['stageCount'] as int;
  final baseSeed = spec['baseSeed'] as int;
  final runs = (spec['runsPerStage'] as int?) ?? 80;
  final bot = AutoPlayer();

  final stages = <StageConfig>[];
  final reportRows = <String>[];
  final sw = Stopwatch()..start();

  for (var i = 0; i < stageCount; i++) {
    final t = stageCount == 1 ? 0.0 : i / (stageCount - 1);
    StageConfig? accepted;
    String? rowNote;

    for (var layout = 0; layout < 6 && accepted == null; layout++) {
      final rng = SeededRng(baseSeed ^ (i * 7919) ^ (layout * 104729));
      final candidate = _buildCandidate(seasonId, i, t, rng, bot, runs);
      if (candidate == null) continue;

      final (stage, winRate, movesLeftWins) = candidate;
      final band = _band(t);
      if (winRate < band.$1 || winRate > band.$2) {
        rowNote = 'rejected layout $layout: winRate ${_pct(winRate)}';
        continue;
      }
      accepted = stage;
      reportRows.add(
        '| ${stage.id} | ${_objLabel(stage)} | ${stage.moveLimit} | '
        '${_pct(winRate)} | ${stage.stars.twoMovesLeft}/${stage.stars.threeMovesLeft} |',
      );
    }

    if (accepted == null) {
      stderr.writeln('stage $i: no acceptable layout (${rowNote ?? "?"})');
      exit(1);
    }
    stages.add(accepted);
    stdout.writeln(
        'stage ${i + 1}/$stageCount calibrated (${sw.elapsed.inSeconds}s)');
  }

  final pack = SeasonPack(
    schemaVersion: 1,
    seasonId: seasonId,
    version: (spec['version'] as int?) ?? 1,
    title: (spec['title'] as Map<String, dynamic>)
        .map((k, v) => MapEntry(k, v as String)),
    theme: SeasonTheme.fromJson(spec['theme'] as Map<String, dynamic>),
    stages: stages,
  );

  const encoder = JsonEncoder.withIndent('  ');
  File('${outDir.path}/pack.json')
      .writeAsStringSync(encoder.convert(pack.toJson()));

  final copyTo = spec['copyToAssets'] as String?;
  if (copyTo != null) {
    File(copyTo)
      ..parent.createSync(recursive: true)
      ..writeAsStringSync(encoder.convert(pack.toJson()));
  }

  File('${outDir.path}/report.md').writeAsStringSync('''
# $seasonId difficulty report

${stages.length} stages, $runs bot runs each, generated in ${sw.elapsed.inSeconds}s.

| stage | objective | moves | bot win rate | 2★/3★ movesLeft |
|---|---|---|---|---|
${reportRows.join('\n')}
''');

  stdout.writeln('pack.json + report.md written to ${outDir.path}');
}

/// Target bot win-rate band: generous early, tight late. Human players are
/// weaker than the bot, so the floor stays high — and early stages are
/// allowed to be a guaranteed win (onboarding).
(double, double) _band(double t) => (
      // Near-guaranteed onboarding (the bot easily clears the first stages, so
      // a human does too), ramping to a genuine challenge by the season's end.
      0.97 - 0.50 * t,
      t < 0.5 ? 1.0 : 1.0 - 0.12 * (t - 0.5) / 0.5,
    );

(StageConfig, double, List<int>)? _buildCandidate(
  String seasonId,
  int index,
  double t,
  SeededRng rng,
  AutoPlayer bot,
  int runs,
) {
  final stageSeed = rng.nextInt(1 << 30);
  final preset = _samplePreset(t, rng);
  final objective = _sampleObjective(index, t, preset, rng);

  // Probe with an effectively unlimited move budget to learn how many moves
  // the bot needs, then clamp the budget to set difficulty.
  final probe = StageConfig(
    id: '${seasonId}_${(index + 1).toString().padLeft(3, '0')}',
    seed: stageSeed,
    moveLimit: 90,
    preset: preset,
    objectives: [objective],
    stars: const StarThresholds(twoMovesLeft: 1, threeMovesLeft: 2),
    generatorProfile: t < 0.33 ? 'easy' : (t < 0.66 ? 'mid' : 'hard'),
  );

  final probeRuns = runs ~/ 2;
  final movesToWin = <int>[];
  for (var a = 0; a < probeRuns; a++) {
    final run = bot.play(probe, attempt: a);
    if (run.won) movesToWin.add(run.movesUsed);
  }
  // Layout must be reliably solvable given unlimited moves.
  if (movesToWin.length < probeRuns * 0.85) return null;

  movesToWin.sort();
  final median = movesToWin[movesToWin.length ~/ 2];
  // Generous early move buffer over what the bot needs (humans place less
  // optimally and the piece RNG won't always cooperate), tightening later.
  final margin = 1.0 - 0.65 * t;
  var moveLimit = (median * (1 + margin)).ceil().clamp(6, 60);

  // Validate at the real budget; nudge the budget up/down until the win
  // rate lands inside the band (or we run out of patience).
  final band = _band(t);
  var spare = <int>[];
  var winRate = 0.0;
  var accepted = false;
  for (var adjust = 0; adjust < 8 && !accepted; adjust++) {
    spare = <int>[];
    var wins = 0;
    final real = StageConfig(
      id: probe.id,
      seed: stageSeed,
      moveLimit: moveLimit,
      preset: preset,
      objectives: [objective],
      stars: probe.stars,
      generatorProfile: probe.generatorProfile,
    );
    for (var a = 0; a < runs; a++) {
      final run = bot.play(real, attempt: 1000 + a);
      if (run.won) {
        wins++;
        spare.add(run.movesLeft);
      }
    }
    winRate = wins / runs;
    if (winRate > band.$2 && moveLimit > 6) {
      moveLimit -= (moveLimit * 0.1).ceil().clamp(1, 4);
    } else if (winRate < band.$1 && moveLimit < 60) {
      moveLimit += (moveLimit * 0.1).ceil().clamp(1, 4);
    } else {
      accepted = true;
    }
  }
  if (!accepted || spare.isEmpty) return null;

  spare.sort();
  var three = spare[(spare.length * 3) ~/ 4];
  var two = spare[(spare.length * 2) ~/ 5];
  if (three < 2) three = 2;
  if (two < 1) two = 1;
  if (two >= three) two = three - 1;
  if (two < 1) {
    two = 1;
    three = 2;
  }

  final stage = StageConfig(
    id: probe.id,
    seed: stageSeed,
    moveLimit: moveLimit,
    preset: preset,
    objectives: [objective],
    stars: StarThresholds(twoMovesLeft: two, threeMovesLeft: three),
    generatorProfile: probe.generatorProfile,
  );
  return (stage, winRate, spare);
}

List<PresetCell> _samplePreset(double t, SeededRng rng) {
  final cells = <PresetCell>[];
  final taken = <int>{};

  void scatter(CellType type, int count, {int colorPool = 8}) {
    var placed = 0;
    var guard = 0;
    while (placed < count && guard < 200) {
      guard++;
      // Keep presets off the border row/col half the time for nicer shapes.
      final x = rng.nextInt(GridState.size);
      final y = rng.nextInt(GridState.size);
      final key = y * GridState.size + x;
      if (taken.contains(key)) continue;
      // Never pre-fill an entire line; cap any row/col at 5 preset cells.
      final rowCount =
          cells.where((c) => c.y == y).length;
      final colCount = cells.where((c) => c.x == x).length;
      if (rowCount >= 5 || colCount >= 5) continue;
      taken.add(key);
      cells.add(PresetCell(
        x: x,
        y: y,
        type: type,
        colorId: type == CellType.filled ? rng.nextInt(colorPool) : 0,
      ));
      placed++;
    }
  }

  final gems = (1 + (t * 6)).round().clamp(1, 7) + rng.nextInt(2);
  scatter(CellType.gem, gems.clamp(1, 8));
  final filler = (t * 7).round() + rng.nextInt(3);
  scatter(CellType.filled, filler);
  return cells;
}

Objective _sampleObjective(
    int index, double t, List<PresetCell> preset, SeededRng rng) {
  final gemCount =
      preset.where((c) => c.type == CellType.gem).length;
  // Keep the opening stretch pure "clear a gem" onboarding; introduce the
  // score-chase and line-sprint variety only once the player has settled in.
  if (index >= 12 && index % 7 == 6) {
    return Objective.clearLines(3 + (t * 7).round() + rng.nextInt(2));
  }
  if (index >= 8 && index % 5 == 4) {
    return Objective.reachScore(600 + (t * 2400).round() + rng.nextInt(200));
  }
  return Objective.clearGems(gemCount);
}

String _objLabel(StageConfig stage) {
  final json = stage.objectives.single.toJson();
  return '${json['type']} ${json['count'] ?? json['target']}';
}

String _pct(double v) => '${(v * 100).round()}%';
