package daemon

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"regexp"
	"sort"
	"sync"
	"time"

	"gordenko.dev/dima/fixme"
	"gordenko.dev/dima/flashscore"
	fsmodel "gordenko.dev/dima/flashscore/model"
	"gordenko.dev/dima/onexbet"
	"gordenko.dev/dima/pretty"
	"gordenko.dev/dima/tipper/model"
	"gordenko.dev/dima/web"
	"gordenko.dev/dima/web/api"
	"gordenko.dev/dima/ws"
	"gordenko.dev/dima/ws/publisher"
)

const (
	ChannelHandball = "handball"
	ChannelFootball = "football"
	ChannelTennis   = "tennis"

	// Флаги для округления тотала
	roundUp   = 0
	roundDown = 1

	// /SportFootball int64 = 1

	MarketTotalH1  = 1
	MarketTotal    = 2
	MarketHandicap = 3

	SideOver  = 1
	SideUnder = 2

	SideHome = 1
	SideAway = 2

	WSMessageTipsChanged = "tipsChanged"
	// WSMessageTeamMatches Обновленный список матчей после синхронизации с flashscore
	WSMessageTeamMatches         = "teamMatches"
	WSMessageWhyNot              = "whyNot"
	WSMessageInplayStatusChanged = "inplayStatusChanged"
)

var onexbetSportToFlashscoreSport = map[onexbet.OnexbetSport]int{
	onexbet.Football: flashscore.Football,
	onexbet.Handball: flashscore.Handball,
	onexbet.Tennis:   flashscore.Tennis,
}

var flashscoreSportToOnexbetSport = map[int]onexbet.OnexbetSport{
	flashscore.Football: onexbet.Football,
	flashscore.Handball: onexbet.Handball,
	flashscore.Tennis:   onexbet.Tennis,
}

var sportToTelegramChatID = map[int]string{
	flashscore.Football: footballTelegramChatID,
	flashscore.Handball: handballTelegramChatID,
	flashscore.Tennis:   tennisTelegramChatID,
}

type InplayStatusMessage struct {
	MatchID           string `json:"matchID"`
	IsInplay          bool   `json:"isInplay"`
	IsLinkedToOnexbet bool   `json:"isLinkedToOnexbet"`
}

type TeamMatchesMessage struct {
	SportID int         `json:"sportID"`
	Matches []TeamMatch `json:"matches"`
}

// flashscore sportID
func getChannel(sportID int) string {
	switch sportID {
	case flashscore.Football:
		return ChannelFootball

	case flashscore.Handball:
		return ChannelHandball

	case flashscore.Tennis:
		return ChannelTennis

	default:
		panic(fmt.Sprintf("Bug: channel is undefined for the sportID %d", sportID))
	}
}

type TipResult struct {
	Status model.TipStatus
	Result string
}

type StrategyOptions struct {
	ID               int64
	NotifyInTelegram bool
	IsChampAccepted  func(string) bool
}

type TeamStrategy interface {
	ID() int64
	ShortName() string
	// (report) notes, isInteresting
	IsInteresting(*TeamMatch) (interface{}, bool)
	// return -> comment why not,
	GetTip(*TeamMatch, onexbet.TeamLiveMatchData) (string, *BetDetails, TipCode)
	GetResult(model.Tip, onexbet.TeamMatchResult) *TipResult
	IsNotifyInTelegram() bool
	GetTelegramTipPattern() string
	IsNeedToWatch(onexbet.LiveMatch) bool
}

type ChampRatingKey struct {
	ChampID    string
	StrategyID int64
}

type ChampRating struct {
	Won  int
	Lost int
}

func (s *ChampRating) GetAccuracy() (accuracy float64) {
	// total = 100%
	// won   = x%
	// x = (won*100) / total
	total := s.Won + s.Lost
	if total > 0 {
		accuracy = float64(s.Won*100) / float64(total)
	}
	return
}

type FavoriteSide int

const (
	FavoriteHome FavoriteSide = 1
	FavoriteAway FavoriteSide = 2
)

type TeamMatch struct {
	// COMMON
	Score          onexbet.Score         `json:"score"`
	ScoreByPeriods map[int]onexbet.Score `json:"scoreByPeriods"`
	CurrentPeriod  int                   `json:"currentPeriod"`
	// TENNIS stats
	TwoSetsBreakpoints int `json:"twoSetsBreakpoints"`
	// Для стратегий где нужно определить доматчевого фаворита
	// 0 - фаворит не определен
	FavoriteSide               FavoriteSide        `json:"favoriteSide"`
	MaxFavoritHandicapIn1stSet *onexbet.ParamOffer `json:"maxFavoritHandicapIn1stSet"`
	// less than or equal 1.6 (для алгоритма 4 в теннисе)
	Lte16FavoriteSide FavoriteSide `json:"lte16FavoriteSide"`
	//IsTargetPriceIn2ndSetFound bool
	// Football
	Favorite string `json:"favorite,omitempty"`
	// Handball
	// Начальный тотал (по flashscore до матча)
	BasicTotal         float64 `json:"basicTotal"`
	H1BasicTotal       float64 `json:"h1BasicTotal"`
	HomeGoalsPerMinute float64 `json:"homeGoalsPerMinute"`
	AwayGoalsPerMinute float64 `json:"awayGoalsPerMinute"`
	H2HGoalsPerMinute  float64 `json:"h2hGoalsPerMinute"`
	GamePace           float64 `json:"-"`
	GamePaceFrame      int     `json:"-"`
	// Main
	MatchID             string                 `json:"matchID"`
	Home                fsmodel.Team           `json:"home"`
	Away                fsmodel.Team           `json:"away"`
	Zone                fsmodel.TeamZone       `json:"zone"`
	Champ               fsmodel.TeamChamp      `json:"champ"`
	FullChampName       string                 `json:"fullChampName"`
	IsLinkedToOnexbet   bool                   `json:"isLinkedToOnexbet"`
	IsInplay            bool                   `json:"isInplay"` // узнать - отписались от обновлений или нет?
	SportID             int                    `json:"sportID"`
	Strategies          map[int64]TeamStrategy `json:"-"`
	StartTime           int64                  `json:"startTime"`
	ParseTime           time.Time              `json:"parseTime"`
	OnexbetMatchID      string                 `json:"onexbetMatchID"`
	OnexbetStatsMatchID string                 `json:"onexbetStatsMatchID"`
	IsTeamsSwapped      bool                   `json:"isTeamsSwapped"`
	Notes               []StrategyNotes        `json:"notes"`
	//TipTotalOver  float64 `json:"tipTotalOver"`
	//TipTotalUnder float64 `json:"tipTotalUnder"`
	// Ожимаемый темп игры (голов в минуту). Начальный тотал / 60 минут
	//BasicGoalsPerMinute float64 `json:"basicGoalsPerMinute"`
	//BasicOverGoalsPerMinute  float64 `json:"basicOverGoalsPerMinute"`
	//BasicUnderGoalsPerMinute float64 `json:"basicUnderGoalsPerMinute"`
	// Статистика для расчета
	HomeTeamMatches []fsmodel.TeamMatch `json:"homeTeamMatches"`
	AwayTeamMatches []fsmodel.TeamMatch `json:"awayTeamMatches"`
	H2H             []fsmodel.TeamMatch `json:"h2h"`
	Odds            flashscore.Odds     `json:"odds"`
}

type StrategyNotes struct {
	StrategyID int64       `json:"strategyID"`
	Notes      interface{} `json:"notes"`
}

type TipCode int

const (
	Waiting TipCode = 0
	Unwatch TipCode = 1
	Bet     TipCode = 2
)

type BetDetails struct {
	Market          int
	Side            int
	Param           string
	Price           float64
	TelegramMessage string
}

/*
var tipTextAlgo100Pattern = `Сигнал # %d.
Будет ГОЛ!
Алгоритм 100
Первый тайм, Тотал Больше 0.5
Футбол. %s. %s
%s - %s
Коэф. %.3f`
*/

var tipResultTextPattern = `Сигнал # %d.
%s
%s
`

const (
	EmptyTipStatus fixme.Code = "T1"
	WrongTipStatus fixme.Code = "T2"
	EmptyValue     fixme.Code = "T3"
)

var errorMessages = map[fixme.Code]string{
	EmptyTipStatus: "не указан статус",
	WrongTipStatus: "неверный статус",
	EmptyValue:     "пустое значение недопустимо",
}

var fix = fixme.New(errorMessages)

type Options struct {
	Logger            *log.Logger
	WsPublicServer    *ws.PublicServer
	FlashscoreAPIAddr string
	OnexbetWsURL      string
	OnexbetAPIURL     string
	SportIDs          []int
	FlashscoreModel   *fsmodel.Model
	TipperModel       *model.Model
}

type Daemon struct {
	mutex            sync.Mutex
	fsAPIClient      *api.Client
	onexbetClient    *ws.Client
	logger           *log.Logger
	model            *model.Model
	wsServer         *ws.PublicServer
	publisher        *publisher.PublicPublisher
	onexbetAPIClient *api.Client
	fsmodel          *fsmodel.Model
	champRatings     map[int]map[ChampRatingKey]*ChampRating //map[int64]map[string]*ChampRating
	//handballChampRatings map[ChampRatingKey]*ChampRating
	// Отобаранные матчи по видам спорта
	teamMatches                map[int]map[string]*TeamMatch
	onexbetMatchToTeamMatch    map[string]*TeamMatch
	sportStrategies            map[int]map[int64]TeamStrategy
	historyProcessors          map[int]func(*TeamMatch)
	sportIDs                   []int
	unrecognizedOnexbetMatches map[int]map[string]bool
	identifiedMatches          map[string]bool // 1xbet MatchID => true
}

func New(opt Options) (*Daemon, error) {
	s := new(Daemon)
	s.logger = opt.Logger
	s.sportIDs = opt.SportIDs
	s.identifiedMatches = make(map[string]bool)
	s.model = opt.TipperModel
	s.fsmodel = opt.FlashscoreModel

	// TELEGRAM, topChamps
	/*
		s.footballStrategies[11] = NewP1Over05(StrategyOptions{
			ID:               11,
			NotifyInTelegram: true,
			IsChampAccepted:  acceptedChamps(topChamps),
		})
		s.footballStrategies[22] = NewOver05(StrategyOptions{
			ID:               22,
			NotifyInTelegram: true,
			IsChampAccepted:  acceptedChamps(topChamps),
		})
		s.footballStrategies[33] = NewOver15(StrategyOptions{
			ID:               33,
			NotifyInTelegram: true,
			IsChampAccepted:  acceptedChamps(topChamps),
		})

		// Мои стратегии - ТМ для всех чемпионатов, кроме топовых
		s.footballStrategies[701] = NewP1Under05(StrategyOptions{
			ID:               701,
			NotifyInTelegram: false,
			IsChampAccepted:  nonAcceptedChamps(topChamps),
		})
		s.footballStrategies[702] = NewUnder05(StrategyOptions{
			ID:               702,
			NotifyInTelegram: false,
			IsChampAccepted:  nonAcceptedChamps(topChamps),
		})
		s.footballStrategies[703] = NewUnder15(StrategyOptions{
			ID:               703,
			NotifyInTelegram: false,
			IsChampAccepted:  nonAcceptedChamps(topChamps),
		})
	*/

	if opt.FlashscoreAPIAddr == "" {
		return nil, errors.New("FlashscoreAPIAddr option is required")
	}

	var err error
	s.fsAPIClient, err = api.NewClient(api.ClientOptions{
		URL:          opt.FlashscoreAPIAddr,
		BuildRequest: api.BuildEnvelopeRequest,
	})
	if err != nil {
		return nil, fmt.Errorf("api.NewClient(flashscore): %s", err)
	}

	s.onexbetClient, err = ws.NewClient(ws.ClientOptions{
		URL:    opt.OnexbetWsURL,
		Logger: opt.Logger,
	})
	if err != nil {
		return nil, fmt.Errorf("ws.NewClient(onexbet): %s", err)
	}

	s.onexbetClient.OnConnected(s.onOnexbetWsClientConnected)
	s.onexbetClient.Handle(onexbet.WsMsgLiveMatches, s.onOnexbetTeamLiveMatches)
	s.onexbetClient.Handle(onexbet.WsMsgTeamLiveMatchData, s.onOnexbetTeamLiveMatchData)
	s.onexbetClient.Handle(onexbet.WsMsgTennisLiveMatchData, s.onOnexbetTeamLiveMatchData)

	s.wsServer = opt.WsPublicServer

	s.wsServer.OnConnected(s.onWsClientConnected)
	s.wsServer.OnDisconnected(s.onWsClientDisconnected)

	s.publisher, err = publisher.NewPublicPublisher(publisher.PublicPublisherOptions{
		WsPublicServer: s.wsServer,
	})

	s.publisher.OnTopicSubscribeRequest(ChannelHandball, s.onWsSubscribeRequestToHandball)
	s.publisher.OnTopicSubscribeRequest(ChannelFootball, s.onWsSubscribeRequestToFootball)
	s.publisher.OnTopicSubscribeRequest(ChannelTennis, s.onWsSubscribeRequestToTennis)

	s.onexbetAPIClient, err = api.NewClient(api.ClientOptions{
		URL:          opt.OnexbetAPIURL,
		BuildRequest: api.BuildEnvelopeRequest,
	})
	if err != nil {
		return nil, fmt.Errorf("api.NewClient(onexbet): %s", err)
	}

	s.teamMatches = make(map[int]map[string]*TeamMatch)
	s.onexbetMatchToTeamMatch = make(map[string]*TeamMatch)
	s.sportStrategies = make(map[int]map[int64]TeamStrategy)
	s.unrecognizedOnexbetMatches = make(map[int]map[string]bool)
	s.champRatings = make(map[int]map[ChampRatingKey]*ChampRating)

	for _, sportID := range s.sportIDs {
		s.teamMatches[sportID] = make(map[string]*TeamMatch)
		s.sportStrategies[sportID] = make(map[int64]TeamStrategy)
		s.unrecognizedOnexbetMatches[sportID] = make(map[string]bool)
		s.champRatings[sportID] = make(map[ChampRatingKey]*ChampRating)
	}

	// Набор статистики по всем чемпионатам

	// FOOTBALL
	/*
		s.sportStrategies[flashscore.Football][1] = NewP1Over05(StrategyOptions{
			ID:               1,
			NotifyInTelegram: true,
		})
		s.sportStrategies[flashscore.Football][2] = NewOver05(StrategyOptions{
			ID:               2,
			NotifyInTelegram: true,
		})
		s.sportStrategies[flashscore.Football][3] = NewOver15(StrategyOptions{
			ID:               3,
			NotifyInTelegram: true,
		})
	*/

	s.sportStrategies[flashscore.Football][6] = NewFootballP1Over05(StrategyOptions{
		ID:               6,
		NotifyInTelegram: true,
	})

	s.sportStrategies[flashscore.Football][7] = NewFootballOver05(StrategyOptions{
		ID:               7,
		NotifyInTelegram: true,
	})

	s.sportStrategies[flashscore.Football][8] = NewFootballOver15(StrategyOptions{
		ID:               8,
		NotifyInTelegram: true,
	})

	// HANDBALL
	s.sportStrategies[flashscore.Handball][4] = NewHandballFTOver(HandballStrategyOptions{
		ID:                          4,
		NotifyInTelegram:            true,
		TotalDiff:                   10,
		FixateGamePaceEveryNMinutes: 5,
	})

	s.sportStrategies[flashscore.Handball][5] = NewHandballFTUnder(HandballStrategyOptions{
		ID:                          5,
		NotifyInTelegram:            true,
		TotalDiff:                   10,
		FixateGamePaceEveryNMinutes: 5,
	})

	// HANDBALL
	s.sportStrategies[flashscore.Handball][11] = NewHandballP1Over(HandballStrategyOptions{
		ID:                          11,
		NotifyInTelegram:            true,
		TotalDiff:                   7,
		FixateGamePaceEveryNMinutes: 5,
	})

	s.sportStrategies[flashscore.Handball][12] = NewHandballP1Under(HandballStrategyOptions{
		ID:                          12,
		NotifyInTelegram:            true,
		TotalDiff:                   7,
		FixateGamePaceEveryNMinutes: 5,
	})
	/*
		s.sportStrategies[flashscore.Handball][13] = NewHandballP1Over(HandballStrategyOptions{
			ID:                          13,
			NotifyInTelegram:            true,
			TotalDiff:                   5,
			FixateGamePaceEveryNMinutes: 5,
		})

		s.sportStrategies[flashscore.Handball][14] = NewHandballP1Under(HandballStrategyOptions{
			ID:                          14,
			NotifyInTelegram:            true,
			TotalDiff:                   5,
			FixateGamePaceEveryNMinutes: 5,
		})
	*/

	// TENNIS
	s.sportStrategies[flashscore.Tennis][15] = NewTennis3rdSetTotalUnder(StrategyOptions{
		ID:               15,
		NotifyInTelegram: true,
	})

	s.sportStrategies[flashscore.Tennis][16] = NewTennis2ndSetPlusHandicap(StrategyOptions{
		ID:               16,
		NotifyInTelegram: true,
	})

	s.sportStrategies[flashscore.Tennis][17] = NewTennis2ndSetPlusHandicapAny(StrategyOptions{
		ID:               17,
		NotifyInTelegram: true,
	})

	s.sportStrategies[flashscore.Tennis][18] = NewTennis2ndSetFavoriteHandicap(StrategyOptions{
		ID:               18,
		NotifyInTelegram: true,
	})

	s.historyProcessors = make(map[int]func(*TeamMatch))

	s.historyProcessors[flashscore.Handball] = HandballHistoryProcessor

	// Формируем рейтинг для футбольных стратегий
	for sportID, sportStrategies := range s.sportStrategies {

		champRatings := s.champRatings[sportID]

		for strategyID := range sportStrategies {
			var stats []model.StrategyChampStats

			stats, err = s.model.ListStrategyChampStats(strategyID)
			if err != nil {
				return nil, fmt.Errorf("model.ListStrategyChampStats(%d): %s",
					strategyID, err)
			}

			//buf, _ := json.MarshalIndent(stats, "", "    ")
			//s.logger.Printf("Stats (%d):\n%s\n\n", strategyID, buf)

			for _, champ := range stats {
				key := ChampRatingKey{
					ChampID:    champ.ChampID,
					StrategyID: strategyID,
				}
				champRatings[key] = &ChampRating{
					Won:  champ.Won,
					Lost: champ.Lost,
				}
			}
		}
	}

	return s, nil
}

func (s *Daemon) ListRatedChamps(state *web.State, reply *web.Reply) (err error) {
	var sportID int

	state.Val("sportID", &sportID)

	s.mutex.Lock()
	defer s.mutex.Unlock()

	ratings, ok := s.champRatings[sportID]
	if !ok {
		return
	}

	str := ""

	type ChampAndRating struct {
		ChampID    string
		StrategyID int64
		Won        int
		Lost       int
		Accuracy   float64
	}

	var sorted []ChampAndRating

	for key, rating := range ratings {
		//if (rating.Won+rating.Lost) >= 6 && rating.GetAccuracy() >= 75 {
		if rating.GetAccuracy() >= 70 {
			sorted = append(sorted, ChampAndRating{
				ChampID:    key.ChampID,
				StrategyID: key.StrategyID,
				Won:        rating.Won,
				Lost:       rating.Lost,
				Accuracy:   rating.GetAccuracy(),
			})
		}
	}

	sort.Slice(sorted, func(i, j int) bool {
		a := sorted[i]
		b := sorted[j]

		if a.StrategyID == b.StrategyID {
			return a.Accuracy < b.Accuracy
		}

		return a.StrategyID < b.StrategyID
	})

	for _, champ := range sorted {
		str += fmt.Sprintf("<tr><td>%s (стратегия %d):</td><td>won=%d, lost=%d, accuracy=%.2f</td></tr>", champ.ChampID, champ.StrategyID, champ.Won, champ.Lost, champ.Accuracy)
	}

	//reply.SetHeader("Content-Type", "text/pain")
	reply.WriteString(`<table style="border-spacing: 10px; border: 0px;">` + str + `</table>`)
	return
}

func (s *Daemon) TryLoadAndSetTipResults() {
	tips, err := s.model.ListWaitingTips()
	if err != nil {
		err = fmt.Errorf("ListWaitingTips: %s", err)
		return
	}

	s.logger.Println(pretty.PSprintln("Waiting Tips", tips))

	// Час назад
	//aHourAgo := time.Now().Unix() - 3600

	for _, tip := range tips {
		// Если матч начался менее часа назад - пропускаем.
		//if tip.TipTime > aHourAgo {
		//	continue
		//}

		err = s.tryLoadAndSetTipResult(tip)
		if err != nil {
			s.logger.Printf("trySetTipResult: %s; tipID=%d", err, tip.TipID)
		}
	}
}

func (s *Daemon) Run() {
	go s.flashscoreLoader()
	go s.tipResultsLoader()
	go s.oldMatchesRemover()
}

func (s *Daemon) oldMatchesRemover() {
	interval := 30 * time.Minute

	ticker := time.NewTicker(interval)

	for {
		select {
		case <-ticker.C:
			s.removeOldMatches()
		}
	}
}

func (s *Daemon) tipResultsLoader() {
	interval := 20 * time.Minute

	ticker := time.NewTicker(interval)

	s.TryLoadAndSetTipResults()

	for {
		select {
		case <-ticker.C:
			s.TryLoadAndSetTipResults()
		}
	}
}

func (s *Daemon) flashscoreLoader() {
	interval := 1 * time.Hour

	ticker := time.NewTicker(interval)

	for _, sportID := range s.sportIDs {
		err := s.listUpcomingMatchReports(sportID)
		if err != nil {
			s.logger.Printf("listUpcomingMatchReports(sportID=%d): %s\n",
				sportID, err)
		}
	}

	s.onexbetClient.ConnectAsync()

	for {
		select {
		case <-ticker.C:
			for _, sportID := range s.sportIDs {
				err := s.listUpcomingMatchReports(sportID)
				if err != nil {
					s.logger.Printf("listUpcomingMatchReports(sportID=%d): %s\n",
						sportID, err)
				}
			}
		}
	}
}

func (s *Daemon) removeOldMatches() {
	s.mutex.Lock()
	defer s.mutex.Unlock()

	a2HoursAgo := time.Now().Unix() - 7200

	for sportID, sportMatches := range s.teamMatches {
		var count int
		for _, match := range sportMatches {
			_, isInInplay := s.onexbetMatchToTeamMatch[match.OnexbetMatchID]

			if match.StartTime < a2HoursAgo && !isInInplay {
				// Если матч стартовал более 2 часов назад и не в Inplay - удаляем
				delete(sportMatches, match.MatchID)
				//delete(s.identifiedMatches, match.OnexbetMatchID)
				count++
			}
		}

		// Отправляем уведомления
		if count > 0 {
			s.publisher.Publish(
				getChannel(sportID),
				WSMessageTeamMatches,
				s.getTeamMatchesMessage(sportID),
			)
		}
	}
}

func (s *Daemon) listUpcomingMatchReports(sportID int) (err error) {
	var buf []byte
	err = s.fsAPIClient.Call(api.CallReq{
		FuncName: "listUpcomingMatchReports",
		In:       sportID,
		Out:      &buf,
	})
	if err != nil {
		return
	}

	var reports []flashscore.UpcomingMatchReport

	err = json.Unmarshal(buf, &reports)
	if err != nil {
		return
	}

	now := time.Now().Unix() - 10800

	s.mutex.Lock()
	defer s.mutex.Unlock()

	sportStrategies := s.sportStrategies[sportID]

	fmt.Printf("Sport strategies qty: %d\n", len(sportStrategies))

	teamMatches := s.teamMatches[sportID]

	// Сохраняем в этот словарь все matchID из reports, чтобы сделав проход по teamMatches,
	// обнаружить матчи, которых больше нет в reports
	//checkedMatchIDs := make(map[string]bool)

	// Синхронизация:
	// - матчи из teamMatches, которые по времени начались - пропускаем
	// - матчи из reports, которые по времени начались - пропускаем
	// - матчи, которых нет в teamMatches - добавляем
	// - матчи, которые есть в teamMatches (и еще не начались), но нет в reports - удаляем
	// - матчи, которые есть и там и там, но не начались - обнуляем список стратегий
	// и прогоняем с нуля. Если ни одной стратегии не удовлетворяем - удаляем

	// Первый проход по reports
	for _, report := range reports {
		//checkedMatchIDs[report.MatchID] = true

		if report.StartTime <= now {
			// матч уже начался - пропускаем
			s.logger.Printf("match already started: %d < now %d\n", report.StartTime, now)
			continue
		}

		match, ok := teamMatches[report.MatchID]
		if ok {
			if match.StartTime <= now {
				// матч уже начался - пропускаем
				s.logger.Printf("match already started: %d < now %d\n", match.StartTime, now)
				continue
			}

			if match.IsLinkedToOnexbet {
				// матч уже в линии 1xBet и распознан - пропускаем
				s.logger.Printf("match %s already linked\n", match.MatchID)
				continue
			}
			// Обнуляем стратегии чтобы пересканировать заново
			match.Strategies = make(map[int64]TeamStrategy)

			match.ParseTime = report.ParseTime

			match.HomeTeamMatches = report.HomeTeamMatches
			match.AwayTeamMatches = report.AwayTeamMatches
			match.H2H = report.H2H
			match.Odds = report.Odds

			match.Notes = nil

		} else {
			// Чтобы звездочками отметить матчи, которые не были ни разу распознаны
			home, err := s.fsmodel.GetTeam(report.Home.TeamID)
			if err != nil {
				s.logger.Printf("fsmodel.GetTeam: %s; teamID=%s", err, report.Home.TeamID)
			} else {
				report.Home.WasLinked = home.WasLinked
			}

			away, err := s.fsmodel.GetTeam(report.Away.TeamID)
			if err != nil {
				s.logger.Printf("fsmodel.GetTeam: %s; teamID=%s", err, report.Away.TeamID)
			} else {
				report.Away.WasLinked = away.WasLinked
			}

			// Добавляем новый матч
			match = &TeamMatch{
				MatchID:         report.MatchID,
				Home:            report.Home,
				Away:            report.Away,
				Zone:            report.Zone,
				Champ:           report.Champ,
				FullChampName:   report.FullChampName,
				StartTime:       report.StartTime,
				ParseTime:       report.ParseTime,
				SportID:         sportID,
				Strategies:      make(map[int64]TeamStrategy),
				HomeTeamMatches: report.HomeTeamMatches,
				AwayTeamMatches: report.AwayTeamMatches,
				H2H:             report.H2H,
				Odds:            report.Odds,
			}
			teamMatches[report.MatchID] = match

			s.logger.Printf("match %s added\n", match.MatchID)
		}

		historyProcessor := s.historyProcessors[sportID]
		if historyProcessor != nil {
			historyProcessor(match)
		}

		for _, strategy := range sportStrategies {
			// Сперва проверяем не было ли уже прогнозов на этот матч и эту стратегию
			var hasTip bool

			hasTip, err = s.model.HasTip(match.MatchID, strategy.ID())
			if err != nil {
				s.logger.Printf("model.HasTip: %s\n", err)
			} else {
				if !hasTip {
					notes, isInteresting := strategy.IsInteresting(match)
					if isInteresting {
						match.Strategies[strategy.ID()] = strategy

						match.Notes = append(match.Notes, StrategyNotes{
							StrategyID: strategy.ID(),
							Notes:      notes,
						})
					}
				}
			}
		}

		if len(match.Strategies) == 0 {
			// матч никому не интересен - удаляем
			s.logger.Printf("match %s not interesting -> remove\n", match.MatchID)
			delete(teamMatches, report.MatchID)
		}
	}

	/*
		// Второй проход по teamMatches
		for matchID, match := range teamMatches {
			if checkedMatchIDs[matchID] {
				continue
			}
			// Матча не было в reports - какндидат на удаление
			if match.StartTime <= now {
				// матч уже начался - пропускаем
				continue
			}

			if match.IsLinkedToOnexbet {
				// матч уже в линии 1xBet и распознан - пропускаем
				continue
			}

			delete(teamMatches, matchID)
		}
	*/

	s.publisher.Publish(getChannel(sportID),
		WSMessageTeamMatches, s.getTeamMatchesMessage(sportID))
	return
}

func (s *Daemon) getTeamMatchesMessage(sportID int) (msg TeamMatchesMessage) {
	teamsMatches, ok := s.teamMatches[sportID]
	if !ok {
		panic(fmt.Sprintf("Bug: getTeamMatchesMessage of unknown sportID %d\n", sportID))
	}

	msg.SportID = sportID

	for _, match := range teamsMatches {
		msg.Matches = append(msg.Matches, *match)
	}
	return
}

func (s *Daemon) tryLoadAndSetTipResult(tip model.Tip) (err error) {
	if tip.OnexbetStatsMatchID == "" {
		err = fmt.Errorf("Empty OnexbetStatsMatchID")
		return
	}

	var matchResult onexbet.TeamMatchResult

	if tip.SportID == flashscore.Tennis {
		matchResult, err = onexbet.GetTennisMatchResult(tip.OnexbetStatsMatchID)
		if err != nil {
			err = fmt.Errorf("onexbet.GetTeamMatchResult: %s", err)
			return
		}
	} else {
		matchResult, err = onexbet.GetTeamMatchResult(tip.OnexbetStatsMatchID)
		if err != nil {
			err = fmt.Errorf("onexbet.GetTeamMatchResult: %s", err)
			return
		}
	}

	s.logger.Printf("%s result: %s\n", tip.OnexbetStatsMatchID, pretty.Sprint(matchResult))

	return s.trySetTipResult(tip, matchResult)
}

func swapTeamMatchResult(r onexbet.TeamMatchResult) onexbet.TeamMatchResult {
	r.AwayPoints, r.HomePoints = r.HomePoints, r.AwayPoints

	for idx, p := range r.ScoreByPeriods {
		p.AwayPoints, p.HomePoints = p.HomePoints, p.AwayPoints

		r.ScoreByPeriods[idx] = p
	}

	return r
}

func (s *Daemon) trySetTipResult(tip model.Tip, matchResult onexbet.TeamMatchResult) (err error) {
	sportStrategies, ok := s.sportStrategies[int(tip.SportID)]
	if !ok {
		err = fmt.Errorf("No strategies found for the sportID %d", tip.SportID)
		return
	}

	strategy, ok := sportStrategies[tip.StrategyID]
	if !ok {
		s.logger.Printf("Unknown strategyID %d", tip.StrategyID)
		return
	}

	if tip.IsTeamsSwapped {
		matchResult = swapTeamMatchResult(matchResult)
	}

	result := strategy.GetResult(tip, matchResult)
	if result == nil {
		err = fmt.Errorf("strategy.GetResult returns nil")
		return
	}

	//s.logger.Printf(pretty.PSprintln)

	err = s.model.SetTipResult(model.SetTipResultReq{
		TipID:  tip.TipID,
		Status: result.Status,
		Result: result.Result,
	})
	if err != nil {
		err = fmt.Errorf("model.SetTipResult: %s", err)
		return
	}

	s.notifyAboutTipResult(tip, result, strategy)
	return
}

func (s *Daemon) notifyAboutTipResult(tip model.Tip, result *TipResult, strategy TeamStrategy) (err error) {
	var outcome string

	switch result.Status {
	case model.Won:
		outcome = "Выигрыш"

	case model.Lost:
		outcome = "Проигрыш"

	case model.Void:
		outcome = "Возврат"
	}

	msg := fmt.Sprintf(tipResultTextPattern, tip.TipID, result.Result, outcome)

	// Обновляем рейтинг чемпионатов
	key := ChampRatingKey{
		ChampID:    tip.ChampID,
		StrategyID: tip.StrategyID,
	}

	champRatings, ok := s.champRatings[int(tip.SportID)]
	if ok {
		rating, ok := champRatings[key]
		if !ok {
			rating = &ChampRating{}
			champRatings[key] = rating
		}

		if result.Status == model.Won {
			rating.Won++
		} else if result.Status == model.Lost {
			rating.Lost++
		}
	}

	if strategy.IsNotifyInTelegram() {
		// дополнительная проверка на isRated. У тенниса нет рейтингов по чемпионатам,
		// поэтому проверка более хитрая.
		var ok bool

		switch tip.SportID {
		case flashscore.Tennis:
			ok = true

		case flashscore.Handball, flashscore.Football:
			if tip.IsRated {
				ok = true
			}
		}

		if ok {
			telegramChatID, hasChat := sportToTelegramChatID[int(tip.SportID)]
			if hasChat {
				err = sendMessageToTelegramGroup(telegramChatID, msg)
				if err != nil {
					s.logger.Printf("send tip result to Telegram: %s\n", err)
				}
			} else {
				s.logger.Printf("Sport %d hasn't telegram chat\n", tip.SportID)
			}
		}
	}

	s.publisher.Publish(getChannel(int(tip.SportID)), WSMessageTipsChanged, nil)
	return
}

func (s *Daemon) SetTipResultManual(in model.SetTipResultReq) (err error) {
	tip, err := s.model.GetTip(in.TipID)
	if err != nil {
		return
	}

	if tip == nil {
		s.logger.Printf("setTipResultManual: tipID=%d not found in db\n", in.TipID)
		return
	}

	switch in.Status {
	case 0:
		err = fix.Field(EmptyTipStatus, "status")
		return

	case model.Won, model.Lost, model.Void:
		// pass
	default:
		err = fix.Field(WrongTipStatus, "status")
		return
	}

	err = s.model.SetTipResult(in)
	if err != nil {
		return
	}

	// Если стратегии не найдены - сообщение в телеграм отправлено не будет
	sportStrategies, ok := s.sportStrategies[int(tip.SportID)]
	if !ok {
		err = fmt.Errorf("No strategies found for the sportID %d", tip.SportID)
		return
	}

	strategy, ok := sportStrategies[tip.StrategyID]
	if !ok {
		s.logger.Printf("Unknown strategyID %d", tip.StrategyID)
		return
	}

	s.notifyAboutTipResult(
		*tip,
		&TipResult{Status: in.Status, Result: in.Result},
		strategy,
	)
	return
}

func (s *Daemon) onWsSubscribeRequestToHandball(conn *ws.Conn, channel, arg string) {
	s.mutex.Lock()
	defer s.mutex.Unlock()

	s.logger.Printf("ws client subscribes to channel %q\n", channel)

	conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Handball))

	s.publisher.SubscribeTo(conn, channel)
}

func (s *Daemon) onWsSubscribeRequestToFootball(conn *ws.Conn, channel, arg string) {
	s.mutex.Lock()
	defer s.mutex.Unlock()

	s.logger.Printf("ws client subscribes to channel %q\n", channel)

	conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Football))

	s.publisher.SubscribeTo(conn, channel)
}

func (s *Daemon) onWsSubscribeRequestToTennis(conn *ws.Conn, channel, arg string) {
	s.mutex.Lock()
	defer s.mutex.Unlock()

	s.logger.Printf("ws client subscribes to channel %q\n", channel)

	conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Tennis))

	s.publisher.SubscribeTo(conn, channel)
}

// func (s *Daemon) onWsSubscribeRequest(conn *ws.Conn, channel string) {
// 	s.mutex.Lock()
// 	defer s.mutex.Unlock()

// 	s.logger.Printf("ws client subscribes to channel %q\n", channel)

// 	switch channel {
// 	case ChannelHandball:
// 		conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Handball))

// 	case ChannelFootball:
// 		conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Football))

// 	case ChannelTennis:
// 		conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Tennis))
// 	}

// 	s.publisher.SubscribeTo(conn, channel)
// }

func (s *Daemon) onWsClientConnected(conn *ws.Conn) {
	//s.mutex.Lock()
	//defer s.mutex.Unlock()

	s.logger.Printf("ws client connected\n")

	//for _, sportID := range s.sportIDs {
	//	conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(sportID))
	//}

	//s.logger.Printf("team matches sent\n")

	//s.publisher.SubscribeTo(conn, Channel)
}

func (s *Daemon) onWsClientDisconnected(conn *ws.Conn) {
	s.mutex.Lock()
	defer s.mutex.Unlock()

	s.logger.Printf("ws client disconnected\n")

	//s.publisher.UnsubscribeConn(conn)
}

// Регулярка для вырезания буквы W из названия команды.
// Поиск на 1xBet не дает результатов если в названии есть W, например Ajax W
var reW = regexp.MustCompile(`\bW\b`)

type SearchMatchCandidatesIn struct {
	SportID int    `json:"sportID"`
	Home    string `json:"home"`
	Away    string `json:"away"`
}

func (s *Daemon) SearchMatchCandidates(in SearchMatchCandidatesIn) (matches []onexbet.MatchCandidate, err error) {
	in.Home = reW.ReplaceAllString(in.Home, "")
	in.Away = reW.ReplaceAllString(in.Away, "")

	switch in.SportID {
	case flashscore.Football:
		return onexbet.SearchMatchCandidates(in.Home, in.Away, onexbet.Football)

	case flashscore.Handball:
		return onexbet.SearchMatchCandidates(in.Home, in.Away, onexbet.Handball)

	case flashscore.Tennis:
		return onexbet.SearchMatchCandidates(in.Home, in.Away, onexbet.Tennis)
	}
	return
}

type SearchMatchCandidatesByTeamNameIn struct {
	SportID  int    `json:"sportID"`
	TeamName string `json:"teamName"`
}

func (s *Daemon) SearchMatchCandidatesByTeamName(in SearchMatchCandidatesByTeamNameIn) (matches []onexbet.MatchCandidate, err error) {
	in.TeamName = reW.ReplaceAllString(in.TeamName, "")

	return onexbet.SearchTeamCandidates(in.TeamName, flashscoreSportToOnexbetSport[in.SportID])
}

type AddTeamsAliasesIn struct {
	SportID int
	Home    fsmodel.TeamAlias
	Away    fsmodel.TeamAlias
}

func (s *Daemon) AddTeamsAliases(in AddTeamsAliasesIn) (err error) {
	err = s.fsmodel.AddTeamAlias(in.Home)
	if err != nil {
		apierr, ok := err.(fixme.Field)
		if !ok {
			return
		}
		if apierr.Code != fsmodel.Duplicate {
			return
		}
		err = nil
	}

	err = s.fsmodel.AddTeamAlias(in.Away)
	if err != nil {
		apierr, ok := err.(fixme.Field)
		if !ok {
			return
		}
		if apierr.Code != fsmodel.Duplicate {
			return
		}
		err = nil
	}

	s.mutex.Lock()
	defer s.mutex.Unlock()

	// Сбрасываем кэш нераспознанных 1xBet матчей
	s.unrecognizedOnexbetMatches[in.SportID] = make(map[string]bool)
	return
}

func (s *Daemon) ListSportTips(sportID int) (tips []model.Tip, err error) {
	tips, err = s.model.ListSportTips(sportID)
	if err != nil {
		return
	}

	for idx, tip := range tips {
		strategies, foundSport := s.sportStrategies[int(tip.SportID)]
		if foundSport {
			strategy, has := strategies[tip.StrategyID]
			if has {
				tip.Strategy = strategy.ShortName()

				tips[idx] = tip
			}
		}
	}

	return
}
